我们正在将应用程序从 .NET Framework 4.8 更新到 .NET 8。

在回归测试期间,我们注意到隐式扩展转换似乎以不同的顺序发生,导致结果发生一些变化。

在 .NET Framework 中,类似这样的操作似乎d1= f1 * f2先将f1转换f2为双精度数,然后再执行乘法,而在 .NET 8 中,则会先进行浮点数之间的乘法,然后再进行扩展。

我知道浮点二进制数学的行为。我并不是想说其中一个是“错误的”——只是想了解为什么行为发生了变化。

并且:有没有办法将.NET 8 行为暂时改回 .NET Framework 行为,以便我们更好地理解我们的回归测试?

(PS:是的,我知道如果我们一开始没有隐式转换的话这不会是个问题。但这是一个庞大的遗留代码库,我无法轻易改变它。)

控制台应用程序测试代码:

Console.WriteLine(".NET 8");

Console.WriteLine("Input Float: 0.3333333F");
float f1 = 0.3333333F;
Console.WriteLine("Decimal: " + f1.ToString());
Console.WriteLine("Binary: " + GetFloatBinary(f1));
Console.WriteLine();

Console.WriteLine("Float multiplication first, then conversion");
Console.WriteLine("f2 = f1 * f1");
float f2 = f1 * f1;
Console.WriteLine("Decimal: " + f2.ToString());
Console.WriteLine("Binary: " + GetFloatBinary(f2));
Console.WriteLine("d1 = (double)f2");
double d1 = (double)f2;
Console.WriteLine("Decimal: " + d1.ToString());
Console.WriteLine("Binary: " + GetDoubleBinary(d1));
Console.WriteLine();

Console.WriteLine("Conversion first, multiplication second");
Console.WriteLine("d2 = (double)f1 * (double)f1");
double d2 = (double)f1 * (double)f1;
Console.WriteLine("Decimal: " + d2.ToString());
Console.WriteLine("Binary: " + GetDoubleBinary(d2));
Console.WriteLine();

Console.WriteLine("Let the platform decide");
Console.WriteLine("d3 = f1 * f1");
double d3 = f1 * f1;
Console.WriteLine("Decimal: " + d3.ToString());
Console.WriteLine("Binary: " + GetDoubleBinary(d3));
Console.WriteLine();

Console.ReadLine();

static string GetFloatBinary(float value)
{
    const int bitCount = sizeof(float) * 8;
    int intValue = System.BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
    return Convert.ToString(intValue, 2).PadLeft(bitCount, '0');
}

static string GetDoubleBinary(double value)
{
    const int bitCount = sizeof(double) * 8;
    int intValue = System.BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
    return Convert.ToString(intValue, 2).PadLeft(bitCount, '0');
}

结果:

.NET FRAMEWORK
Input Float: 0.3333333F
Decimal: 0.3333333
Binary: 00111110101010101010101010101010

Float multiplication first, then conversion
f2 = f1 * f1
Decimal: 0.1111111
Binary: 00111101111000111000111000110111
d1 = (double)f2
Decimal: 0.111111097037792
Binary: 0000000000000000000000000000000011100000000000000000000000000000

Conversion first, multiplication second
d2 = (double)f1 * (double)f1
Decimal: 0.111111097865635
Binary: 0000000000000000000000000000000011100011100011100011100100000000

Let the platform decide
d3 = f1 * f1
Decimal: 0.111111097865635
Binary: 0000000000000000000000000000000011100011100011100011100100000000
.NET 8
Input Float: 0.3333333F
Decimal: 0.3333333
Binary: 00111110101010101010101010101010

Float multiplication first, then conversion
f2 = f1 * f1
Decimal: 0.1111111
Binary: 00111101111000111000111000110111
d1 = (double)f2
Decimal: 0.1111110970377922
Binary: 0000000000000000000000000000000011100000000000000000000000000000

Conversion first, multiplication second
d2 = (double)f1 * (double)f1
Decimal: 0.11111109786563489
Binary: 0000000000000000000000000000000011100011100011100011100100000000

Let the platform decide
d3 = f1 * f1
Decimal: 0.1111110970377922
Binary: 0000000000000000000000000000000011100000000000000000000000000000

我认为我清楚地了解了发生了什么变化,但我找不到任何关于原因的文献。

谢谢您的见解!

4

  • 调试和发布版本的结果是否相同?


    – 

  • 1
    是的,发布配置中的结果相同。


    – 

  • 1
    我很想知道在乘法之前先加宽会更糟糕的用例。我想不出这会造成问题的情况,除非你期望特定的精确浮点值匹配,而你永远不应该这样做。


    – 

  • @Charlieface 我想到的一件事是性能。双重计算可能成本更高。我不知道是否有没有浮点硬件的实现,但在这样的系统上,这可能是令人望而却步的。


    – 


最佳答案
1

仅对我而言,该问题在 32 位版本(或 .NET Framework 上首选 32 位的 AnyCPU)上可重现。

两者的 IL 是相同的,并且 ECMA 附录中没有提及,因此推测在这方面没有真正的变化。

在 64 位上,.NET Framework 与我的情况下的 .NET 6 相同(并且结果与问题相同) – 浮点数首先相乘。

在 32 位上,.NET Framework 的反汇编如下:

00120AD2 D9 45 C0             fld         dword ptr [ebp-40h]  
00120AD5 D8 C8                fmul        st,st(0)  
00120AD7 DD 5D A4             fstp        qword ptr [ebp-5Ch]

关于fld

fld 指令将 32 位、64 位或 80 位浮点值加载到堆栈上。此指令将 32 位和 64 位操作数转换为 80 位扩展精度值,然后将该值推送到浮点堆栈上。

但这种不一致性与 ECMA 335 标准一致:

I.12.1.3 浮点数据类型的处理:

在其他任何地方(在计算堆栈上、作为参数、作为返回类型以及作为局部变量),浮点数都使用内部浮点类型表示。在每个此类实例中,
变量或表达式的名义类型为 float32 或 float64,但其值可以在内部以附加范围和/或精度表示。内部浮点表示的大小 取决于实现,可以变化,并且精度至少应与所表示的变量或表达式的精度一样高。

讨论了跨运行时实现(不同的 JITters 发出不同的指令)的这种变化,特别是那些评论(我在 [] 中澄清):

无论如何,当前的 x86 JIT [.NET Framework] 使用 x87 的超越指令,而这些指令的精度仍有待商榷。

@mikedn RyuJIT 的 x86 端口将使用 SSE .[.NET Core] 但是,对于这个问题,除非程序明确更改评估模式,否则评估无论如何都会是双倍的。我认为 @gafter 实际上更担心这不是规范的要求,而且也没有办法强制它发生。

还有这些问题/答案更详细地讨论了该主题:

很好地总结了主要问题(JIT 编译器在哪个实现中使用哪个指令集),并提供了可能的解决方案(使用decimal或第三方库):

要明确的是,运行时将在执行应用程序时编译即时 CIL 代码,并且如果我们使用的是 32 位,谁将决定使用 FPU,或者如果我们使用的是 64 位(并且如果 SSE 可用),谁将决定使用 SSE 指令。

1

  • 2
    很有趣。我可以确认我在 64 位上看到了同样的行为。这可能足以让我们解决当前的问题,但找到一个标记为错误修复的注释仍然很有趣。谢谢!


    –