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

Huge performance difference (26x faster) when compiling for 32 and 64 bits

我试图衡量在访问值类型和引用类型列表时使用 forforeach 的区别。

我使用以下 class 进行分析。

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 作为我的值类型。 我创建了这个 'fake class' 来测试引用类型:

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()方法,大家可以compile/run自己动手:

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.

的结果几乎相同

在您的机器上 64 位执行速度更快的原因可能有多种。我之所以问您使用的 CPU 是因为当 64 位 CPU 首次出现时,AMD 和英特尔有不同的机制来处理 64 位代码。

处理器架构:

英特尔的 CPU 架构是纯 64 位的。为了执行 32 位代码,需要在执行前将 32 位指令(在 CPU 内)转换为 64 位指令。

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

这显然是几年前的事了,所以我不知道 if/how 技术发生了变化,但本质上,您会期望 64 位代码在 CPU 能够处理每条指令的双倍位数。

.NET JIT

有人认为 .NET(以及 Java 等其他托管语言)能够胜过 C++ 等语言,因为 JIT 编译器能够根据处理器架构优化代码。在这方面,您可能会发现 JIT 编译器正在使用 64 位体系结构中的某些东西,这些东西在 32 位中执行时可能不可用或需要解决方法。

注:

您是否考虑过使用 Nullable<double> 或 shorthand 语法而不是使用 DoubleWrapper:double? - 我很想知道这是否对您的测试有任何影响。

注2: 有些人似乎将我关于 64 位架构的评论与 IA-64 混为一谈。澄清一下,在我的回答中,64 位指的是 x86-64,而 32 位指的是 x86-32。此处未提及 IA-64!

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

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

  1. The 32 bit x87 float instructions use 10 byte precision internally.
  2. Extended precision division is super slow.

除法运算使得32位版本非常慢。 取消对除法的注释在很大程度上平衡了性能(32 位从 430 毫秒减少到 3.25 毫秒)。

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

Peter Cordes ... 消除这个问题(valueList.Add(i + 1) 以便没有除数为零)主要使结果相等。显然,32 位代码根本不喜欢 NaN 操作数。让我们打印一些中间值:if (i % 1000 == 0) Console.WriteLine(result);。这证实数据现在是正常的。

进行基准测试时,您需要对实际工作负载进行基准测试。但谁会想到,一个无辜的分裂会把你的基准搞得一团糟?!

尝试简单地对数字求和以获得更好的基准。

除法和取模总是很慢。如果您修改 BCL Dictionary 代码以简单地不使用模运算符来计算存储桶索引,则性能可测量得到提高。这就是除法的慢。

这是 32 位代码:

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

尽管使用了 SSE 指令,但向量化。

valueList[i] = i,从i=0开始,所以第一次循环迭代0.0 / 0.0因此整个基准测试中的每个操作都用 NaNs 完成。

一样,32位版本使用x87浮点数,64位版本使用SSE浮点数

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

与其他操作相比,除法非常慢。请参阅我对@usr 的回答的评论。另请参阅 http://agner.org/optimize/,了解有关硬件的大量精彩内容,以及优化 asm 和 C/C++,其中一些与 C# 相关。他有所有最近 x86 CPUs.

的大多数指令的延迟和吞吐量指令表

但是,对于正常值,10B x87 fdiv 并不比 SSE2 的 8B 双精度 divsd 慢多少。关于 NaN、无穷大或非正规数的性能差异的 IDK。

不过,他们对 NaN 和其他 FPU 异常发生的情况有不同的控制。 x87 FPU control word 与 SSE 舍入/异常控制寄存器 (MXCSR) 是分开的。如果 x87 在每个分区都得到一个 CPU 异常,但 SSE 没有,这很容易解释 26 的因数。或者在处理 NaN 时可能只是性能差异那么大。硬件 针对 NaN 之后的 NaN 进行优化。

IDK 如果 SSE 控制避免非正规化减速将在这里发挥作用,因为我相信 result 将一直是 NaN。 IDK 如果 C# 在 MXCSR 中设置非正规数为零标志,或刷新为零标志(首先写入零,而不是在回读时将非正规数视为零)。

我发现了一篇关于 SSE 浮点控制的 Intel article,将其与 x87 FPU 控制字进行了对比。不过,关于 NaN 没什么可说的。结尾是这样的:

Conclusion

To avoid serialization and performance issues due to denormals and underflow numbers, use the SSE and SSE2 instructions to set Flush-to-Zero and Denormals-Are-Zero modes within the hardware to enable highest performance for floating-point applications.

IDK 如果这对除零有帮助。

for 与 foreach

测试吞吐量受限的循环体可能会很有趣,而不仅仅是一个循环携带的依赖链。事实上,所有的工作都取决于以前的结果; CPU 没有什么可并行执行的(除了在 mul/div 链为 运行 时边界检查下一个数组加载)。

如果 "real work" 占用了更多 CPU 的执行资源,您可能会发现这些方法之间存在更多差异。此外,在 Sandybridge 之前的 Intel 上,循环是否适合 28uop 循环缓冲区之间存在很大差异。如果没有,你会遇到指令解码瓶颈,尤其是。当平均指令长度较长时(SSE 会发生这种情况)。解码为多个 uop 的指令也会限制解码器的吞吐量,除非它们采用适合解码器的模式(例如 2-1-1)。因此,具有更多循环开销指令的循环可以决定循环是否适合 28 条目 uop 缓存,这在 Nehalem 上很重要,有时在 Sandybridge 和更高版本上有帮助。

我们观察到 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 处开始循环并再次测量。这四个操作的结果 * temp, + temp, / temp, - temp 有效地添加 (1 - temp) 因此对于大多数操作我们不会有任何不寻常的数字 (0, infinity, NaN)。

唯一的问题可能是除法总是给出一个整数结果,并且当正确的结果不使用很多位时,一些除法实现有捷径。例如,将 310.0 / 31.0 除以 10.0 作为前四位,余数为 0.0,一些实现可以停止评估剩余的 50 左右位,而其他实现则不能。如果存在显着差异,则以 result = 1.0 / 3.0 开始循环会有所不同。