Does compiler optimize operation on const variable and literal const number?

2.7k views Asked by At

Let's say I have class with field:

const double magicalConstant = 43;

This is somewhere in code:

double random = GetRandom();
double unicornAge = random * magicalConstant * 2.0;

Will compiler optimize my code so that it doesn't calculate magicalConstant * 2.0 every time it calcuates unicornAge?

I know that I can define next const that takes this multiplication into account. But this looks much cleaner in my code. And it makes sense for compiler to optimize it.

4

There are 4 answers

6
Eric Lippert On BEST ANSWER

(This question was the subject of my blog in October 2015; thanks for the interesting question!)

You have some good answers already that answer your factual question: No, the C# compiler does not generate the code to do a single multiplication by 86. It generates a multiplication by 43 and a multiplication by 2.

There are some subtleties here that no one has gone into though.

Multiplication is "left associative" in C#. That is,

x * y * z

must be computed as

(x * y) * z

And not

x * (y * z)

Now, is it the case that you ever get different answers for those two computations? If the answer is "no" then the operation is said to be an "associative operation" -- that is, it does not matter where we put the parentheses, and therefore can do optimizations to put the parentheses in the best place. (Note: I made an error in a previous edit of this answer where I said "commutative" when I meant "associative" -- a commutative operation is one where x * y is equal to y * x.)

In C#, string concatenation is an associative operation. If you say

myString + "hello" + "world" + myString

then you get the same result for

((myString + "hello") + "world") + myString

And

(myString + ("hello" + "world")) + myString

and therefore the C# compiler can do an optimization here; it can do a computation at compile time and generate the code as though you had written

(myString + "helloworld") + myString

which is in fact what the C# compiler does. (Fun fact: implementing that optimization was one of the first things I did when I joined the compiler team.)

Is a similar optimization possible for multiplication? Only if multiplication is associative. But it is not! There are a few ways in which it is not.

Let's look at a slightly different case. Suppose we have

x * 0.5 * 6.0

Can we just say that

(x * 0.5) * 6.0

is the same as

x * (0.5 * 6.0)

and generate a multiplication by 3.0? No. Suppose x is so small that x multiplied by 0.5 is rounded to zero. Then zero times 6.0 is still zero. So the first form can give zero, and the second form can give a non-zero value. Since the two operations give different results, the operation is not associative.

The C# compiler could have smarts added to it -- like I did for string concatenation -- to figure out in which cases multiplication is associative and do the optimization, but frankly it is simply not worth it. Saving on string concatenations is a huge win. String operations are expensive in time and memory. And it is very common for programs to contain very many string concatenations where constants and variables are mixed together. Floating point operations are very cheap in time and memory, it is hard to know which ones are associative, and it is rare to have long chains of multiplications in realistic programs. The time and energy it would take to design, implement and test that optimization would be better spent writing other features.

4
Alex Sikilinda On

In your particular case it won't. Let's consider the following code:

class Program
{
    const double test = 5.5;

    static void Main(string[] args)
    {
        double i = Double.Parse(args[0]);
        Console.WriteLine(test * i * 1.5);
    }
}

in this case the constants are not folded:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       36 (0x24)
  .maxstack  2
  .locals init ([0] float64 i)
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelem.ref
  IL_0003:  call       float64 [mscorlib]System.Double::Parse(string)
  IL_0008:  stloc.0
  IL_0009:  ldc.r8     5.5
  IL_0012:  ldloc.0
  IL_0013:  mul
  IL_0014:  ldc.r8     1.5
  IL_001d:  mul
  IL_001e:  call       void [mscorlib]System.Console::WriteLine(float64)
  IL_0023:  ret
} // end of method Program::Main

But in general it will be optimized. This optimization is called constant folding.

We can prove it. Here is the test code in C#:

class Program
{
    const double test = 5.5;

    static void Main(string[] args)
    {
        Console.WriteLine(test * 1.5);
    }
}

Here is the decompiled code code from ILDasm:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       15 (0xf)
  .maxstack  8
  IL_0000:  ldc.r8     8.25
  IL_0009:  call       void [mscorlib]System.Console::WriteLine(float64)
  IL_000e:  ret
} // end of method Program::Main

As you can see IL_0000: ldc.r8 8.25 compiler has calculated the expression.

Some guys said that it is because you are dealing with float, but it is not true. The optimization doesn't occur even on integers:

class Program
{
    const int test = 5;

    static void Main(string[] args)
    {
        int i = Int32.Parse(args[0]);
        Console.WriteLine(test * i * 2);
    }
}

Il Code (no folding):

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       20 (0x14)
  .maxstack  2
  .locals init ([0] int32 i)
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelem.ref
  IL_0003:  call       int32 [mscorlib]System.Int32::Parse(string)
  IL_0008:  stloc.0
  IL_0009:  ldc.i4.5
  IL_000a:  ldloc.0
  IL_000b:  mul
  IL_000c:  ldc.i4.2
  IL_000d:  mul
  IL_000e:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_0013:  ret
} // end of method Program::Main
5
Adriano Repetti On

If it just was just:

double unicornAge = magicalConstant * 2.0;

Then yes, even if compiler is not required to perform any specific optimization we may reasonably expect and assume this simple one is performed. As noted by Eric this example is little bit misleading because in this case compiler has to consider magicalConstant * 2.0 as a constant too.

However because of floating point errors (random * 6.0 != (random * 3.0) * 2.0) it'll replace calculated value only if you add parenthesis:

double unicornAge = random * (magicalConstant * 2.0);

EDIT: which are these floating point errors I'm talking about? There are two causes of error:

  • Precision 1: floating point numbers are an approximation and compiler is not allowed to perform any optimization that will change result. For example (as better exposed in Eric's answer) in verySmallValue * 0.1 * 10 if verySmallValue * 0.1 will round to 0 (because of f.p.) then (verySmallValue * 0.1) * 10 != verySmallValue * (0.1 * 10) because 0 * 10 == 0.
  • Precision 2: you don't have this issue only for very small numbers, because of IEEE 754 integer numbers above 2^53 - 1 (9007199254740991) cannot safely represented then c * (10 * 0.5) may give an incorrect result if c * 10 is above 9007199254740991 (but see later, that's official implementation, CPUs may use extended precision).
  • Precision 3: note that x * 0 >= 0 isn't always true then expression a * b * c >= 0 when b is 0 may be true or not according to a and c values or associativity.
  • Range: floating point numeric types have a finite range, if first multiplication will cause value to be Infinity then that optimization will change that value.

Let's see an example about range issue because it's more subtle than this.

// x = n * c1 * c2
double x = veryHighNumber * 2 * 0.5;

Assuming veryHighNumber * 2 is outside of double range then you expect (without any optimization) that x is +Infinity (because veryHighNumber * 2 is +Infinity). Surprising (?) result is correct (or incorrect if you're expecting +Infinity) and x == veryHighNumber (even when compiler keep things as you wrote them and it generates code for (veryHighNumber * 2) * 0.5).

Why this happens? Compiler isn't performing any optimization here then CPU has to be guilty. C# compiler generates ldc.r8 and mul instructions, JIT generates this (if it compiles to plain FPU code, for generated SIMD instructions you can see disassembled code in Alex's answer):

fld  qword ptr ds:[00540C48h] ; veryHighNumber
fmul qword ptr ds:[002A2790h] ; 2
fmul qword ptr ds:[002A2798h] ; 0.5
fstp qword ptr [ebp-44h]      ; x

fmul multiply ST(0) with value from memory and stores result in ST(0). Registers are in extended precision then an fmul chain (contractions) won't cause +Infinity untill it won't overflow extended precision range (can be checked using a very high number also for c1 in previous example).

This happens only when intermediate values are kept in FPU register, if you split our example expression in multiple steps (where each intermediate value is stored in memory and then converted back to double precision) you will have expected behavior (result is +Infinity). This is IMO the more confusing thing:

double x = veryHighNumber * 2 * 0.5;

double terriblyHighNumber = veryHighNumber * 2;
double x2 = terriblyHighNumber * 0.5;

Debug.Assert(!Double.IsInfinity(x));
Debug.Assert(Double.IsInfinity(x2));
Debug.Assert(x != x2);
0
Alex Zhukovskiy On

No, it doesn't in this case.

Look at this code:

const double magicalConstant = 43;
static void Main(string[] args)
{
    double random = GetRandom();
    double unicornAge = random * magicalConstant * 2.0;
    Console.WriteLine(unicornAge);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static double GetRandom()
{
    return new Random().NextDouble();
}

our dissassembly is:

        double random = GetRandom();
00007FFDCD203C92  in          al,dx  
00007FFDCD203C93  sub         al,ch  
00007FFDCD203C95  mov         r14,gs  
00007FFDCD203C98  push        rdx  
        double unicornAge = random * magicalConstant * 2.0;
00007FFDCD203C9A  movups      xmm1,xmmword ptr [7FFDCD203CC0h]  
00007FFDCD203CA1  mulsd       xmm1,xmm0  
00007FFDCD203CA5  mulsd       xmm1,mmword ptr [7FFDCD203CC8h]  
        Console.WriteLine(unicornAge);
00007FFDCD203CAD  movapd      xmm0,xmm1  
00007FFDCD203CB1  call        00007FFE2BEDAFE0  
00007FFDCD203CB6  nop  
00007FFDCD203CB7  add         rsp,28h  
00007FFDCD203CBB  ret  

we have two mulsd instructions here therefore we have two multiplications.

Now let's place some brackets:

    double unicornAge = random * (magicalConstant * 2.0);
00007FFDCD213C9A  movups      xmm1,xmmword ptr [7FFDCD213CB8h]  
00007FFDCD213CA1  mulsd       xmm1,xmm0  

as you can see, compiler optimized it. In float-point worls (a*b)*c != a*(b*c), so it can't optimize it without manual help.

For example, integer code:

        int random = GetRandom();
00007FFDCD203860  sub         rsp,28h  
00007FFDCD203864  call        00007FFDCD0EC8E8  
        int unicornAge = random * magicalConstant * 2;
00007FFDCD203869  imul        eax,eax,2Bh  
        int unicornAge = random * magicalConstant * 2;
00007FFDCD20386C  add         eax,eax  

with brackets:

        int random = GetRandom();
00007FFDCD213BA0  sub         rsp,28h  
00007FFDCD213BA4  call        00007FFDCD0FC8E8  
        int unicornAge = random * (magicalConstant * 2);
00007FFDCD213BA9  imul        eax,eax,56h