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 绑定检查错误? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章