【问题标题】:Why is this totaling operation faster on the stack than the heap?为什么堆栈上的求和操作比堆上快?
【发布时间】:2016-09-20 22:26:56
【问题描述】:

在以下 C# 程序中,在 Broadwell CPU 和 Windows 8.1 上以 Visual Studio 2015 Update 2 x64 Release 模式编译,运行基准测试的两个变体。他们都做同样的事情——在一个数组中总共有 500 万个整数。

两个基准测试之间的区别在于,一个版本将运行总数(一个长整数)保存在堆栈上,而另一个版本将其保存在堆上。两个版本都没有分配;沿数组扫描时会添加总计。

在测试中,我发现基准变体与堆上的总数和堆栈上的变体之间存在一致的显着性能差异。对于某些测试大小,当总数在堆上时,速度会慢三倍。

为什么总的两个内存位置之间会有这样的性能差异?

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;
            }
        }
    }
}

【问题讨论】:

  • “堆栈”总计器几乎可以肯定使用寄存器。拆解看过了吗?
  • 我只知道答案与缓存命中和未命中有关
  • 局部变量 ldlocstloc 所涉及的 MSIL 操作码比 ldfldstfld 少(用于访问和写入字段)。看看每一个的堆栈会发生什么。

标签: c# performance heap-memory stack-memory


【解决方案1】:

对于某些测试大小,它会慢三倍

这是解决潜在问题的唯一线索。如果您关心 long 变量的性能,请不要使用 x86 抖动。对齐很关键,您无法在 32 位模式下获得足够好的对齐保证。

然后,CLR 只能对齐到 4,这会给出这样的测试 3 个不同的结果。变量可以对齐到 8,快速版本。并且在一个高速缓存行中未对齐到 4,大约慢 2 倍。并且未对齐到 4 并跨越 L1 缓存线边界,大约慢 3 倍。 double 顺便说一句,同样的问题。

使用项目 > 属性 > 构建选项卡 > 取消选中“首选 32 位模式”复选框。以防万一,使用工具 > 选项 > 调试 > 常规 > 取消选中“抑制 JIT 优化”。调整基准代码,在代码周围放置一个 for 循环,我总是至少运行 10 次。选择发布模式配置并再次运行测试。

您现在有一个完全不同的问题,可能更符合您的期望。是的,默认情况下,局部变量不是 volatile,字段是。必须在循环内更新 heapTotal 是您看到的开销。

【讨论】:

  • 两个分开答案合二为一——对我来说都是新的。我没有意识到 32 位 CLR 只有 4 字节对齐,默认情况下字段也是易失的。谢谢! (This article 确认 32 位 CLR 上的 4 字节对齐。)(但我无法为默认情况下易失的字段提供类似的参考 - 你能提供一个吗?)
  • (事实上,this answer of yours 暗示他们不是,你能澄清一下吗?我看错了这个答案吗?)
  • 叹息,易变的苦难。 Joe Duffy 放弃了,不知道我为什么要遮住他的背。然而,这是一个非常简单的案例,该字段在其他地方可见,因此必须更新。
  • 我并不是要给你压力,因为我非常感谢你的写作和知识,但我链接到的答案的最后一段说“x86 jitter optimizer 利用,它将存储支持cpu 寄存器中属性的字段,而不是从内存中更新它。你永远不会观察到更新。需要明确声明它是 volatile 以抑制这种优化“并且你写了它,而不是 Joe Duffy,AFAIK。但是这里的答案很好,我会在别处寻找更多细节。
【解决方案2】:

这是来自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

寄存器比内存访问快,因此速度有所提高。

【讨论】:

    猜你喜欢
    • 2015-10-15
    • 2013-11-08
    • 1970-01-01
    • 2015-09-05
    • 2016-01-29
    • 2013-04-13
    • 2019-12-07
    • 2023-03-23
    • 2012-05-04
    相关资源
    最近更新 更多