C# 意外的循环性能。可能的 JIT 绑定检查错误? [关闭]

Posted

技术标签:

【中文标题】C# 意外的循环性能。可能的 JIT 绑定检查错误? [关闭]【英文标题】:C# Unexpected loop performance. Possible JIT bound check bug? [closed] 【发布时间】:2020-12-28 05:38:53 【问题描述】:

在比较两个应该执行相同的方法生成的 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[], Int32[])
    L0000: sub rsp, 0x28
    L0004: xor eax, 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 性能更差,即使它根本不执行边界检查?


我还运行 BenchmarkDotNet 并验证 M2 对于长度为 10 的数组的性能比 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, CoreFX 4.700.20.37001), 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 性能更差,即使它根本不执行边界检查?” -- 你没有提供minimal reproducible example,所以不可能肯定地说。但是,M1() 要知道它可以使用“快速”路径,有很多开销......如果您的数组不够大,开销将占主导地位并产生违反直觉的结果。 循环迭代太少,用大约 500k 次迭代进行测试,那么 resilt 会更加独立。 当我测试这两个版本时,几乎没有区别。 我试过这个,我可以重现小阵列的结果 - 但我也尝试了更大的阵列,与 M2 相比,M1 逐渐变得越来越快。在 N=50,000 时,M1 比 M2 快 50%。 将您的示例添加到 RyuJit 中循环克隆的跟踪问题中:github.com/dotnet/runtime/issues/8558 【参考方案1】:

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

彼得·杜尼霍

为优化边界检查选择路径(在 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++)

【讨论】:

以上是关于C# 意外的循环性能。可能的 JIT 绑定检查错误? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章

没有绑定检查的 C# byte[] 比较

JIT被批准用于PHP 8,以提高CPU性能

Java JIT循环展开策略?

.Net8顶级技术:边界检查之IR解析(慎入)

角度意外错误:无法将 FormControl 绑定到输入

C# 减少嵌套循环