2

When I have a readonly variable:

public readonly bool myVar = true;

And check it in a code like this:

for(int i = 0; i != 10; i++)
{
    if(myVar)
        DoStuff();
    else
        DoOtherStuff();
}

Looking at emitted IL, I can see that the check is performed on each iteration of the loop. I would expect the code above to produce the same IL as this:

if (myVar)
{
    for(int i = 0; i != 10; i++)
    {
        DoStuff();
    }
}
else
{
    for(int i = 0; i != 10; i++)
    {
        DoOtherStuff();
    }
}

So why isn't the check optimised to the outside of the loop, since the field is readonly and can't be changed between iterations?

defaultUsernameN
  • 365
  • 5
  • 14
  • Because `readonly` is only a compile-time rule. It doesn't actually _guarantee_ anything. See duplicate. Optimizations _must_ not break legal code, no matter how ill-advised. – Peter Duniho Apr 01 '21 at 22:29
  • 1
    Also note that checking the generated CIL does not really show anything. The generated native code is what matters, and I am fairly sure I have seen this optimization happen there. – IS4 Apr 01 '21 at 23:05

3 Answers3

4

Because it can be changed using Reflection:

using System;
using System.Reflection;

public class Program
{
    public static void Main()
    {
        var t = new Test();
        var field = typeof(Test).GetField("myVar", BindingFlags.Instance | BindingFlags.Public);

        Console.WriteLine(t.myVar); // prints True

        field.SetValue(t, false);
        Console.WriteLine(t.myVar); // prints False

        // Trying to use t.myVar = false or true; <-- does not compile
    }
}

public class Test
{
    public readonly bool myVar = true;
}

Working Fiddle: https://dotnetfiddle.net/W9UO3m

Note there is no way in which the compile time code optimizer can predict or detect with absolute certainty whether or not such reflection code exists, and if it exists, if it will run or not.

Peter B
  • 22,460
  • 5
  • 32
  • 69
  • This is a well-thought-out answer and presents a valid point, however: I've yet to run across a case where an optimization is intentionally disabled just to keep reflection happy. Got any other examples? – 3Dave Apr 01 '21 at 22:23
  • @3Dave It might be slightly contrived, but it does give a good reason why the compiler cannot optimise it away – DavidG Apr 01 '21 at 22:26
  • 2
    Breaking invariants using Reflection leads to undefined behavior. See for example https://stackoverflow.com/a/19966051/103167 – Ben Voigt Apr 01 '21 at 22:36
4

Your proposed optimization really is a combination of two individual simpler transformations. First is pulling the member access outside the loop. From

for(int i = 0; i != 10; i++)
{
    var localVar = this.memberVar;
    if(localVar)
        DoStuff();
    else
        DoOtherStuff();
}

to

var localVar = this.memberVar;
for(int i = 0; i != 10; i++)
{
    if(localVar)
        DoStuff();
    else
        DoOtherStuff();
}

The second is interchanging the loop condition with the if condition. From

var localVar = this.memberVar;
for(int i = 0; i != 10; i++)
{
    if(localVar)
        DoStuff();
    else
        DoOtherStuff();
}

to

var localVar = this.memberVar;
if (localVar) {
    for(int i = 0; i != 10; i++)
        DoStuff();
}
else {
    for(int i = 0; i != 10; i++)
        DoOtherStuff();
}

The first one is influenced by readonly. To do it, the compiler has to prove that memberVar cannot change inside the loop, and readonly guarantees this1 -- even though this loop could be called inside a constructor, and the value of memberVar could be changed in the constructor after the loop ends, it cannot be changed in the loop body -- DoStuff() is not a constructor of the current object, neither is DoOtherStuff(). Reflection does not count, while it may be possible to use Reflection to break invariants, it isn't permitted to do so. Threads do count, see footnote.

The second is a simple transformation but a more difficult decision for the compiler to make, because it's difficult to predict whether it will actually improve performance. Naturally you can look at it separately by doing the first transformation on the code yourself, and seeing what code is generated.

Perhaps a more important consideration is that in .NET, the optimization pass takes place in between MSIL and machine code, not during compilation of C# to IL. So you cannot see what optimizations are being done by looking at the MSIL!


1 Or does it? The .NET memory model is considerably more forgiving than e.g. the C++ model where any data race leads very quickly to undefined behavior unless the object is defined volatile/atomic. What if this loop runs in a worker thread spawned from the object constructor, and after spawning the thread, the constructor goes on (which I'll call the "second half") to change the readonly member? Does the memory model require that change to be seen by the worker thread? What if DoStuff() and the second half of the constructor force memory fences, for example access other members which are volatile, or take a lock? So readonly would only allow the optimization in a single-threaded environment.

Ben Voigt
  • 277,958
  • 43
  • 419
  • 720
2

A readonly field can be initialized to different values at different points in the code (multiple constructors). It can't be optimized out because multiple constructors allow for multiple values of the field in question. Now, if you only had one constructor, with no relevant branching, and therefore only one possible value, ever, for that field, I'd expect more from the optimizer. However, that sort of analysis is one reason that C++ takes much longer to compile than C#, and these sorts of tasks aren't typically delegated to the runtime.

For a clearer explanation:

Note

The readonly keyword is different from the const keyword. A const field can only be initialized at the declaration of the field. A readonly field can be assigned multiple times in the field declaration and in any constructor. Therefore, readonly fields can have different values depending on the constructor used. Also, while a const field is a compile-time constant, the readonly field can be used for run-time constants as in the following example:

See https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly

For your specific case, I'd suggest using const instead of readonly, though admittedly I haven't looked for differences in the generated IL.

Honestly, I'm wondering if it really makes a difference. Any CPU that performs branch prediction and has a halfway-decent cache will likely yield the same performance in either case. (Note that I haven't tested that; it's just a suspicion.)

3Dave
  • 28,657
  • 18
  • 88
  • 151
  • I don't expect it be optimised like const would be (const would just be represented in IL by its value, and the whole else part would be noop since we would know the value at compile time). I would expect it to be optimised to just one check outside of the loop, at least if we are not in a constructor or a static method, because at that point the value is already assigned. – defaultUsernameN Apr 01 '21 at 22:32
  • 1
    It is possible to modify i within the loop, but it wouldn't affect the check result. – defaultUsernameN Apr 01 '21 at 22:34
  • @defaultUsernameN You can't optimize the IL based on that because it's shared amongst all instances of the class. You'd have to verify, at compile time, that every possible instantiation has the same value for the `readonly` field. By using `readonly`, you're telling the compiler that it's possible for it to have different values. (Valid point on the `if`, though.) – 3Dave Apr 01 '21 at 22:37
  • The readonly var isn't shared since it isn't static. The method code is shared, sure, but that would just make the proposed optimisation also shared - what's the harm? But anyways, there is a point in another answer that does point to the case where readonly var will be changed legitimately - from a constructor in another thread. Edited the question to make sure it doesn't read like I expect it to just throw away the whole check like it's a const - thanks for pointing that out! – defaultUsernameN Apr 01 '21 at 23:03