为什么JIT顺序会影响性能?

34

为什么在.NET 4.0中,C#方法的即时编译顺序会影响它们执行的速度?例如,考虑两个等价的方法:

public static void SingleLineTest()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    int count = 0;
    for (uint i = 0; i < 1000000000; ++i) {
        count += i % 16 == 0 ? 1 : 0;
    }
    stopwatch.Stop();
    Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
}

public static void MultiLineTest()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    int count = 0;
    for (uint i = 0; i < 1000000000; ++i) {
        var isMultipleOf16 = i % 16 == 0;
        count += isMultipleOf16 ? 1 : 0;
    }
    stopwatch.Stop();
    Console.WriteLine("Multi-line test  --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
}
唯一的区别在于引入了本地变量,这会影响生成的汇编代码和循环性能。为什么会出现这种情况是一个问题,这个问题可以参考这里
可能更奇怪的是,在x86(但不是x64)上调用方法的顺序对性能有约20%的影响。请按照以下方式调用方法...
static void Main()
{
    SingleLineTest();
    MultiLineTest();
}

...并且 SingleLineTest 更快。 (使用x86 Release配置编译,确保启用了“优化代码”设置,并从VS2010外部运行测试。)但是反转顺序...

static void Main()
{
    MultiLineTest();
    SingleLineTest();
}

...同时这两种方法所需的时间相同(几乎相同,但不及 MultiLineTest 多)。运行此测试时,添加一些额外的对 SingleLineTestMultiLineTest 的调用以获取更多样本是有用的。调用的数量和顺序不重要,除了哪种方法首先被调用之外。

最后,为了证明JIT顺序很重要,让 MultiLineTest 先执行,但强制先对 SingleLineTest 进行JIT编译...

static void Main()
{
    RuntimeHelpers.PrepareMethod(typeof(Program).GetMethod("SingleLineTest").MethodHandle);
    MultiLineTest();
    SingleLineTest();
}

现在,SingleLineTest的速度再次提高。

如果你在VS2010中关闭“抑制模块加载时的JIT优化”,则可以在SingleLineTest中设置断点,并查看循环中的汇编代码无论JIT顺序如何都是相同的;但是方法开头处的汇编代码会有所不同。但当大部分时间花费在循环中时,这对性能的影响令人困惑。

GitHub上有一个演示这种行为的示例项目

目前尚不清楚这种行为如何影响实际应用程序的性能。问题之一是,它可能会使性能调整变得不稳定,具体取决于首先调用的方法的顺序。使用分析器很难检测到此类问题。一旦找到了热点并优化了他们的算法,除非进行很多猜测和试错,否则很难知道是否可以通过尽早JIT方法来获得额外的加速。

更新:请参阅此问题的Microsoft Connect条目


"...对性能的影响大约在20%左右" 我得到了大约8%。 - Lukasz Madon
1
实际指令的对齐方式是否相同?无论是 JITted 跟随另一种方法,还是对前导进行微小更改,都可能影响对齐。 - Ben Voigt
@lukas 在两台配备Intel Core i5的计算机上,我对SingleLineTest进行测试得到平均值分别为1412ms和1490ms,对MultiLineTest进行测试得到平均值分别为1773ms和1792ms。这意味着速度提升了26%和20%。对于每台计算机,速度提升的标准差为2%。我本来期望会看到一些机器之间的差异,但8%的差异令人惊讶。 - Edward Brey
在移动版的第一代Core i7上,我得到了大约2000到2400之间的性能,因此相似的百分比为20%。 - Ben Voigt
3个回答

25

请注意,我不信任“在模块加载时禁止JIT优化”选项,我会在不调试的情况下生成进程,并在JIT运行后附加我的调试器。

在单行运行更快的版本中,这是Main

        SingleLineTest();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  call        dword ptr ds:[0019380Ch] 
            MultiLineTest();
00000009  call        dword ptr ds:[00193818h] 
            SingleLineTest();
0000000f  call        dword ptr ds:[0019380Ch] 
            MultiLineTest();
00000015  call        dword ptr ds:[00193818h] 
            SingleLineTest();
0000001b  call        dword ptr ds:[0019380Ch] 
            MultiLineTest();
00000021  call        dword ptr ds:[00193818h] 
00000027  pop         ebp 
        }
00000028  ret 

请注意,MultiLineTest 已经放置在 8 字节边界上,而 SingleLineTest 则放置在 4 字节边界上。

下面是两个同时运行的版本的 Main

            MultiLineTest();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  call        dword ptr ds:[00153818h] 

            SingleLineTest();
00000009  call        dword ptr ds:[0015380Ch] 
            MultiLineTest();
0000000f  call        dword ptr ds:[00153818h] 
            SingleLineTest();
00000015  call        dword ptr ds:[0015380Ch] 
            MultiLineTest();
0000001b  call        dword ptr ds:[00153818h] 
            SingleLineTest();
00000021  call        dword ptr ds:[0015380Ch] 
            MultiLineTest();
00000027  call        dword ptr ds:[00153818h] 
0000002d  pop         ebp 
        }
0000002e  ret 

令人惊奇的是,JIT选择的地址在最后4位相同,即使它声称按相反顺序处理它们。不确定我还是否相信。

需要更深入的挖掘。我认为提到了循环之前的代码在两个版本中并不完全相同?我要进行调查。

这是SingleLineTest的“慢速”版本(我检查过,函数地址的最后几位没有改变)。

            Stopwatch stopwatch = new Stopwatch();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        edi 
00000004  push        esi 
00000005  push        ebx 
00000006  mov         ecx,7A5A2C68h 
0000000b  call        FFF91EA0 
00000010  mov         esi,eax 
00000012  mov         dword ptr [esi+4],0 
00000019  mov         dword ptr [esi+8],0 
00000020  mov         byte ptr [esi+14h],0 
00000024  mov         dword ptr [esi+0Ch],0 
0000002b  mov         dword ptr [esi+10h],0 
            stopwatch.Start();
00000032  cmp         byte ptr [esi+14h],0 
00000036  jne         00000047 
00000038  call        7A22B314 
0000003d  mov         dword ptr [esi+0Ch],eax 
00000040  mov         dword ptr [esi+10h],edx 
00000043  mov         byte ptr [esi+14h],1 
            int count = 0;
00000047  xor         edi,edi 
            for (uint i = 0; i < 1000000000; ++i) {
00000049  xor         edx,edx 
                count += i % 16 == 0 ? 1 : 0;
0000004b  mov         eax,edx 
0000004d  and         eax,0Fh 
00000050  test        eax,eax 
00000052  je          00000058 
00000054  xor         eax,eax 
00000056  jmp         0000005D 
00000058  mov         eax,1 
0000005d  add         edi,eax 
            for (uint i = 0; i < 1000000000; ++i) {
0000005f  inc         edx 
00000060  cmp         edx,3B9ACA00h 
00000066  jb          0000004B 
            }
            stopwatch.Stop();
00000068  mov         ecx,esi 
0000006a  call        7A23F2C0 
            Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
0000006f  mov         ecx,797C29B4h 
00000074  call        FFF91EA0 
00000079  mov         ecx,eax 
0000007b  mov         dword ptr [ecx+4],edi 
0000007e  mov         ebx,ecx 
00000080  mov         ecx,797BA240h 
00000085  call        FFF91EA0 
0000008a  mov         edi,eax 
0000008c  mov         ecx,esi 
0000008e  call        7A23ABE8 
00000093  push        edx 
00000094  push        eax 
00000095  push        0 
00000097  push        2710h 
0000009c  call        783247EC 
000000a1  mov         dword ptr [edi+4],eax 
000000a4  mov         dword ptr [edi+8],edx 
000000a7  mov         esi,edi 
000000a9  call        793C6F40 
000000ae  push        ebx 
000000af  push        esi 
000000b0  mov         ecx,eax 
000000b2  mov         edx,dword ptr ds:[03392034h] 
000000b8  mov         eax,dword ptr [ecx] 
000000ba  mov         eax,dword ptr [eax+3Ch] 
000000bd  call        dword ptr [eax+1Ch] 
000000c0  pop         ebx 
        }
000000c1  pop         esi 
000000c2  pop         edi 
000000c3  pop         ebp 
000000c4  ret 

还有“快速”版本:

            Stopwatch stopwatch = new Stopwatch();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        edi 
00000004  push        esi 
00000005  push        ebx 
00000006  mov         ecx,7A5A2C68h 
0000000b  call        FFE11F70 
00000010  mov         esi,eax 
00000012  mov         ecx,esi 
00000014  call        7A1068BC 
            stopwatch.Start();
00000019  cmp         byte ptr [esi+14h],0 
0000001d  jne         0000002E 
0000001f  call        7A12B3E4 
00000024  mov         dword ptr [esi+0Ch],eax 
00000027  mov         dword ptr [esi+10h],edx 
0000002a  mov         byte ptr [esi+14h],1 
            int count = 0;
0000002e  xor         edi,edi 
            for (uint i = 0; i < 1000000000; ++i) {
00000030  xor         edx,edx 
                count += i % 16 == 0 ? 1 : 0;
00000032  mov         eax,edx 
00000034  and         eax,0Fh 
00000037  test        eax,eax 
00000039  je          0000003F 
0000003b  xor         eax,eax 
0000003d  jmp         00000044 
0000003f  mov         eax,1 
00000044  add         edi,eax 
            for (uint i = 0; i < 1000000000; ++i) {
00000046  inc         edx 
00000047  cmp         edx,3B9ACA00h 
0000004d  jb          00000032 
            }
            stopwatch.Stop();
0000004f  mov         ecx,esi 
00000051  call        7A13F390 
            Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
00000056  mov         ecx,797C29B4h 
0000005b  call        FFE11F70 
00000060  mov         ecx,eax 
00000062  mov         dword ptr [ecx+4],edi 
00000065  mov         ebx,ecx 
00000067  mov         ecx,797BA240h 
0000006c  call        FFE11F70 
00000071  mov         edi,eax 
00000073  mov         ecx,esi 
00000075  call        7A13ACB8 
0000007a  push        edx 
0000007b  push        eax 
0000007c  push        0 
0000007e  push        2710h 
00000083  call        782248BC 
00000088  mov         dword ptr [edi+4],eax 
0000008b  mov         dword ptr [edi+8],edx 
0000008e  mov         esi,edi 
00000090  call        792C7010 
00000095  push        ebx 
00000096  push        esi 
00000097  mov         ecx,eax 
00000099  mov         edx,dword ptr ds:[03562030h] 
0000009f  mov         eax,dword ptr [ecx] 
000000a1  mov         eax,dword ptr [eax+3Ch] 
000000a4  call        dword ptr [eax+1Ch] 
000000a7  pop         ebx 
        }
000000a8  pop         esi 
000000a9  pop         edi 
000000aa  pop         ebp 
000000ab  ret 

只有循环,左边快,右边慢:

00000030  xor         edx,edx                 00000049  xor         edx,edx 
00000032  mov         eax,edx                 0000004b  mov         eax,edx 
00000034  and         eax,0Fh                 0000004d  and         eax,0Fh 
00000037  test        eax,eax                 00000050  test        eax,eax 
00000039  je          0000003F                00000052  je          00000058 
0000003b  xor         eax,eax                 00000054  xor         eax,eax 
0000003d  jmp         00000044                00000056  jmp         0000005D 
0000003f  mov         eax,1                   00000058  mov         eax,1 
00000044  add         edi,eax                 0000005d  add         edi,eax 
00000046  inc         edx                     0000005f  inc         edx 
00000047  cmp         edx,3B9ACA00h           00000060  cmp         edx,3B9ACA00h 
0000004d  jb          00000032                00000066  jb          0000004B 
指令是相同的(作为相对跳转,机器码相同,即使反汇编显示不同的地址),但对齐方式不同。有三个跳转。在“慢”版本中对常数1进行加载的je被对齐,而在“快”版本中则没有,但这几乎没有关系,因为该跳转只执行1/16的时间。另外两个跳转(在加载零常数后的jmp和重复整个循环的jb)执行了数百万次,并在“快速”版本中对齐。
我认为这就是确凿证据。

函数地址的最后几位没有变化,这个对我也是成立的。但我的反汇编输出有104行,其中只有一行是75。 - Lukasz Madon
是的。构建选项卡:目标为x86,优化代码已勾选并且我从“调试”->“窗口”->“反汇编”中获取了汇编代码。 - Lukasz Madon
@lukas:您能否把它粘贴到“答案”或pastebin/ideone/任何地方,这样我就可以看到您的代码了吗? - Ben Voigt
你是在说4字节对齐吗?“快速”jmp只对齐一个,是吗? - Lukasz Madon
@lukas:jmp 44hjb 32h的对齐要比jmp 5Dhjb 4Bh好。哦,抱歉,我把循环末尾和循环中央的条件搞混了,是吧?说明已经修复。 - Ben Voigt

0

我的电脑配置是i5-2410M 2.3GHz,4GB内存,64位Win 7系统,时间分别为2400和2600。

以下是我的输出结果:Single first

在启动进程后再附加调试器。

            SingleLineTest();
            MultiLineTest();
            SingleLineTest();
            MultiLineTest();
            SingleLineTest();
            MultiLineTest();
--------------------------------
SingleLineTest()
           Stopwatch stopwatch = new Stopwatch();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        edi 
00000004  push        esi 
00000005  push        ebx 
00000006  mov         ecx,685D2C68h 
0000000b  call        FFD91F70 
00000010  mov         esi,eax 
00000012  mov         ecx,esi 
00000014  call        681D68BC 
            stopwatch.Start();
00000019  cmp         byte ptr [esi+14h],0 
0000001d  jne         0000002E 
0000001f  call        681FB3E4 
00000024  mov         dword ptr [esi+0Ch],eax 
00000027  mov         dword ptr [esi+10h],edx 
0000002a  mov         byte ptr [esi+14h],1 
            int count = 0;
0000002e  xor         edi,edi 
            for (int i = 0; i < 1000000000; ++i)
00000030  xor         edx,edx 
            {
                count += i % 16 == 0 ? 1 : 0;
00000032  mov         eax,edx 
00000034  and         eax,8000000Fh 
00000039  jns         00000040 
0000003b  dec         eax 
0000003c  or          eax,0FFFFFFF0h 
0000003f  inc         eax 
00000040  test        eax,eax 
00000042  je          00000048 
00000044  xor         eax,eax 
00000046  jmp         0000004D 
00000048  mov         eax,1 
0000004d  add         edi,eax 
            for (int i = 0; i < 1000000000; ++i)
0000004f  inc         edx 
00000050  cmp         edx,3B9ACA00h 
00000056  jl          00000032 
            }
            stopwatch.Stop();
00000058  mov         ecx,esi 
0000005a  call        6820F390 
            Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
0000005f  mov         ecx,6A8B29B4h 
00000064  call        FFD91F70 
00000069  mov         ecx,eax 
0000006b  mov         dword ptr [ecx+4],edi 
0000006e  mov         ebx,ecx 
00000070  mov         ecx,6A8AA240h 
00000075  call        FFD91F70 
0000007a  mov         edi,eax 
0000007c  mov         ecx,esi 
0000007e  call        6820ACB8 
00000083  push        edx 
00000084  push        eax 
00000085  push        0 
00000087  push        2710h 
0000008c  call        6AFF48BC 
00000091  mov         dword ptr [edi+4],eax 
00000094  mov         dword ptr [edi+8],edx 
00000097  mov         esi,edi 
00000099  call        6A457010 
0000009e  push        ebx 
0000009f  push        esi 
000000a0  mov         ecx,eax 
000000a2  mov         edx,dword ptr ds:[039F2030h] 
000000a8  mov         eax,dword ptr [ecx] 
000000aa  mov         eax,dword ptr [eax+3Ch] 
000000ad  call        dword ptr [eax+1Ch] 
000000b0  pop         ebx 
        }
000000b1  pop         esi 
000000b2  pop         edi 
000000b3  pop         ebp 
000000b4  ret 

多重优先级:

            MultiLineTest();

            SingleLineTest();
            MultiLineTest();
            SingleLineTest();
            MultiLineTest();
            SingleLineTest();
            MultiLineTest();
--------------------------------
SingleLineTest()
            Stopwatch stopwatch = new Stopwatch();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        edi 
00000004  push        esi 
00000005  push        ebx 
00000006  mov         ecx,685D2C68h 
0000000b  call        FFF31EA0 
00000010  mov         esi,eax 
00000012  mov         dword ptr [esi+4],0 
00000019  mov         dword ptr [esi+8],0 
00000020  mov         byte ptr [esi+14h],0 
00000024  mov         dword ptr [esi+0Ch],0 
0000002b  mov         dword ptr [esi+10h],0 
            stopwatch.Start();
00000032  cmp         byte ptr [esi+14h],0 
00000036  jne         00000047 
00000038  call        682AB314 
0000003d  mov         dword ptr [esi+0Ch],eax 
00000040  mov         dword ptr [esi+10h],edx 
00000043  mov         byte ptr [esi+14h],1 
            int count = 0;
00000047  xor         edi,edi 
            for (int i = 0; i < 1000000000; ++i)
00000049  xor         edx,edx 
            {
                count += i % 16 == 0 ? 1 : 0;
0000004b  mov         eax,edx 
0000004d  and         eax,8000000Fh 
00000052  jns         00000059 
00000054  dec         eax 
00000055  or          eax,0FFFFFFF0h 
00000058  inc         eax 
00000059  test        eax,eax 
0000005b  je          00000061 
0000005d  xor         eax,eax 
0000005f  jmp         00000066 
00000061  mov         eax,1 
00000066  add         edi,eax 
            for (int i = 0; i < 1000000000; ++i)
00000068  inc         edx 
00000069  cmp         edx,3B9ACA00h 
0000006f  jl          0000004B 
            }
            stopwatch.Stop();
00000071  mov         ecx,esi 
00000073  call        682BF2C0 
            Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
00000078  mov         ecx,6A8B29B4h 
0000007d  call        FFF31EA0 
00000082  mov         ecx,eax 
00000084  mov         dword ptr [ecx+4],edi 
00000087  mov         ebx,ecx 
00000089  mov         ecx,6A8AA240h 
0000008e  call        FFF31EA0 
00000093  mov         edi,eax 
00000095  mov         ecx,esi 
00000097  call        682BABE8 
0000009c  push        edx 
0000009d  push        eax 
0000009e  push        0 
000000a0  push        2710h 
000000a5  call        6B0A47EC 
000000aa  mov         dword ptr [edi+4],eax 
000000ad  mov         dword ptr [edi+8],edx 
000000b0  mov         esi,edi 
000000b2  call        6A506F40 
000000b7  push        ebx 
000000b8  push        esi 
000000b9  mov         ecx,eax 
000000bb  mov         edx,dword ptr ds:[038E2034h] 
000000c1  mov         eax,dword ptr [ecx] 
000000c3  mov         eax,dword ptr [eax+3Ch] 
000000c6  call        dword ptr [eax+1Ch] 
000000c9  pop         ebx 
        }
000000ca  pop         esi 
000000cb  pop         edi 
000000cc  pop         ebp 
000000cd  ret

看到那些nop指令了吗?我认为这并没有被优化,因为在调试器中运行会抑制优化。 - Ben Voigt
@Ben 是的,我后来附加了调试器,输出有点不同。 - Lukasz Madon

0

那么要得到一个明确的答案... 我怀疑我们需要挖掘汇编语言。

然而,我有一个猜想。 SingleLineTest() 的编译器在堆栈上存储每个方程式的结果,并在需要时弹出每个值。 然而,MultiLineTest() 可能正在存储值并且必须从那里访问它们。 这可能会导致错过几个时钟周期。 而从堆栈中抓取值将使其保留在寄存器中。

有趣的是,更改函数编译的顺序可能会调整垃圾收集器的操作。 因为isMultipleOf16定义在循环内部,所以它可能会被处理得很奇怪。 您可能希望将该定义移动到循环外部,看看那会有什么变化...


由于isMultipleOf16是值类型,它存储在堆栈上,并且不会被垃圾回收器触及。除了用于秒表和控制台输出之外,垃圾回收器不应该做任何事情。 - Edward Brey
"由于isMultipleOf16是一个值类型,它存储在堆栈上,并且不会被GC触及"这是不正确的。没有这样的保证。JITer可以将值类型移动到堆上。但我想这里不是这种情况。" - Lukasz Madon
如果将定义移出循环,会有所不同吗? - poy
1
@Andrew:我试过了,将isMultipleOf16移出循环并没有什么影响。至少在这方面你可以信任编译器。 - Edward Brey
@Andrew:我确实查看了反汇编,并在上面的问题正文中表达了我的想法。如果你有兴趣,我还提供了如何自行检查它的提示(最简单的方法是从Github获取示例项目的Zip并进行操作)。 - Edward Brey
@EdwardBrey:好的。我现在不在电脑前,无法编译和运行代码。我会尽量明天看一下...我的兴趣被激发了。 - poy

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