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

在访问值类型和引用类型列表时,我试图测量使用 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!

然后我选择 Releaseandx64选项,运行程序,得到以下时间:

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位版本的速度这么快? 我期待一些不同,但没有这么大的东西。

我没有其他电脑的权限。你能在你的机器上运行这个并告诉我结果吗?我使用的是 VisualStudio2015,我有一个英特尔 核心 I7930。

这是 SafeExit()方法,所以你可以自己编译/运行:

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

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

任何中央处理器

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 几乎相同。

7329 次浏览

可能有几个原因可以解释为什么在您的计算机上以64位执行速度更快。我之所以问你使用哪个 CPU 是因为当64位 CPU 第一次出现的时候,AMD 和 Intel 有不同的机制来处理64位代码。

处理器架构:

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

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

这显然是几年前的事了,所以我不知道这项技术是否/如何发生了变化,但本质上,你可以期望64位代码在64位机器上表现得更好,因为 CPU 能够处理两倍于每条指令的比特数。

. NET JIT

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

注:

比起使用 DoubleWrapper,你有没有考虑过使用 Nullable<double>或者简写语法: double?-我很想知道这对你的测试有没有什么影响。

注二: 有些人似乎把我对64位架构的看法和 IA-64混为一谈。澄清一下,在我的回答中,64位指的是 x86-64,32位指的是 x86-32。这里没有提到 IA-64!

我可以在4.5。这里没有 RyuJIT。X86和 x64的拆卸看起来都很合理。范围检查等等都是一样的。相同的基本结构。没有循环展开。

X86使用一组不同的 float 指令。这些指令的性能似乎与 x64指令 除了部门相当:

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

除法操作使32位版本极其缓慢。不注释除法等于性能到一个大的程度(32位从430毫秒下降到3.25毫秒)。

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

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

当进行基准测试时,您需要对一个现实的工作负载进行基准测试。但谁会想到一个无辜的部门会破坏你的基准呢!

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

除法和模总是很慢。如果您修改 BCL Dictionary代码,使其不使用模运算符来计算 bucket 索引性能,那么可测量的性能提高了。组织就是这么慢。

这是32位代码:

enter image description here

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

enter image description here

这是 没有矢量化,尽管 SSE 指令正在使用。

0,从 i=0开始,因此第一个循环迭代执行 0.0 / 0.0.因此,整个基准测试中的每个操作都是使用 NaN完成的。

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

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

与其他行动相比,“组织”行动非常缓慢。请看我对@usr 的回答的评论。还可以看到 http://agner.org/optimize/中大量关于硬件、优化 asm 和 C/C + + 的内容,其中一些内容与 C # 相关。他为最近的所有 x86 CPU 提供了大多数指令的延迟和吞吐量指令表。

然而,对于正常值来说,10B x87fdiv并不比 SSE2的8B 双精度 divsd慢多少。关于与 NaNs、无穷大或反规范的性能差异的 IDK。

但是,对于 NaNs 和其他 FPU 异常,它们有不同的控制。X87 FPU 控制字独立于 SSE 舍入/异常控制寄存器(MXCSR)。如果 x87在每个除中都获得了 CPU 异常,但 SSE 没有,那么很容易解释为什么是26。或者,在处理 NaNs 时,性能差异可能就这么大。硬件是 没有优化通过 NaN搅拌后 NaN

IDK 如果 SSE 控制避免减速与反常将在这里发挥作用,因为我相信 result将是 NaN所有的时间。如果 C # 在 MXCSR 中设置了反规范为零的标志,或者刷新为零的标志(它首先写入零,而不是在读回时将反规范视为零) ,则为 IDK。

我发现了一个关于 SSE 浮点控制的 英特尔的文章,并将其与 x87FPU 控制字进行了对比。不过,它没有太多关于 NaN的内容。它的结尾是这样的:

结论

以避免由于异常和 底流数字,使用 SSE 和 SSE2指令设置 硬件内的刷新至零和 Denormals-Are-Zero 模式 为浮点应用程序启用最高性能。

IDK,如果这有助于任何除以零。

对 Foreach

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

如果“实际工作”占用了更多的 CPU 执行资源,您可能会看到这些方法之间的更多差异。此外,在桑迪布里奇之前的英特尔,在28uop 循环缓冲区中的循环配件与否之间有很大的区别。如果没有,就会出现指令解码瓶颈。当平均指令长度较长时(在 SSE 中会发生这种情况)。对多个 uop 进行解码的指令也会限制解码器的吞吐量,除非它们的模式适合解码器(例如2-1-1)。因此,一个循环有更多的循环开销指令,可以决定一个循环是否适合28进入 uop 缓存,这是一个大问题,在尼黑勒姆,有时有助于 Sandybridge 和以后。

我们观察到,99.9% 的浮点操作将涉及到 NaN,这至少是非常不寻常的(由 Peter Cordes 首先发现)。我们还有另一个 USR 的实验,发现去掉除法指令,时差几乎完全消失了。

然而事实上,只有当第一个除法计算0.0.0.0时,才会生成 NaN,从而得到初始 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、无穷大、 NaN)。

唯一的问题可能是除法总是给出一个整数结果,并且当正确的结果不使用很多位时,一些除法实现有快捷方式。例如,将310.0/31.0除以10.0作为前四位,其余为0.0,一些实现可以停止计算剩余的50位左右,而其他实现则不能。如果存在显著差异,则以 result = 1.0/3.0启动循环将产生差异。