Good developers are always on the look out for unnecessary code. Both to avoid in their own code and to help others avoid during code reviews. One such example is redundant casts: a cast where the type of the expression and cast are the same type. For example:
float x = GetValue(); float y = z / (float)(x * 2);
The result of
x * 2 is a
float. Hence the
(float) cast here is not changing the type, it is seemingly just adding extra words to the code. Given the simplicity of the expression and context it’s hard to argue that it’s making the code more readable. It’s just a cast that adds no value to the code and other developers may call for it to be removed.
But does it really have no value? Let’s take a look at the IL generated by the above code:
IL_0008: ldc.r4 2 IL_000d: ldloc.0 IL_000e: ldc.r4 2 IL_0013: mul IL_0014: conv.r4 IL_0015: div IL_0016: stloc.1
Now take a look at the IL after that cast is removed:
IL_0008: ldc.r4 2 IL_000d: ldloc.0 IL_000e: ldc.r4 2 IL_0013: mul IL_0014: div IL_0015: stloc.1
In the second set of IL instructions the
conv.r4 instruction is missing. This instruction is used to convert the top of the evaluation stack to a
float. In this case the top of the stack is already a
float so is this instruction, like the cast used to produce it, redundant?
It turns out that this instruction is not redundant, it’s actually significant to the value of the expression.
The CLI specification in section 12.1.3 dictates an exact precision for floating point numbers,
double, when used in storage locations. However it allows for the precision to be exceeded when floating point numbers are used in other locations like the execution stack, arguments return values, etc … What precision is used is left to the runtime and underlying hardware. This extra precision can lead to subtle differences in floating point evaluations between different machines or runtimes 1.
This is where the extra
conv.r8 instructions come in. Typically they are used to coerce non-floating point values into floating point values. One of their side effects though is the resulting value will have the exact precision specified by the type. This means when applied to a floating point value on the evaluation stack it will truncate it to the specified precision.
Hence the extra
float cast in the original code is also not redundant, it is instead ensuring that the result of the multiplication operation is exactly 32 bits. A
double cast can be used in exactly the same fashion. This is typically unneeded but can be vital in floating point intensive applications.
This brings up another question: Is this behavior guaranteed by the C# specification? At this time the answer is no, it is simply an implementation detail. If it was guaranteed I think the most logical place would be in section 4.1.6. I don’t think developers need to spend much time worrying about this. Both the original and Roslyn based C# compiler exhibit this behavior and changing it would be inviting a hard to track down back compat break.
Aside: I actually discovered this behavior because I inadventently broke this with a change to the 5.0 compiler. One of the test suites, which had nothing to do with floating point, showed an IL diff where several
conv.r4 instructions were removed. At a glance they seemed unnecessary, the code I had changed had no comments about the behavior, the C# specification didn’t mention it and the Roslyn compiler also did not emit them.
In order to be thorough I emailed the Roslyn team to ensure the change was deliberate and after some discussion it turned out it was not. Eventually the decision was to keep the emitted
conv.r4 and potentially update the specification to codify the behavior.
Bugs resulting from these differences can be quite madenning to track down. ↩