32位和64位编译时性能差异巨大(快了26倍)

80

我试图测量使用 forforeach 访问值类型和引用类型列表时的差异。

我使用以下类进行分析。

public static class Benchmarker
{
    public static void Profile(string description, int iterations, Action func)
    {
        Console.Write(description);

        // Warm up
        func();

        Stopwatch watch = new Stopwatch();

        // Clean up
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        watch.Start();
        for (int i = 0; i < iterations; i++)
        {
            func();
        }
        watch.Stop();

        Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
    }
}

我在我的值类型中使用了double。我创建了这个“假类”来测试引用类型:

class DoubleWrapper
{
    public double Value { get; set; }

    public DoubleWrapper(double value)
    {
        Value = value;
    }
}

最后我运行了这段代码并比较了时间差异。

static void Main(string[] args)
{
    int size = 1000000;
    int iterationCount = 100;

    var valueList = new List<double>(size);
    for (int i = 0; i < size; i++) 
        valueList.Add(i);

    var refList = new List<DoubleWrapper>(size);
    for (int i = 0; i < size; i++) 
        refList.Add(new DoubleWrapper(i));

    double dummy;

    Benchmarker.Profile("valueList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < valueList.Count; i++)
        {
             unchecked
             {
                 var temp = valueList[i];
                 result *= temp;
                 result += temp;
                 result /= temp;
                 result -= temp;
             }
        }
        dummy = result;
    });

    Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in valueList)
        {
            var temp = v;
            result *= temp;
            result += temp;
            result /= temp;
            result -= temp;
        }
        dummy = result;
    });

    Benchmarker.Profile("refList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < refList.Count; i++)
        {
            unchecked
            {
                var temp = refList[i].Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }
        dummy = result;
    });

    Benchmarker.Profile("refList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in refList)
        {
            unchecked
            {
                var temp = v.Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }

        dummy = result;
    });

    SafeExit();
}

我选择了 ReleaseAny CPU 选项,运行程序后得到了以下时间:

valueList for:  average time: 483,967938 ms
valueList foreach:  average time: 477,873079 ms
refList for:  average time: 490,524197 ms
refList foreach:  average time: 485,659557 ms
Done!

然后我选择了Release和x64选项,运行程序并得到了以下结果:

valueList for:  average time: 16,720209 ms
valueList foreach:  average time: 15,953483 ms
refList for:  average time: 19,381077 ms
refList foreach:  average time: 18,636781 ms
Done!

x64位版本为什么会快这么多?我本来期望有一些差异,但没想到会这么大。

我没有其他电脑可以使用。你能否在你的电脑上运行并告诉我结果?我正在使用Visual Studio 2015,并且我的处理器是英特尔Core i7 930。

这里是SafeExit()方法,这样你就可以自己编译和运行:

private static void SafeExit()
{
    Console.WriteLine("Done!");
    Console.ReadLine();
    System.Environment.Exit(1);
}

按要求,使用 double? 替代我的 DoubleWrapper:

任意 CPU

valueList for:  average time: 482,98116 ms
valueList foreach:  average time: 478,837701 ms
refList for:  average time: 491,075915 ms
refList foreach:  average time: 483,206072 ms
Done!

x64

valueList for:  average time: 16,393947 ms
valueList foreach:  average time: 15,87007 ms
refList for:  average time: 18,267736 ms
refList foreach:  average time: 16,496038 ms
Done!

最后但同样重要的是:创建一个x86配置文件,可以让我获得几乎与使用Any CPU相同的结果。


14
“Any CPU”不等于“32位”!如果选择“Any CPU”编译,你的应用程序应该在64位系统上以64位进程运行。此外,我会删除与GC相关的代码,因为它实际上没有帮助。 - Thorsten Dittmar
9
@ThorstenDittmar 这些 GC 调用是在测量之前发生的,而不是在被测代码中。这是一个合理的做法,可以减少 GC 时间对测量结果的影响程度。此外,在构建过程中,还有“支持 32 位”和“支持 64 位”作为一个因素。 - Jon Hanna
2
你使用的是哪个运行时版本?4.6中的新RyuJIT速度非常快,但即使是早期版本,x64编译器和JITer也比x32版本更新和更先进。它们能够执行比x86版本更为激进的优化。 - Panagiotis Kanavos
2
我想指出的是,所涉及的类型似乎没有影响;将“double”更改为“float”、“long”或“int”,您会得到类似的结果。 - Jon Hanna
1
默认情况下,Visual Studio的AnyCPU配置启用了“优先使用32位”选项,这意味着您的进程将在每个平台上以32位运行。如果您想要看到您的进程在AnyCPU配置下以64位运行,可以禁用“优先使用32位”选项。 - Erti-Chris Eelmaa
显示剩余7条评论
4个回答

87

我可以在4.5.2上重现这个问题。没有RyuJIT。x86和x64的反汇编看起来都很合理。范围检查等都是一样的。同样的基本结构。没有循环展开。

x86使用一组不同的浮点指令。这些指令的性能似乎与x64指令相当,除了除法

  1. 32位x87浮点指令在内部使用10个字节的精度。
  2. 扩展精度除法非常慢。

除法操作使32位版本变得极其缓慢。取消注释等价于分配更多时间(32位从430ms降至3.25ms)。

Peter Cordes指出两个浮点单元的指令延迟并不那么不同。也许一些中间结果是非规格化数或NaN。这可能会触发其中一个单元的慢路径。或者,也许由于10字节与8字节浮点精度之间的差异,两种实现之间的值会发生分歧。

Peter Cordes 还指出所有中间结果都是NaN...修复这个问题(valueList.Add(i + 1),使除数不为零)基本上平衡了结果。显然,32位代码根本不喜欢NaN操作数。让我们打印一些中间值:if (i % 1000 == 0) Console.WriteLine(result);。这证实数据现在已经正确。

进行基准测试时,需要对一个实际的工作负载进行基准测试。但谁会想到一个无害的除法会搞乱你的基准测试?!

尝试简单地将数字相加以获得更好的基准测试。

除法和取模运算总是非常缓慢的。如果您修改BCL Dictionary 代码,以简单地不使用取模运算符来计算桶索引,则性能将显著提高。这就是除法有多慢。

下面是32位代码:

输入图像描述

64位代码(相同结构,快速除法):

输入图像描述

尽管使用了SSE指令,但这并不是矢量化的。


11
谁会想到一个无辜的除法操作会破坏你的基准测试结果呢?我一看到内部循环中出现了除法运算,尤其是作为依赖链的一部分,就立刻想到了这点。只有当除数为2的整数次幂时,除法才是无害的。根据http://agner.org/optimize/指令表,Nehalem fdiv 的延迟为7-27个周期(倒数吞吐量相同),divsd 的延迟为7-22个周期,addsd 的延迟为3个周期,吞吐量为1个数据元素每周期。在Intel/AMD CPU中,除法是唯一的非流水线执行单元。对于使用divPd的x86-64编译器,C# JIT没有将循环向量化。 - Peter Cordes
1
此外,32位C#不使用SSE数学运算是正常的吗?能够使用当前机器的功能是JIT的重点之一,难道不是吗?因此,在Haswell及更高版本上,它可以自动将整数循环向量化为256b AVX2,而不仅仅是SSE。要想获得FP循环的向量化,我想你必须使用类似于4个累加器并行计算的方法来编写它们,因为FP数学不是可结合的。但无论如何,在32位模式下使用SSE更快,因为当您不必操纵x87 FP堆栈时,您需要更少的指令来执行相同的标量工作。 - Peter Cordes
4
无论如何,div非常慢,但是10B x87 fdiv的速度并不比8B SSE2慢多少,因此这并不能解释x86和x86-64之间的差异。能够解释它的可能是FPU异常或者处理denormals/无穷大时的减速。x87 FPU控制字是独立于SSE舍入/异常控制寄存器(MXCSR)的。不同的denormal或NaN处理方式可以解释26倍性能差异。C# 中可能会将denormals-are-zero设置为MXCSR中的值。 - Peter Cordes
2
@Trauer和usr:我刚刚注意到valueList [i] = i,从i = 0开始,所以第一个循环迭代执行了0.0 / 0.0。 因此,在整个基准测试中进行的每个操作都是使用NaN完成的。这种除法看起来越来越不无辜!我不是NaN性能或x87和SSE之间的差异专家,但我认为这解释了26倍的性能差异。如果您初始化valueList [i] = i + 1,则我敢打赌您的结果在32位和64位之间会更接近。 - Peter Cordes
1
关于flush-to-zero,我对64位双精度不太感冒,但当80位扩展和64位双精度一起使用时,80位值可能下溢然后被放大到足以产生可表示为64位“double”的值的情况相当罕见。 80位类型的主要用法之一是允许多个数字在不必紧密舍入结果的情况下进行求和,直到最后一步才进行舍入。 在这种模式下,溢出根本不是问题。 - supercat
显示剩余14条评论

31

valueList[i] = i,从i=0开始,因此第一次循环迭代执行了0.0 / 0.0因此你整个基准测试中的每个操作都是使用NaN进行的。

@usr在反汇编输出中显示的那样,32位版本使用x87浮点,而64位使用SSE浮点。

我不是关于使用NaN或x87和SSE之间差异的性能专家,但我认为这解释了26倍的性能差异。如果您初始化valueList[i] = i+1,则32位和64位之间的结果将会更接近。(更新:usr确认这使32位和64位性能相当接近。)

Division操作与其他操作相比非常缓慢。请参见我的@usr答案的评论。此外,http://agner.org/optimize/提供了大量关于硬件和优化asm、C/C++的材料,其中一些与C#相关。他有针对大多数最近x86 CPU的指令延迟和吞吐量表格。
然而,对于普通值,10B x87 fdiv并不比SSE2的8B双精度divsd慢多少。我不知道NaN、infinities或denormals的性能差异。
他们有不同的控制方式来处理NaN和其他FPU异常。x87 FPU control word 与SSE舍入/异常控制寄存器(MXCSR)是分开的。如果x87在每个除法中获得CPU异常,而SSE没有,则很容易解释26倍的因素。或者处理NaN时可能有如此大的性能差异。硬件并未针对频繁出现NaN进行优化。我不知道SSE控制避免减速的方式是否会在这里发挥作用,因为我相信result将始终为NaN。我不知道C#是否设置了MXCSR中的denormals-are-zero标志,或者flush-to-zero标志(在读回时将denormals视为零)。
我发现了一篇Intel文章,关于SSE浮点控制,与x87 FPU控制字进行对比。但它并没有多少关于NaN的内容。文章以以下内容结束:
结论 为避免由denormals和underflow numbers引起的串行化和性能问题,在硬件内使用SSE和SSE2指令设置Flush-to-Zero和Denormals-Are-Zero模式,以实现浮点应用的最高性能。
不确定这是否有助于解决除以零的问题。
for vs. foreach
测试受吞吐量限制的循环体可能很有趣,而不仅仅是一个单一的循环依赖链。因为现在所有的工作都依赖于之前的结果; CPU无法并行处理(除了在mul/div链运行时进行下一个数组加载的边界检查)。
如果“真正的工作”占用了更多的CPU执行资源,您可能会看到更多方法之间的差异。此外,在Sandybridge之前的Intel处理器上,循环是否适合于28uop循环缓冲区存在很大差异。如果不适合,则会出现指令解码瓶颈,特别是当平均指令长度较长时(使用SSE时会发生)。解码为多个uop的指令也会限制解码器吞吐量,除非它们以对解码器有利的模式出现(例如2-1-1)。因此,具有更多循环开销指令的循环可能会导致循环适合于28个条目的uop缓存或不适合,这在Nehalem上非常重要,并且有时在Sandybridge及更高版本上也有帮助。

我从未遇到过观察数据流中是否存在NaNs会对性能产生任何影响的情况,但是非规范化数字的存在可能会对性能产生巨大影响。虽然在这个例子中似乎不是这种情况,但这是需要记在心里的事情。 - Jason R
@JasonR:这是因为NaN在实践中确实非常罕见吗?我保留了所有关于非规格化数的内容和英特尔资料的链接,主要是为了读者的利益,而不是因为我认为它对这个特定情况会有太大影响。 - Peter Cordes
在大多数应用程序中,它们很少出现。但是,在开发使用浮点数的新软件时,实现错误导致产生NaN流而不是所需结果并不罕见!这种情况发生过很多次,当NaN出现时,我不记得有任何明显的性能损失。如果我做一些导致denormal出现的事情,我观察到相反的情况;通常会立即导致性能显著下降。请注意,这些仅基于我的个人经验;可能会有一些NaN的性能下降,我只是没有注意到。 - Jason R
@JasonR:我不知道,也许使用SSE时NaN的速度并没有变慢。显然,对于x87来说,它们是一个大问题。SSE浮点语义是由英特尔在PII/PIII时代设计的。这些CPU在内部具有与当前设计相同的乱序机制,因此可以推断,在设计SSE时,他们考虑了P6的高性能。(是的,Skylake基于P6微架构。一些东西已经改变,但它仍然解码为uops,并使用重排序缓冲区将它们调度到执行端口。) x87语义是为一个可选的外部协处理器芯片设计的,用于一个按顺序标量CPU。 - Peter Cordes
@PeterCordes 称Skylake芯片为基于P6的芯片有些牵强。1)在Sandy Bridge时代,FPU(浮点运算单元)被(几乎)完全重新设计,因此旧的P6 FPU在今天基本上已经消失了;2)在Core2时代,x86到uop解码进行了关键修改:虽然以前的设计将计算和内存指令解码为单独的uops,但Core2+芯片具有由计算指令内存操作符组成的uops。这导致了大大提高的性能和功率效率,代价是更复杂的设计和潜在的较低峰值频率。 - shodanshok
显示剩余5条评论

1
我们观察到99.9%的浮点运算涉及NaN,这至少是非常不寻常的(由Peter Cordes首先发现)。我们有另一个实验由usr完成,发现删除除法指令几乎完全消除了时间差异。
然而,事实上,NaN仅在第一个除法计算0.0 / 0.0时生成,从而产生初始NaN。如果不执行除法,则结果将始终为0.0,并且我们将始终计算0.0 * temp -> 0.0,0.0 + temp -> temp,temp-temp = 0.0。因此,删除除法不仅删除了除法,还删除了NaN。我认为NaN实际上是问题所在,其中一种实现处理NaN非常缓慢,而另一种则没有问题。
将循环从i = 1开始并再次测量是值得的。四个操作result * temp,+ temp,/ temp,- temp有效地添加(1-temp),因此对于大多数操作,我们不会有任何异常数字(0、无穷大、NaN)。
唯一的问题可能是,除法总是给出整数结果,并且某些除法实现在正确结果不使用许多位时具有快捷方式。例如,将310.0 / 31.0相除得到10.0作为前四位并余数为0.0,一些实现可以停止计算其余约50个位,而其他实现则不能。如果存在显着差异,则从result = 1.0 / 3.0开始循环会有所不同。

-2

在您的计算机上,64位执行速度更快可能有几个原因。我询问您使用的CPU是因为当64位CPU首次出现时,AMD和Intel有不同的处理64位代码的机制。

处理器架构:

Intel的CPU架构完全是64位的。为了执行32位代码,需要将32位指令转换(在CPU内部)为64位指令才能执行。

AMD的CPU架构是在其32位架构之上构建64位;也就是说,它本质上是一个带有64位扩展的32位架构 - 没有代码转换过程。

这显然是几年前的事情了,所以我不知道技术是否已经改变,但基本上,您可以期望64位代码在64位机器上表现更好,因为CPU能够每个指令处理双倍的位数。

.NET JIT

有人认为.NET(以及其他像Java这样的托管语言)能够胜过C++等语言,因为JIT编译器可以根据处理器架构优化您的代码。在这方面,您可能会发现JIT编译器正在利用64位架构中的某些东西,而在32位执行时可能不可用或需要解决问题。
注意:
与其使用DoubleWrapper,您是否考虑使用Nullable或简写语法:double?-我很想看看它对您的测试是否有任何影响。
注意2: 有些人似乎将我的关于64位架构的评论与IA-64混淆了。澄清一下,在我的答案中,64位是指x86-64,32位是指x86-32。这里没有提到IA-64!

4
好的,为什么它要快26倍?答案中没有找到这个。 - usr
2
我猜测这可能是抖动差异,但仅仅是猜测。 - Jon Hanna
2
@seriesOne:我认为MSalters想说的是你把IA-64和x86-64混淆了。(英特尔在他们的手册中也使用IA-32e来表示x86-64)。每个人的台式电脑CPU都是x86-64。Itanic几年前就已经沉没了,我认为它主要用于服务器而不是工作站。Core2(第一个支持x86-64长模式的P6系列CPU)在64位模式下实际上有一些限制。例如,uop宏融合只在32位模式下起作用。英特尔和AMD做了同样的事情:将他们的32位设计扩展到64位。 - Peter Cordes
1
@PeterCordes,我在哪里提到了IA-64?我知道Itanium CPU是完全不同的设计和指令集;早期型号被标记为EPIC或显式并行指令计算。我认为MSalters混淆了64位和IA-64。我的答案适用于x86-64架构——其中没有任何涉及Itanium CPU系列的内容。 - Matthew Layton
2
@series0ne: 好的,所以你关于英特尔CPU是“纯64位”的段落完全就是胡说八道。我想你可能在想IA-64,因为那样你就不会完全错了。运行32位代码从来没有额外的翻译步骤。x86->uop解码器只有两个相似的模式:x86和x86-64。英特尔在P4的基础上构建了64位P4。64位Core2与Core和Pentium M相比具有许多其他的架构改进,但是像宏融合只在32位模式下工作这样的事情表明64位被添加了进去(虽然在设计过程中相当早)。 - Peter Cordes
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接