C#意外的循环性能可能的JIT绑定检查错误?

问题描述

当比较两种应该执行相同方法生成的JIT时,我发现有些奇怪。 令我惊讶的是,生成的JIT有很大的不同,对于本来简单的方法M1,它的长度几乎增加了一倍。

我比较的方法是M1和M2。 分配的数量是相同的,所以唯一的区别应该是每种方法的绑定检查的处理方式。

using System;

public class C {
    static void M1(int[] left,int[] right)
    {
        for (int i = 0; i < 5; i++)
        {
            left[i] = 1;
            right[i] = 1;
        }
    }  
    
    static void M2(int[] left,int[] right)
    {
        for (int i = 0; i < 10; i+=2)
        {
            left[i] = 1;
            right[i] = 1;
        }
    } 
}

为每种方法生成的JIT:

C.M1(Int32[],Int32[])
    L0000: sub rsp,0x28
    L0004: xor eax,eax
    L0006: test rcx,rcx
    L0009: setne r8b
    L000d: movzx r8d,r8b
    L0011: test rdx,rdx
    L0014: setne r9b
    L0018: movzx r9d,r9b
    L001c: test r9d,r8d
    L001f: je short L005c
    L0021: cmp dword ptr [rcx+8],5
    L0025: setge r8b
    L0029: movzx r8d,r8b
    L002d: cmp dword ptr [rdx+8],5
    L0031: setge r9b
    L0035: movzx r9d,r9b
    L0039: test r9d,r8d
    L003c: je short L005c
    L003e: movsxd r8,eax
    L0041: mov dword ptr [rcx+r8*4+0x10],1
    L004a: mov dword ptr [rdx+r8*4+0x10],1
    L0053: inc eax
    L0055: cmp eax,5
    L0058: jl short L003e
    L005a: jmp short L0082
    L005c: cmp eax,[rcx+8]
    L005f: jae short L0087
    L0061: movsxd r8,eax
    L0064: mov dword ptr [rcx+r8*4+0x10],1
    L006d: cmp eax,[rdx+8]
    L0070: jae short L0087
    L0072: mov dword ptr [rdx+r8*4+0x10],1
    L007b: inc eax
    L007d: cmp eax,5
    L0080: jl short L005c
    L0082: add rsp,0x28
    L0086: ret
    L0087: call 0x00007ffc50fafc00
    L008c: int3

C.M2(Int32[],eax
    L0006: mov r8d,[rcx+8]
    L000a: cmp eax,r8d
    L000d: jae short L0036
    L000f: movsxd r9,eax
    L0012: mov dword ptr [rcx+r9*4+0x10],1
    L001b: cmp eax,[rdx+8]
    L001e: jae short L0036
    L0020: mov dword ptr [rdx+r9*4+0x10],1
    L0029: add eax,2
    L002c: cmp eax,0xa
    L002f: jl short L000a
    L0031: add rsp,0x28
    L0035: ret
    L0036: call 0x00007ffc50fafc00
    L003b: int3

M1的长度是M2的两倍!

什么可以解释这一点?它是某种错误吗?

编辑

发现M1为循环创建了一个版本,没有绑定检查,这就是M1更长的原因。问题仍然存在,即使M1根本不执行边界检查,为什么M1的性能仍然较差?


我还运行了BenchmarkDotNet,并验证了对于长度为10的数组,M2的执行速度比M1快20%-30%。

BenchmarkDotNet=v0.12.1,OS=Windows 10.0.14393.3930 (1607/AnniversaryUpdate/Redstone1)
Intel Core i7-4790 cpu 3.60GHz (Haswell),1 cpu,8 logical and 4 physical cores
Frequency=3515622 Hz,Resolution=284.4447 ns,Timer=TSC
.NET Core SDK=3.1.401
  [Host]     : .NET Core 3.1.7 (CoreCLR 4.700.20.36602,CoreFX 4.700.20.37001),X64 RyuJIT
  DefaultJob : .NET Core 3.1.7 (CoreCLR 4.700.20.36602,X64 RyuJIT


|  Method |     Mean |     Error |    StdDev | Ratio |
|-------- |---------:|----------:|----------:|------:|
| M1Bench | 4.372 ns | 0.0215 ns | 0.0201 ns |  1.00 |
| M2Bench | 3.350 ns | 0.0340 ns | 0.0301 ns |  0.77 |

解决方法

但是,要知道M1()可以使用很多开销 “快速”路径...如果您的数组不够大,那么开销 将占主导地位并产生违反直觉的结果。

彼得·杜尼奥(Peter Duniho)

为循环类型优化的边界检查选择路径(在JIT中)的开销:

for(int i = 0; i < array.Length; i++)

对于较小的循环不会有好处。

随着循环的增大,消除边界检查变得更加有益,并超越了非优化路径的性能。

非优化循环的示例:

for(int i = 0; i < array.Length; i+=2)
for(int i = 0; i <= array.Length; i++)
for(int i = 0; i < array.Length / 2; i++)