为什么编译器优化 ldc.i8 而不是 ldc.r8?

Why does the compiler optimize ldc.i8 and not ldc.r8?

我想知道为什么这个 C# 代码

long b = 20;

编译为

ldc.i4.s 0x14
conv.i8

(因为它需要 3 个字节而不是 ldc.i8 20 所需的 9 个字节。有关详细信息,请参阅 。)

而这段代码

double a = 20;

编译成9字节指令

ldc.r8 20

而不是这个 3 字节序列

ldc.i4.s 0x14
conv.r8

(使用单声道 4.8。)

这是一个错失的机会还是 conv.i8 的成本超过了代码大小的收益?

因为浮点数不是更小的双精度数,整数也不是浮点数(反之亦然)。

所有 int 值都有 1:1 映射到 long 值。 floatdouble 的情况并非如此 - 浮点运算在这种情况下很棘手。更不用说 int-float 转换不是免费的——不像将 1 字节的值压入堆栈/寄存器;查看两种方法生成的 x86-64 代码,而不仅仅是 IL 代码。 IL 代码的大小不是优化时要考虑的唯一因素。

这与 decimal 相反,后者实际上是一个以 10 为底的十进制数,而不是一个以 2 为底的十进制浮点数。 20M 完美映射到 20,反之亦然,因此编译器可以自由地发出:

IL_0000:  ldc.i4.s    0A 
IL_0002:  newobj      System.Decimal..ctor

对于二进制浮点数,同样的方法根本不安全(或便宜!)。

您可能会认为这两种方法一定是安全的,因为我们是否在编译时将整数文字 ("a string") 转换为双精度值并不重要,或者是否我们在伊利诺伊州做。但事实并非如此,因为一些规范潜水揭示了:

ECMA CLR 规范,III.1.1.1:

Storage locations for floating-point numbers (statics, array elements, and fields of classes) are of fixed size. The supported storage sizes are float32 and float64. Everywhere else (on the evaluation stack, as arguments, as return types, and as local variables) floating-point numbers are represented using an internal floating-point type. In each such instance, the nominal type of the variable or expression is either float32 or float64, but its value might be represented internally with additional range and/or precision.

为简短起见,假设 float64 实际上使用 4 个二进制数字,而实现定义的浮点类型 (F) 使用 5 个二进制数字。我们想要转换一个恰好具有超过四位二进制表示的整数文字。现在比较它的行为方式:

ldc.r8 0.1011E2 ; expanded to 0.10110E2
ldc.r8 0.1E2
mul             ; 0.10110E2 * 0.10000E2 == 0.10110E3

conv.r8 转换为 F,而不是 float64。所以我们实际上得到:

ldc.i4.s theSameLiteral
conv.r8 ; converted to 0.10111E2
mul     ; 0.10111E2 * 0.10000E2 == 0.10111E3

糟糕 :)

现在,我很确定在任何合理的平台上,0-255 范围内的整数都不会发生这种情况。但是由于我们是根据 CLR 规范进行编码的,所以我们不能做出这样的假设。 JIT 编译器可以,但为时已晚。语言编译器可能将两者定义为等价的,但 C# 规范没有定义 - double local 被认为是 float64,而不是 F。如果你愿意,你可以创建自己的语言。

无论如何,IL 生成器并没有真正优化太多。这大部分留给了 JIT 编译。如果你想要一个优化的 C#-IL 编译器,写一个——我怀疑是否有足够的好处来保证付出努力,特别是如果你的唯一目标是使 IL 代码更小。大多数 IL 二进制文件已经比等效的本机代码小很多。

至于实际运行的代码,在我的机器上,两种方法都产生完全相同的 x86-64 程序集——从数据段加载双精度值。 JIT 可以很容易地进行这种优化,因为它知道代码实际上 运行 在什么架构上。

首先,让我们考虑一下正确性。 ldc.i4.s 可以处理 -128 到 127 之间的整数,所有这些都可以在 float32 中精确表示。但是,CIL 对某些存储位置使用称为 F 的内部浮点类型。 ECMA-335 标准在 III.1.1.1 中说:

...the nominal type of the variable or expression is either float32 or float64...The internal representation shall have the following characteristics:

  • The internal representation shall have precision and range greater than or equal to the nominal type.
  • Conversions to and from the internal representation shall preserve value.

这意味着任何 float32 值都可以保证在 F 中安全地表示,无论 F 是什么。

我们得出结论,您提出的替代指令顺序是正确的。现在的问题是:在性能方面是否更好?

为了回答这个问题,让我们看看 JIT 编译器在看到这两个代码序列时会做什么。使用 ldc.r8 20 时,您引用的 link 中给出的答案很好地解释了使用长指令的后果。

让我们考虑 3 字节序列:

ldc.i4.s 0x14
conv.r8

我们可以在这里做出一个假设,该假设对于任何优化的 JIT 编译器都是合理的。我们假设 JIT 能够识别这样的指令序列,以便可以将这两条指令编译在一起。编译器被赋予以二进制补码格式表示的值 0x14,并且必须将其转换为 float32 格式(如上所述,它始终是安全的)。在相对现代的架构上,这可以非常有效地完成。这种微小的开销是 JIT 时间的一部分,因此只会产生一次。两个 IL 序列生成的本机代码的质量相同。

因此 9 字节序列有一个大小问题,可能会产生从无到更多的任意数量的开销(假设我们在任何地方都使用它),而 3 字节序列具有一次性微小的转换开销。哪一个更好?好吧,必须有人做一些科学合理的实验来衡量性能差异才能回答这个问题。我想强调的是,除非你是编译器优化方面的工程师或研究员,否则你不应该关心这个。否则,您应该在更高级别(在源代码级别)优化您的代码。

我怀疑你会得到比"because noone thought it necessary to implement it."

更满意的答案

事实是,他们本可以这样做,但正如 Eric Lippert 多次指出的那样,选择要实现的特性而不是选择不实现的特性。在这种特殊情况下,此功能的收益并没有超过成本,例如额外的测试,intfloat 之间的重要转换,而在 ldc.i4.s 的情况下,这并不是什么大问题。另外最好不要用更多的优化规则来膨胀抖动。

Roslyn source code 所示,仅对 long 进行转换。总而言之,完全可以为 floatdouble 添加此功能,但除非生成较短的 CIL 代码(需要内联时很有用),以及当您想使用浮点数常量,实际上通常使用浮点数(即不是整数)。