【问题标题】:Does compiler optimize operation on const variable and literal const number?编译器是否优化了对 const 变量和文字 const 数的操作?
【发布时间】:2015-10-28 01:26:48
【问题描述】:

假设我有字段类:

const double magicalConstant = 43;

这是代码中的某处:

double random = GetRandom();
double unicornAge = random * magicalConstant * 2.0;

编译器是否会优化我的代码,使其不会在每次计算 unicornAge 时计算 magicalConstant * 2.0

我知道我可以定义下一个考虑这个乘法的常量。但这在我的代码中看起来更清晰。编译器对其进行优化是有意义的。

【问题讨论】:

  • 如果只是double x = magicalConstant * 2.0,那么(即使它不是需要来执行任何特定优化)但是...IMO -如果我没记错的话 - 由于浮点错误,如果添加括号,它将替换计算值:random * (magicalConstant * 2.0)
  • 我注意到“常量变量”是矛盾的。变量不同;这就是为什么它们被称为变量。常数是常数;这就是为什么它们被称为常量。没有常量变量这样的东西。你打算我想说“常量字段”。
  • @EricLippert 谢谢有道理。

标签: c# .net optimization


【解决方案1】:

(这个问题是the subject of my blog in October 2015;感谢这个有趣的问题!)

您已经有一些很好的答案可以回答您的实际问题:不,C# 编译器不会生成执行单次乘以 86 的代码。它生成乘以 43 和乘以 2。

这里有一些微妙之处,但没有人涉足。

乘法在 C# 中是“左结合”。也就是说,

x * y * z

必须计算为

(x * y) * z

不是

x * (y * z)

现在,您是否曾经为这两个计算得到不同的答案?如果答案为“否”,则该操作被称为“关联操作”——也就是说,我们将括号放在哪里并不重要,因此可以进行优化以将括号放在最佳位置。 (注意:我在此答案的先前编辑中犯了一个错误,当我的意思是“关联”时,我说“交换”——交换操作是 x * y 等于 y * x 的操作。)

在 C# 中,字符串连接是一种关联操作。如果你说

myString + "hello" + "world" + myString

那么你会得到同样的结果

((myString + "hello") + "world") + myString

(myString + ("hello" + "world")) + myString

因此C#编译器可以在这里进行优化;它可以在编译时进行计算并像你写的一样生成代码

(myString + "helloworld") + myString

这实际上是 C# 编译器所做的。 (有趣的事实:实现优化是我加入编译器团队时做的第一件事。)

乘法是否可以进行类似的优化? 仅当乘法具有关联性时。但事实并非如此!有几种方法不是。

让我们看一个稍微不同的案例。假设我们有

x * 0.5 * 6.0

我们可以这么说吗

(x * 0.5) * 6.0

相同
x * (0.5 * 6.0)

并生成乘以 3.0?不会。假设 x 太小,x 乘以 0.5 会舍入到 0。那么 0 乘以 6.0 仍然是零。所以第一种形式可以给出零,第二种形式可以给出非零值。由于这两个操作给出不同的结果,所以该操作不是关联的。

C# 编译器可以添加智能功能——就像我对字符串连接所做的那样——找出在哪些情况下乘法关联并进行优化,但坦率地说,这根本不值得它。节省字符串连接是一个巨大的胜利。字符串操作在时间和内存上都是昂贵的。程序包含非常多的字符串连接,其中常量和变量混合在一起是很常见的。浮点运算在时间和内存上非常便宜,很难知道哪些是关联的,而且在现实程序中很少有长链的乘法运算。设计、实施和测试优化所花费的时间和精力最好花在编写其他功能上。

【讨论】:

  • 你能告诉我为什么 c# 不折叠整数常量吗?似乎整数乘法是可交换的(至少在checked 上下文中)
  • @AlexSikilinda:同样的原因。在更多情况下优化是安全的,但在极少数程序中优化可以节省纳秒的情况更是如此。可以更好地花在开发优化上的时间和精力。
  • JIT 编译器不是增加了一些优化吗?
  • @FelixK.: 确实是这样,但您必须向有关抖动的专家咨询。我不知道它做了什么优化。我应该在博客中讨论的是,C# 编译器确实 做了一些优化,它意识到一个操作具有一个常量值操作数。例如,如果它看到有一个整数 1 的乘法,那么它将删除乘法,依此类推。
  • “如果它看到有一个整数 1 的乘法,那么它将删除乘法”。如果乘法运算符被重载了怎么办?
【解决方案2】:

不,在这种情况下不会。

看看这段代码:

const double magicalConstant = 43;
static void Main(string[] args)
{
    double random = GetRandom();
    double unicornAge = random * magicalConstant * 2.0;
    Console.WriteLine(unicornAge);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static double GetRandom()
{
    return new Random().NextDouble();
}

我们的反汇编是:

        double random = GetRandom();
00007FFDCD203C92  in          al,dx  
00007FFDCD203C93  sub         al,ch  
00007FFDCD203C95  mov         r14,gs  
00007FFDCD203C98  push        rdx  
        double unicornAge = random * magicalConstant * 2.0;
00007FFDCD203C9A  movups      xmm1,xmmword ptr [7FFDCD203CC0h]  
00007FFDCD203CA1  mulsd       xmm1,xmm0  
00007FFDCD203CA5  mulsd       xmm1,mmword ptr [7FFDCD203CC8h]  
        Console.WriteLine(unicornAge);
00007FFDCD203CAD  movapd      xmm0,xmm1  
00007FFDCD203CB1  call        00007FFE2BEDAFE0  
00007FFDCD203CB6  nop  
00007FFDCD203CB7  add         rsp,28h  
00007FFDCD203CBB  ret  

这里有两个mulsd 指令,因此我们有两个乘法。

现在让我们放置一些括号:

    double unicornAge = random * (magicalConstant * 2.0);
00007FFDCD213C9A  movups      xmm1,xmmword ptr [7FFDCD213CB8h]  
00007FFDCD213CA1  mulsd       xmm1,xmm0  

如您所见,编译器对其进行了优化。 在浮点数世界(a*b)*c != a*(b*c),所以没有人工帮助是无法优化的。

例如整数代码:

        int random = GetRandom();
00007FFDCD203860  sub         rsp,28h  
00007FFDCD203864  call        00007FFDCD0EC8E8  
        int unicornAge = random * magicalConstant * 2;
00007FFDCD203869  imul        eax,eax,2Bh  
        int unicornAge = random * magicalConstant * 2;
00007FFDCD20386C  add         eax,eax  

带括号:

        int random = GetRandom();
00007FFDCD213BA0  sub         rsp,28h  
00007FFDCD213BA4  call        00007FFDCD0FC8E8  
        int unicornAge = random * (magicalConstant * 2);
00007FFDCD213BA9  imul        eax,eax,56h 

【讨论】:

    【解决方案3】:

    在您的特定情况下不会。 让我们考虑以下代码:

    class Program
    {
        const double test = 5.5;
    
        static void Main(string[] args)
        {
            double i = Double.Parse(args[0]);
            Console.WriteLine(test * i * 1.5);
        }
    }
    

    在这种情况下,常量不折叠:

    .method private hidebysig static void  Main(string[] args) cil managed
    {
      .entrypoint
      // Code size       36 (0x24)
      .maxstack  2
      .locals init ([0] float64 i)
      IL_0000:  ldarg.0
      IL_0001:  ldc.i4.0
      IL_0002:  ldelem.ref
      IL_0003:  call       float64 [mscorlib]System.Double::Parse(string)
      IL_0008:  stloc.0
      IL_0009:  ldc.r8     5.5
      IL_0012:  ldloc.0
      IL_0013:  mul
      IL_0014:  ldc.r8     1.5
      IL_001d:  mul
      IL_001e:  call       void [mscorlib]System.Console::WriteLine(float64)
      IL_0023:  ret
    } // end of method Program::Main
    

    但总的来说它会被优化。这种优化称为constant folding

    我们可以证明这一点。以下是 C# 中的测试代码:

    class Program
    {
        const double test = 5.5;
    
        static void Main(string[] args)
        {
            Console.WriteLine(test * 1.5);
        }
    }
    

    这是来自 ILDasm 的反编译代码:

    .method private hidebysig static void  Main(string[] args) cil managed
    {
      .entrypoint
      // Code size       15 (0xf)
      .maxstack  8
      IL_0000:  ldc.r8     8.25
      IL_0009:  call       void [mscorlib]System.Console::WriteLine(float64)
      IL_000e:  ret
    } // end of method Program::Main
    

    如您所见,IL_0000: ldc.r8 8.25 编译器已计算出表达式。

    有些人说是因为你在处理浮点数,但事实并非如此。即使在整数上也不会发生优化:

    class Program
    {
        const int test = 5;
    
        static void Main(string[] args)
        {
            int i = Int32.Parse(args[0]);
            Console.WriteLine(test * i * 2);
        }
    }
    

    Il 代码(不可折叠):

    .method private hidebysig static void  Main(string[] args) cil managed
    {
      .entrypoint
      // Code size       20 (0x14)
      .maxstack  2
      .locals init ([0] int32 i)
      IL_0000:  ldarg.0
      IL_0001:  ldc.i4.0
      IL_0002:  ldelem.ref
      IL_0003:  call       int32 [mscorlib]System.Int32::Parse(string)
      IL_0008:  stloc.0
      IL_0009:  ldc.i4.5
      IL_000a:  ldloc.0
      IL_000b:  mul
      IL_000c:  ldc.i4.2
      IL_000d:  mul
      IL_000e:  call       void [mscorlib]System.Console::WriteLine(int32)
      IL_0013:  ret
    } // end of method Program::Main
    

    【讨论】:

    • 这仅适用于固定文字。在 OPs 代码中,随机首先生成并随类的每个实例而变化
    • ,它的行为不是那样的。请参阅comment:您必须添加括号以使 优化非优化 版本等效。
    • 已编辑,但问题是,@AdrianoRepetti 说由于浮点类型而没有应用优化,但实际上并没有与之相关。整数也没有优化。我相信这可能是由数字溢出引起的(我的意思是选中/未选中)。
    • @AlexSikilinda 对于它确实适用的整数并保存了交换性。经过检查的上下文将抛出一个异常,而未经检查的上下文不会,但结果本身仍然有效:((veryHighNumber * 2) * 3) == veryHighNumber * 6.
    【解决方案4】:

    如果只是:

    double unicornAge = magicalConstant * 2.0;
    

    那么是的,即使编译器不需要执行任何特定的优化,我们也可以合理地期望并假设执行了这个简单的优化。正如Eric 所指出的,这个例子有点误导,因为在这种情况下,编译器也必须将magicalConstant * 2.0 视为一个常量。

    但是由于浮点错误 (random * 6.0 != (random * 3.0) * 2.0),它只会在您添加括号时替换计算值:

    double unicornAge = random * (magicalConstant * 2.0);
    

    编辑:我说的是这些浮点错误?错误有两个原因

    • 精度 1:浮点数是近似值,不允许编译器执行任何会改变结果的优化。例如(在Eric's answer 中更好地展示)如果verySmallValue * 0.1 将舍入为0(因为f.p.​​)然后(verySmallValue * 0.1) * 10 != verySmallValue * (0.1 * 10) 因为0 * 10 == 0
    • 精度 2:您不会只对非常小的数字有此问题,因为 IEEE 754 整数高于 2^53 - 1 (9007199254740991) 无法安全表示,然后 c * (10 * 0.5) 可能会给出不正确的结果如果c * 10 高于9007199254740991(但见后文,这是官方 实现,CPU 可能使用扩展精度)。
    • 精度 3:请注意,x * 0 >= 0 并不总是为真,那么当b0 时,表达式a * b * c >= 0 可能根据ac 值或关联性为真或不真.
    • 范围:浮点数值类型有一个有限范围,如果第一次乘法将导致值为 Infinity,则优化将更改该值。

    让我们看一个关于范围问题的例子,因为它比这更微妙。

    // x = n * c1 * c2
    double x = veryHighNumber * 2 * 0.5;
    

    假设veryHighNumber * 2 超出double 范围,那么您期望(没有任何优化)x+Infinity(因为veryHighNumber * 2+Infinity)。令人惊讶的 (?) 结果是正确的(如果您期望 +Infinity 或不正确)和 x == veryHighNumber(即使编译器保留您编写的内容并为 (veryHighNumber * 2) * 0.5 生成代码)。

    为什么会这样?编译器在这里没有执行任何优化,那么 CPU 必须有罪。 C#编译器生成ldc.r8mul指令,JIT生成这个(如果它编译成纯FPU代码,对于生成的SIMD指令,你可以在Alex's答案中看到反汇编代码):

    fld  qword ptr ds:[00540C48h] ; veryHighNumber
    fmul qword ptr ds:[002A2790h] ; 2
    fmul qword ptr ds:[002A2798h] ; 0.5
    fstp qword ptr [ebp-44h]      ; x
    

    fmulST(0) 与内存中的值相乘并将结果存储在ST(0) 中。寄存器处于扩展精度,那么fmul 链(收缩)不会导致+Infinity,直到它不会溢出扩展精度范围(可以使用在前面的例子中,c1 的数字也很高)。

    仅当中间值保存在 FPU 寄存器中时才会发生这种情况,如果您将我们的示例表达式拆分为多个步骤(其中每个中间值存储在内存中,然后转换回双精度),您将 预期 em> 行为(结果为+Infinity)。这是 IMO 更令人困惑的事情:

    double x = veryHighNumber * 2 * 0.5;
    
    double terriblyHighNumber = veryHighNumber * 2;
    double x2 = terriblyHighNumber * 0.5;
    
    Debug.Assert(!Double.IsInfinity(x));
    Debug.Assert(Double.IsInfinity(x2));
    Debug.Assert(x != x2);
    

    【讨论】:

    • 正如您正确指出的,编译器不需要执行任何特定的优化。然而,C# 规范确实 要求仅包含与某些操作组合的常量的表达式将它们自己归类为常量
    • 您说由于“浮点错误”,将数字乘以 6.0 与乘以 3.0 然后乘以 2.0 不同。你能举一个例子来说明你的主张吗?
    • @EricLippert 如果你允许我一个非常简单的 escape 我会说当乘法是......除法(四舍五入到零或结果为 NaN)。无论如何谈到了here
    • 好吧,如果乘以 6 得到 NaN(或无穷大),那么乘以 2 再乘以 3 也会得到 NaN(或无穷大)。
    • @EricLippert 你是对的(当然),NaN 就像欧芹,但不在这道菜里!
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-09-06
    • 1970-01-01
    • 2011-05-16
    • 2017-05-04
    • 2022-11-03
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多