我遇到了一个不寻常的情况。在与它无关的循环之前添加的一行代码会减慢循环速度。这是“简化形式”的样子。
我有一个原始的循环方法:
int[] array = new int[ElemCnt];
public int Sum()
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
sum += array[i];
return sum;
}
然后将此方法修改如下:
long state = 1;
public int Sum2()
{
int sum = 0;
if (Interlocked.Read(ref state) == 0)
return sum;
for (int i = 0; i < array.Length; i++)
sum += array[i];
return sum;
}
那些。它只是添加了一个循环前检查。
假设这个检查的执行时间可以通过某个常数来估计是合乎逻辑的。第二种方法(结果最差)的执行时间可以估计T2 ≈ T + C为 随着数组长度的增长,显然这两种方法的执行时间将越来越难以区分()。TCT2 ≈ T
但是,这不会发生:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1766 (21H2)
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=6.0.301
[Host] : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
DefaultJob : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
| 方法 | 元件 | 意思是 | 错误 | 标准差 | 比率 | 比率标准差 |
|---|---|---|---|---|---|---|
| 和 | 1000 | 590.1ns | 6.22ns | 5.81ns | 1.00 | 0.00 |
| 总和2 | 1000 | 1,896.4ns | 9.88ns | 9.24ns | 3.21 | 0.03 |
| 和 | 10000 | 5,729.5ns | 27.40ns | 24.29ns | 1.00 | 0.00 |
| 总和2 | 10000 | 18.929.2ns | 208.10ns | 194.66ns | 3.30 | 0.03 |
| 和 | 100000 | 57.273.8ns | 258.89ns | 242.17ns | 1.00 | 0.00 |
| 总和2 | 100000 | 187.707.1ns | 2,121.93ns | 1,984.85ns | 3.28 | 0.03 |
| 和 | 1000000 | 590.207.1ns | 9,395.29ns | 8,328.68ns | 1.00 | 0.00 |
| 总和2 | 1000000 | 1,943,186.9ns | 34.793.55ns | 32.545.90ns | 3.29 | 0.06 |
那些。它确实有效T2 ≈ K * T!
这表明循环在第二种情况下运行较慢。就好像添加的检查以某种方式减慢了循环。这是怎么回事?
基准代码:
public class LoopBench
{
int[] array = null;
long state = 1;
[Params(1000, 10000, 100000, 1000000)]
public int ElemCnt { get; set; }
[GlobalSetup]
public void Setup()
{
array = new int[ElemCnt];
}
[Benchmark(Baseline = true)]
public int Sum()
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
sum += array[i];
return sum;
}
[Benchmark]
public int Sum2()
{
int sum = 0;
if (Interlocked.Read(ref this.state) == 0)
return sum;
for (int i = 0; i < array.Length; i++)
sum += array[i];
return sum;
}
}
如果我们比较这两种方法的 IL 代码,则看不到任何罪犯。
这是第二种方法的 IL 的样子:
对于第一种方法,我不提供 IL 代码,因为 它几乎相同,看起来好像从IL_0002到IL_0010 的指令被从第二种方法的代码中抛出(实际检查和返回)。至于循环,特别是循环体(从IL_0015到IL_002d)没有区别。
但 JIT 生成的代码略有不同。并且在循环体中同样存在差异。
第一种方法的代码,如果你把所有次要的东西都扔掉,看起来像这样:
而第二种方法的代码(也没有小细节)是这样的:
主要区别在于,在第二种情况下,在循环的每次迭代中,累加的和从堆栈中加载到寄存器 ( r9d ) 中,并在求和后存储回堆栈中。在第一种情况下,总和立即在寄存器(eax)中累加,根本不会进入堆栈。正是这种对堆栈的额外工作(连同内存访问比处理器寄存器慢的事实——即使可能存在缓存)导致第二种方法的性能下降。
如果循环中有大量工作,则使用堆栈不会产生明显的效果。但这里很少。因此,这样的代码生成看起来很不典型,因为 编译器特别注意具有小主体的循环(它们的检测和优化)。
不,检查本身不会减慢循环的执行速度。但它的存在会导致 JIT 为循环生成性能较低的代码。
RyuJIT 编译器的这种行为是对以前版本的回归:
在.NET Framework 4.8和.NET Core 2.2中,事情还没有那么糟糕 :)
我报告了“应该在哪里”的问题。也许它将在.NET的未来版本中得到修复。