为什么当我使用变量时,带有连分数的模数运算符的结果会有所不同,并且会因不同的编译器而不同?

Why does the result of modulus operator with continued fractions differ when I use variables and differ for different compilers?

我有一些代码会根据我使用的编译器产生不同的结果:

float a = 1f;
float b = 6f;
float result = a % (a / b);
float result1 = 1f % (1f / 6f);
Console.WriteLine("{0} , {1}", result, result1);

当运行这段代码用Roslyn时,输出是:

5.551115E-17 , 0.1666666

当我在 Unity 中使用此代码为 Windows (Mono) 构建项目时,我得到

0 , 0.1666666

同一项目,在 Unity 上,Android (il2cpp):

0.1666666 , 0.1666666

与 .NET 4.7.2 相同:

5.551115E-17 , 5.551115E-17

与 .NET 5 相同:

0.16666664 , 0.16666664

首先,Mono 的不同之处似乎在于它实际上优化了整个浮点算术运算(注意到模数运算符之前的值和分数的分子相同,并立即将整个表达式替换为零,这在逻辑上是有道理的,但破坏了整个“我明确输入那些都是浮点数”的事情)。 但为什么所有其他差异,尤其是第一个差异?该行为是否在某处定义(例如,其中一个编译器以“正确的方式”执行,而其他编译器则不是)?

基本上,IL 对于 floatdouble 没有合适的“堆栈类型”。它只有 F (ECMA 335 I.12.1.3)

对于 float a = 1f; float b = 6f; float result = a % (a / b);,C# 编译器本质上发出:

ldc.r4 1
ldc.r4 6
stloc.0
dup
ldloc.0
div
rem

其中,由于运行时如何使用其单一类型,这意味着它可以自由地将其视为:

float a = 1f; float b = 6f;
double tmp1 = (a / b);
double tmp2 = a % tmp1;
float result = (float)tmp2;

当这样处理时,结果总是5.551115E-17

这在很大程度上只是遗留 32 位 JIT 的一个问题,因为它必须使用 x87 FPU 堆栈,由于更改舍入模式的成本过高,因此只能将所有操作作为 64 位进行。由于遗留原因,它也没有插入中间转换以在操作之间浮动。

RyuJIT(现代 64 位 JIT 以及自 2.1 以来所有 .NET Core 使用的 JIT,IIRC)使用 x86 SIMD 指令(SSE/SSE2),而不是原生支持 32 位和 64 位操作,所以它没有这个问题。所有操作都直接作为“正确”类型完成(请注意,可能仍然存在一些我不记得的边缘情况)。

使这个在任何地方都一致的“修复”是在每个“操作”之后插入一个显式转换为 float。例如:

float a = 1f; float b = 6f;
float result = a % (float)(a / b);

同样:

float a = 1f; float b = 6f;
float result = a / b;
result = a % result;