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

9
我想知道为什么这段C#代码
long b = 20;

编译为

ldc.i4.s 0x14
conv.i8

由于使用 ldc.i8 20 需要9个字节,而使用三个字节的ldc.i4.s 20 更为高效。详见这里

代码示例:

double a = 20;

编译为9字节指令

ldc.r8 20

替换这个由3个字节组成的序列

ldc.i4.s 0x14
conv.r8

(使用mono 4.8。)
这是错过的机会还是conv.i8的成本超过了代码大小的收益?

Roslyn 也不会对此进行优化。 - Stephane Delcroix
2
你的优化并不是真正的优化,因为从int到float的转换并不像从int到long那样简单。你正在争论要使编译器变得更加复杂(必须找出何时可以安全地使用此序列)JIT编译器(必须识别此序列以生成单个优化加载),否则会产生代码比现在更慢或不正确。而所有这些都是为了减少IL大小,这相对不重要--JIT编译后的对象代码大小更为重要,在这种情况下不会减少。 - Jeroen Mostert
(我本质上是在重申现有答案已经表达的内容,但可能会更加直接。) - Jeroen Mostert
2
到目前为止,答案中没有提到的另一个原因是:长整型初始化为小整数值与双精度浮点数初始化为小整数值有多常见?我现在面前没有C#代码语料库,但我猜测长整型更经常被初始化为小整数,即使这是一种优化——请记住,优化也必须在JIT编译器中实现——它可能并不会带来太大的优势。 - Eric Lippert
感谢@EricLippert,我面前有一堆C#代码,其中大部分从字符串字面量初始化的double都是用整数值进行初始化的:主要是0、-1、1和2。这些值显然在之后被修改了。 - Stephane Delcroix
3个回答

7
因为float不是比double更小的类型,integer也不是float(反之亦然)。
所有int值都与long值具有1:1映射关系。但对于float和double来说却并非如此简单 - 浮点运算就是这样棘手。更不用说int-float转换并非免费 - 不像将一个1字节值推送到堆栈/寄存器中;需要查看两种方法生成的x86-64代码,而不仅仅是IL代码。在优化时,IL代码的大小不是唯一要考虑的因素。
这与decimal相反,它实际上是一个基于10进制的十进制数,而不是基于2进制的十进制浮点数。在这里,20M完美地映射到20,反之亦然,所以编译器可以自由地发出这个指令:
IL_0000:  ldc.i4.s    0A 
IL_0002:  newobj      System.Decimal..ctor

同样的方法对于二进制浮点数来说并不安全(或者便宜)!
你可能会认为这两种方法都是安全的,因为我们从整数文字(“字符串”)转换为双精度值在编译时或者在 IL 中执行并没有什么区别。但是这根本不是事实,对规范进行一点深入的研究就可以揭示出来:
ECMA CLR 规范,III.1.1.1:

存储浮点数(静态、数组元素和类字段)的存储位置是固定大小的。支持的存储大小是 float32 和 float64。 在其他地方(评估堆栈、参数、返回类型和本地变量中),使用内部浮点类型表示浮点数。在每个这样的实例中,变量或表达式的名义类型为 float32 或 float64,但其值可能在内部使用额外的范围和/或精度表示。

为了简化问题,假设 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局部变量被认为是float64,而不是F。如果您想这样做,您可以自己制作语言。

无论如何,IL生成器实际上并没有进行太多优化。这在很大程度上留给JIT编译。如果您想要一个优化的C#-IL编译器,请编写一个——我怀疑是否有足够的利益来证明这种努力,特别是如果您唯一的目标是使IL代码更小。大多数IL二进制文件已经比等效本机代码小得多。

至于实际运行的代码,在我的计算机上,这两种方法产生完全相同的x86-64汇编——从数据段加载双精度值。JIT可以轻松地进行此优化,因为它知道代码实际上是在哪种架构上运行的。


检查double是否为整数就像 d %1 == 0 一样简单,我认为与加载长整型的优化没有太大区别,检查是 l < 256 ... - Stephane Delcroix
我并没有混淆字节码和堆栈,并且也不认为 conv.r8 是免费的。本文底部的问题是:“这是错失的机会,还是 conv.i8 的成本超过了代码大小的收益?” - Stephane Delcroix
@Luaan F必须具有与float32 / float64相等或更大的范围。F必须具有与float32 / float64相等或更大的精度。否则,F必须按照IEEE 754 / IEC 60559规定执行。这在I.12.1.3中详细说明。它不能是像您建议的十进制浮点类型。它不能是完全不同的数字系统,就像您建议的那样。不,我没有混淆float32 / float64和F,F的要求基于float32 / float64的要求。 - user743382
@Luaan 我本想直接驳斥这个观点,但我现在意识到我错过了更简单的东西:即使 float64->F 转换允许有损失,即使转换回 float64 的结果可能不同,即使如此也没有任何影响,仍然不会使操作不安全:当编译时 int->float64 转换是精确的时,运行时 int->F 和运行时 float64->F 转换必须给出相同的结果,因为两者只有一个操作,该操作的舍入不取决于源是 int 还是 float64。 - user743382
@Luaan 是的,如果int->float转换不精确,并且float->F也允许不精确(我并不是说允许这样做,我只是同意你不继续讨论),那么我同意将int->float->F更改为int->F可能会改变结果。虽然所有int32值都可以在float64中精确存储(如果我错了,请纠正我),但它们不能全部在float32中精确存储,因此编译器仍需要逻辑来仅在安全时进行转换。(顺便说一句,整个相同的论点或许也可以用于缩短ldc.r8到ldc.r4。) - user743382
显示剩余8条评论

3
我怀疑你不会得到比“因为没有人认为有必要实现它”更令人满意的答案。
事实上,他们本可以这样做,但正如Eric Lippert多次指出的那样,选择实现功能而不是选择不实现。在这种特殊情况下,这个特性的收益没有超过成本,例如额外的测试、int和float之间的非平凡转换,而在ldc.i4.s的情况下,这并不是太麻烦。此外,最好不要用更多的优化规则来膨胀jit。
正如Roslyn源代码所示,转换仅针对long完成。总的来说,也完全可以为floatdouble添加此功能,但除了在生成较短的CIL代码(需要内联时有用)和当您想要使用浮点常数时,通常实际上使用浮点数(即不是整数)时,它并没有太多用处。

即使内联在 IL 级别上没有发生(在那里它很棘手且很少有用 - 调用通常比在 IL 中内联便宜),但它会在 JITter 上发生。并且在我的计算机上,两个 IL 源代码产生完全相同的汇编代码 - 从数据段加载浮点值,一个单独的加载指令。不用说,它已经被大量内联了,因为它只是一个常数。 - Luaan
@Luaan,你说得对,内联是在JITter级别发生的,但我并没有反驳这一点。为了使一个方法(没有AggresiveInlining)成为内联的候选者,它必须最多有一定数量的指令。这就是我的意思。 - IS4
我对此并不确定。尺寸方面唯一重要的是生成的汇编代码,这与原始IL代码大小几乎没有关系(再次证明了这两个片段具有极大的IL大小,同时产生完全相同的汇编)。但我相当确定这些只是实现细节,虽然我现在不想去挖掘规范 :) 总的来说,IL代码大小的成本非常低。 - Luaan
@Luaan 我要引用 MSDN 博客 - "内联函数的大小限制为 32 字节的 IL". 这就是我所说的,尽管我没有指定确切的数字。是的,当你运行代码时,汇编代码的大小很重要,但当你想让你的方法被内联时,IL 的大小限制也很重要;只有这个启发式(和其他一些标准)。别担心,我甚至证明了 Hans Passant 在这方面是错误的。☺ - IS4
是的,那很有趣。虽然这只是一个实现细节,但相当重要。我猜在实践中,你会在 IL 达到 32 字节之前遇到其他内联限制。请注意,这是从 2004 年开始的——由于我们同时有了一些新的更好、更快的 JITters,在这方面的限制可能会大不相同。然而,我并不真的关心这个问题——通常这是我基于特定情况(通常是由于分析)而关注的东西,而不是一般性问题。我不会在 C# 中编写很多带有方法调用的紧凑循环 :) - Luaan

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

...变量或表达式的名义类型是float32float64之一...内部表示应具有以下特征:

  • 内部表示的精度和范围应大于或等于名义类型。
  • 对内部表示的转换应保留值。
这意味着任何float32值都可以保证在F中安全地表示,无论F是什么。
我们得出结论,您提出的替代指令序列是正确的。现在的问题是:从性能角度来看,它是否更好?
为了回答这个问题,让我们看看JIT编译器在看到这两个代码序列时会做什么。当使用ldc.r8 20时,您引用的链接中给出的答案很好地解释了使用长指令的影响。
让我们考虑这个3字节序列:
ldc.i4.s 0x14
conv.r8

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

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接