When you read MSDN on System.Single
:
Single
complies with the IEC 60559:1989 (IEEE 754) standard for binary floating-point arithmetic.
and the C# Language Specification:
The
float
anddouble
types are represented using the 32-bit single-precision and 64-bit double-precision IEEE 754 formats [...]
and later:
The product is computed according to the rules of IEEE 754 arithmetic.
you easily get the impression that the float
type and its multiplication comply with IEEE 754.
It is a part of IEEE 754 that multiplcation is well-defined. By that I mean that when you have two float
instances, there exists one and only one float
which is their "correct" product. It is not permissible that the product depends on some "state" or "set-up" of the system calculating it.
Now, consider the following simple program:
using System;
static class Program
{
static void Main()
{
Console.WriteLine("Environment");
Console.WriteLine(Environment.Is64BitOperatingSystem);
Console.WriteLine(Environment.Is64BitProcess);
bool isDebug = false;
#if DEBUG
isDebug = true;
#endif
Console.WriteLine(isDebug);
Console.WriteLine();
float a, b, product, whole;
Console.WriteLine("case .58");
a = 0.58f;
b = 100f;
product = a * b;
whole = 58f;
Console.WriteLine(whole == product);
Console.WriteLine((a * b) == product);
Console.WriteLine((float)(a * b) == product);
Console.WriteLine((int)(a * b));
}
}
Appart from writing some info on the environment and compile configuration, the program just considers two float
s (namely a
and b
) and their product. The last four write-lines are the interesting ones. Here's the output of running this on a 64-bit machine after compiling with Debug x86 (left), Release x86 (middle), and x64 (right):
We conclude that the result of simple float
operations depends on the build configuration.
The first line after "case .58"
is a simple check of equality of two float
s. We expect it to be independent of build mode, but it's not. The next two lines we expect to be identical because it does not change anything to cast a float
to a float
. But they are not. We also expect them to read "True↩ True"
because we're comparing the product a*b
to itself. The last line of the output we expect to be independent of build configuration, but it's not.
To figure out what the correct product is, we calculate manually. The binary representation of 0.58
(a
) is:
0 . 1(001 0100 0111 1010 1110 0)(001 0100 0111 1010 1110 0)...
where the block in parentheses is the period which repeats forever. The single-precision representation of this number needs to be rounded to:
0 . 1(001 0100 0111 1010 1110 0)(001 (*)
where we have rounded (in this case round down) to the nearest representable Single
. Now, the number "one hundred" (b
) is:
110 0100 . (**)
in binary. Computing the full product of the numbers (*)
and (**)
gives:
11 1001 . 1111 1111 1111 1111 1110 0100
which rounded (in this case rounding up) to single-precision gives
11 1010 . 0000 0000 0000 0000 00
where we rounded up because the next bit was 1
, not 0
(round to nearest). So we conclude that the result is 58f
according to IEEE. This was not in any way given a priori, for example 0.59f * 100f
is less than 59f
, and 0.60f * 100f
is greater than 60f
, according to IEEE.
So it looks like the x64 version of the code got it right (right-most output window in the picture above).
Note: If any of the readers of this question have an old 32-bit CPU, it would be interesting to hear what the output of the above program is on their architecture.
And now for the question:
- Is the above a bug?
- If this is not a bug, where in the C# Specifcation does it say that the runtime may choose to perform a
float
multiplication with extra precision and then "forget" to get rid of that precision again? - How can casting a
float
expression to the typefloat
change anything? - Isn't it a problem that seemingly innocent operations like splitting an expression into two expressions by e.g. pulling out an
(a*b)
to a temprary local variable, changes behavior, when they ought to be mathematically (as per IEEE) equivalent? How can the programmer know in advance if the runtime chooses to hold thefloat
with "artificial" extra (64-bit) precision or not? - Why are "optimizations" from compiling in Release mode allowed to change arithmetics?
(This was done in the 4.0 version of the .NET Framework.)