JIT和循环优化

16
using System; 

namespace ConsoleApplication1
{ 
    class TestMath
    {  
        static void Main()
        {
            double res = 0.0;

            for(int i =0;i<1000000;++i)
                res +=  System.Math.Sqrt(2.0);

            Console.WriteLine(res);

            Console.ReadKey();  
        }
    }
}

通过将此代码与C++版本进行基准测试,我发现性能比C ++版本慢了10倍。我对此没有问题,但这引出了以下问题:

似乎(经过一些搜索)JIT编译器不能像C++编译器那样优化此代码,即只需调用一次sqrt并对其应用*1000000。

有没有办法强制JIT这样做?


3
我认为JIT编译器没有理由 不能 这样做。当前版本可能会或可能不会这样做,但这并不意味着未来的版本不会这样做。我很好奇 - 您是否尝试在发布模式下编译,而不是调试模式下?您可能会发现发布模式可以显著提高性能。 - Chris Shain
1
@ChrisShain 你确定编译器能做到这一点吗?它必须确保 Math.Sqrt 没有副作用,以便将其简化为单个调用。 - Euphoric
3
我翻译的结果如下:这些数字对我来说不符合逻辑。 C#版本的运行时间是之前的10倍,但调用次数却增加了一百万倍? - Mark Peters
3
@John: 不,这只是教育目的的代码,不是生产代码。如果你所说的“实际”是指生产代码,那就不是了。 - Guillaume Paris
1
除了使用发布版本之外,还要确保您没有启用调试器(即在Visual Studio外部直接启动exe文件)。 - Dan Bryant
显示剩余4条评论
3个回答

10

我重复测试了一下,发现C++版本运行时间是1.2毫秒,而C#版本则需要12.2毫秒。原因很明显,如果你查看C++代码生成器和优化器生成的机器码,就可以看到它将循环改写成了以下形式(使用C#语言表述):

double temp = Math.Sqrt(2.0);
for (int i = 0; i < 1000000; ++i) {
    res += temp;
}

这是两种优化的组合,称为"不变代码移动"和"循环提升"。换句话说,C++编译器知道sqrt()函数的返回值不会受到周围代码的影响,因此可以自由移动。而且,将该代码移出循环并创建一个额外的本地变量来存储结果是值得的,并且计算sqrt()比添加要慢。听起来很明显,但这是一个必须内置于优化器中并且必须考虑的规则之一。

是的,JIT优化器错过了这个机会。它不能像C++优化器那样花费同样多的时间,因为它在严格的时间限制下操作。因为如果它花费的时间太长,程序启动所需的时间就会太长。

开玩笑地说:C#程序员需要比代码生成器聪明一些,并且自己识别这些优化机会。这是一个相当明显的优化机会。好吧,现在你也知道了 :)


编译器为什么需要知道计算平方根比加法慢?此外,C#编译器不能执行这个提升,而不是将其留给JIT吗? - Drew Noakes
把原因反过来想。如果求平方根比加法快,那么这将是一种糟糕的优化。C#编译器不会优化代码,它是JIT的职责。与C++编译器相同,它是代码生成器的职责。 - Hans Passant
1
我一定是漏掉了什么,因为我看不出你如何避免N次加法操作。改变的是执行一次sqrt而不是N次。如果N==1,则可能拥有额外的寄存器变量会更慢,但是如果我们假设N>1,即使被提升的操作本身是一个加法,我们仍然会更快,对吧? - Drew Noakes
不确定你在说什么。如果你建议将重复的加法转换为乘法(可以通过 res = 1000000 * temp 在纸上实现),那么,不,这不是一个有效的优化。由于舍入误差,浮点数运算不是可结合的,你会得到不同的结果。当然,你可以自己编写代码以实现这种方式。 - Hans Passant
1
这不是我想提出的。我试图描绘的画面是用1.0+2.0(一个固定值的加法操作)替换sqrt(2.0)。你说sqrt比加法慢很多,所以如果sqrt的成本与加法相同,编译器会有什么不同的处理方式吗?我看不出你关于相对成本的陈述有任何区别。 - Drew Noakes

6
为了进行您想要的优化,编译器必须确保对于一个特定的输入,函数Sqrt()总是返回相同的值。
编译器可以执行各种检查以确保该函数未使用任何其他“外部”变量来确定其是否无状态。但这也并不意味着它不能受到副作用的影响。
当循环中调用函数时,应在每次迭代中调用它(考虑多线程环境,以了解为什么这很重要)。因此,通常由用户将常量内容从循环中提取出来,如果他希望进行这种优化。
回到C++编译器 - 编译器可能针对其库函数具有某些优化。许多编译器尝试优化重要库(如数学库),因此可能与编译器有关。
另一个很大的区别在于,在C++中,通常会从头文件中包含那种东西。这意味着编译器可能拥有决定函数调用是否在调用之间更改所需的所有信息。
.NET编译器(在编译时-Visual Studio)并不总是拥有解析所有代码所需的所有代码。大多数库函数已经编译为IL(第一阶段)。因此,在考虑第三方dll时可能无法进行深度优化。而在JIT(运行时)编译时,进行这些优化可能会过于昂贵。

5

如果将Math.Sqrt注释为[Pure],可能会有助于JIT(甚至是C#编译器)。然后,假设函数的参数像您的示例一样是常量,则可以将计算值的操作提升到循环外部。

更重要的是,这样的循环可以合理地转换为以下代码:

double res = 1000000 * Math.Sqrt(2.0);

理论上,编译器或JIT可以自动执行此操作。但我怀疑它会针对实际代码中很少出现的模式进行优化。
我提出了一个ReSharper的功能请求,建议设计时工具建议进行这样的重构。

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