为什么这个总计操作在栈上比在堆上更快?
Why is this totaling operation faster on the stack than the heap?
在 Broadwell CPU 和 Windows 8.1 上的 Visual Studio 2015 Update 2 x64 发布模式下编译的以下 C# 程序中,基准测试的两个变体是 运行 .他们都做同样的事情——一个数组中总共有 500 万个整数。
两个基准测试的区别在于,一个版本将 运行ning 总计(一个长整数)保存在堆栈中,而另一个版本将其保存在堆中。两个版本都没有分配;沿数组扫描时添加总数。
在测试中,我发现基准变体与堆上的总数和堆栈上的总数之间存在一致的显着性能差异。对于某些测试大小,当总数在堆上时,速度会慢三倍。
为什么两个内存位置的总性能差异如此之大?
using System;
using System.Diagnostics;
namespace StackHeap
{
class StackvHeap
{
static void Main(string[] args)
{
double stackAvgms, heapAvgms;
// Warmup
runBenchmark(out stackAvgms, out heapAvgms);
// Run
runBenchmark(out stackAvgms, out heapAvgms);
Console.WriteLine($"Stack avg: {stackAvgms} ms\nHeap avg: {heapAvgms} ms");
}
private static void runBenchmark(out double stackAvgms, out double heapAvgms)
{
Benchmarker b = new Benchmarker();
long stackTotalms = 0;
int trials = 100;
for (int i = 0; i < trials; ++i)
{
stackTotalms += b.stackTotaler();
}
long heapTotalms = 0;
for (int i = 0; i < trials; ++i)
{
heapTotalms += b.heapTotaler();
}
stackAvgms = stackTotalms / (double)trials;
heapAvgms = heapTotalms / (double)trials;
}
}
class Benchmarker
{
long heapTotal;
int[] vals = new int[5000000];
public long heapTotaler()
{
setup();
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < vals.Length; ++i)
{
heapTotal += vals[i];
}
stopWatch.Stop();
//Console.WriteLine($"{stopWatch.ElapsedMilliseconds} milliseconds with the counter on the heap");
return stopWatch.ElapsedMilliseconds;
}
public long stackTotaler()
{
setup();
var stopWatch = new Stopwatch();
stopWatch.Start();
long stackTotal = 0;
for (int i = 0; i < vals.Length; ++i)
{
stackTotal += vals[i];
}
stopWatch.Stop();
//Console.WriteLine($"{stopWatch.ElapsedMilliseconds} milliseconds with the counter on the stack");
return stopWatch.ElapsedMilliseconds;
}
private void setup()
{
heapTotal = 0;
for (int i = 0; i < vals.Length; ++i)
{
vals[i] = i;
}
}
}
}
With some test sizes it's three times slower
这是解决潜在问题的唯一线索。如果您关心 long 变量的性能,则不要使用 x86 抖动。对齐很关键,在 32 位模式下无法获得足够好的对齐保证。
然后 CLR 只能对齐到 4,这给这样的测试提供了 3 个不同的结果。变量可以对齐到 8,快速版本。并且在高速缓存行中未对齐到 4,大约慢 2 倍。并且未对齐到 4 并跨越 L1 缓存行边界,大约慢 3 倍。 double 顺便说一句,同样的问题。
使用“项目”>“属性”>“构建”选项卡> 取消选中 "Prefer 32-bit mode" 复选框。以防万一,使用工具 > 选项 > 调试 > 常规 > 取消勾选 "Suppress JIT optimization"。调整基准代码,在代码周围放置一个 for 循环,我总是 运行 它至少 10 次。 Select 发布模式配置和 运行 再次测试。
你现在有一个完全不同的问题,可能更符合你的预期。是的,默认情况下,局部变量不是 volatile,字段是。必须在循环内更新 heapTotal 是您看到的开销。
这是来自 heapTotaller
反汇编:
heapTotal = 0;
000007FE99F34966 xor ecx,ecx
000007FE99F34968 mov qword ptr [rsi+10h],rcx
for (int i = 0; i < vals.Length; ++i)
000007FE99F3496C mov rax,qword ptr [rsi+8]
000007FE99F34970 mov edx,dword ptr [rax+8]
000007FE99F34973 test edx,edx
000007FE99F34975 jle 000007FE99F34993
{
heapTotal += vals[i];
000007FE99F34977 mov r8,rax
000007FE99F3497A cmp ecx,edx
000007FE99F3497C jae 000007FE99F349C8
000007FE99F3497E movsxd r9,ecx
000007FE99F34981 mov r8d,dword ptr [r8+r9*4+10h]
000007FE99F34986 movsxd r8,r8d
000007FE99F34989 add qword ptr [rsi+10h],r8
您可以看到它使用 [rsi+10h]
作为 heapTotal
变量。
这是来自 stackTotaller
:
long stackTotal = 0;
000007FE99F3427A xor ecx,ecx
for (int i = 0; i < vals.Length; ++i)
000007FE99F3427C xor eax,eax
000007FE99F3427E mov rdx,qword ptr [rsi+8]
000007FE99F34282 mov r8d,dword ptr [rdx+8]
000007FE99F34286 test r8d,r8d
000007FE99F34289 jle 000007FE99F342A8
{
stackTotal += vals[i];
000007FE99F3428B mov r9,rdx
000007FE99F3428E cmp eax,r8d
000007FE99F34291 jae 000007FE99F342DD
000007FE99F34293 movsxd r10,eax
000007FE99F34296 mov r9d,dword ptr [r9+r10*4+10h]
000007FE99F3429B movsxd r9,r9d
000007FE99F3429E add rcx,r9
您可以看到 JIT 已经优化了代码:它使用 RCX
寄存器 heapTotal
。
寄存器比内存访问快,因此速度提高。
在 Broadwell CPU 和 Windows 8.1 上的 Visual Studio 2015 Update 2 x64 发布模式下编译的以下 C# 程序中,基准测试的两个变体是 运行 .他们都做同样的事情——一个数组中总共有 500 万个整数。
两个基准测试的区别在于,一个版本将 运行ning 总计(一个长整数)保存在堆栈中,而另一个版本将其保存在堆中。两个版本都没有分配;沿数组扫描时添加总数。
在测试中,我发现基准变体与堆上的总数和堆栈上的总数之间存在一致的显着性能差异。对于某些测试大小,当总数在堆上时,速度会慢三倍。
为什么两个内存位置的总性能差异如此之大?
using System;
using System.Diagnostics;
namespace StackHeap
{
class StackvHeap
{
static void Main(string[] args)
{
double stackAvgms, heapAvgms;
// Warmup
runBenchmark(out stackAvgms, out heapAvgms);
// Run
runBenchmark(out stackAvgms, out heapAvgms);
Console.WriteLine($"Stack avg: {stackAvgms} ms\nHeap avg: {heapAvgms} ms");
}
private static void runBenchmark(out double stackAvgms, out double heapAvgms)
{
Benchmarker b = new Benchmarker();
long stackTotalms = 0;
int trials = 100;
for (int i = 0; i < trials; ++i)
{
stackTotalms += b.stackTotaler();
}
long heapTotalms = 0;
for (int i = 0; i < trials; ++i)
{
heapTotalms += b.heapTotaler();
}
stackAvgms = stackTotalms / (double)trials;
heapAvgms = heapTotalms / (double)trials;
}
}
class Benchmarker
{
long heapTotal;
int[] vals = new int[5000000];
public long heapTotaler()
{
setup();
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < vals.Length; ++i)
{
heapTotal += vals[i];
}
stopWatch.Stop();
//Console.WriteLine($"{stopWatch.ElapsedMilliseconds} milliseconds with the counter on the heap");
return stopWatch.ElapsedMilliseconds;
}
public long stackTotaler()
{
setup();
var stopWatch = new Stopwatch();
stopWatch.Start();
long stackTotal = 0;
for (int i = 0; i < vals.Length; ++i)
{
stackTotal += vals[i];
}
stopWatch.Stop();
//Console.WriteLine($"{stopWatch.ElapsedMilliseconds} milliseconds with the counter on the stack");
return stopWatch.ElapsedMilliseconds;
}
private void setup()
{
heapTotal = 0;
for (int i = 0; i < vals.Length; ++i)
{
vals[i] = i;
}
}
}
}
With some test sizes it's three times slower
这是解决潜在问题的唯一线索。如果您关心 long 变量的性能,则不要使用 x86 抖动。对齐很关键,在 32 位模式下无法获得足够好的对齐保证。
然后 CLR 只能对齐到 4,这给这样的测试提供了 3 个不同的结果。变量可以对齐到 8,快速版本。并且在高速缓存行中未对齐到 4,大约慢 2 倍。并且未对齐到 4 并跨越 L1 缓存行边界,大约慢 3 倍。 double 顺便说一句,同样的问题。
使用“项目”>“属性”>“构建”选项卡> 取消选中 "Prefer 32-bit mode" 复选框。以防万一,使用工具 > 选项 > 调试 > 常规 > 取消勾选 "Suppress JIT optimization"。调整基准代码,在代码周围放置一个 for 循环,我总是 运行 它至少 10 次。 Select 发布模式配置和 运行 再次测试。
你现在有一个完全不同的问题,可能更符合你的预期。是的,默认情况下,局部变量不是 volatile,字段是。必须在循环内更新 heapTotal 是您看到的开销。
这是来自 heapTotaller
反汇编:
heapTotal = 0;
000007FE99F34966 xor ecx,ecx
000007FE99F34968 mov qword ptr [rsi+10h],rcx
for (int i = 0; i < vals.Length; ++i)
000007FE99F3496C mov rax,qword ptr [rsi+8]
000007FE99F34970 mov edx,dword ptr [rax+8]
000007FE99F34973 test edx,edx
000007FE99F34975 jle 000007FE99F34993
{
heapTotal += vals[i];
000007FE99F34977 mov r8,rax
000007FE99F3497A cmp ecx,edx
000007FE99F3497C jae 000007FE99F349C8
000007FE99F3497E movsxd r9,ecx
000007FE99F34981 mov r8d,dword ptr [r8+r9*4+10h]
000007FE99F34986 movsxd r8,r8d
000007FE99F34989 add qword ptr [rsi+10h],r8
您可以看到它使用 [rsi+10h]
作为 heapTotal
变量。
这是来自 stackTotaller
:
long stackTotal = 0;
000007FE99F3427A xor ecx,ecx
for (int i = 0; i < vals.Length; ++i)
000007FE99F3427C xor eax,eax
000007FE99F3427E mov rdx,qword ptr [rsi+8]
000007FE99F34282 mov r8d,dword ptr [rdx+8]
000007FE99F34286 test r8d,r8d
000007FE99F34289 jle 000007FE99F342A8
{
stackTotal += vals[i];
000007FE99F3428B mov r9,rdx
000007FE99F3428E cmp eax,r8d
000007FE99F34291 jae 000007FE99F342DD
000007FE99F34293 movsxd r10,eax
000007FE99F34296 mov r9d,dword ptr [r9+r10*4+10h]
000007FE99F3429B movsxd r9,r9d
000007FE99F3429E add rcx,r9
您可以看到 JIT 已经优化了代码:它使用 RCX
寄存器 heapTotal
。
寄存器比内存访问快,因此速度提高。