编译 32 位和 64 位时的巨大性能差异(快 26 倍)
Huge performance difference (26x faster) when compiling for 32 and 64 bits
我试图衡量在访问值类型和引用类型列表时使用 for
和 foreach
的区别。
我使用以下 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();
}
我选择了 Release
和 Any 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 指令相当 除了除法 :
- The 32 bit x87 float instructions use 10 byte precision internally.
- 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
。 因此整个基准测试中的每个操作都用 NaN
s 完成。
同一样,32位版本使用x87浮点数,64位版本使用SSE浮点数
我不是 NaN
s 的性能专家,也不是 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 开始循环会有所不同。
我试图衡量在访问值类型和引用类型列表时使用 for
和 foreach
的区别。
我使用以下 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();
}
我选择了 Release
和 Any 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 指令相当 除了除法 :
- The 32 bit x87 float instructions use 10 byte precision internally.
- 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
。 因此整个基准测试中的每个操作都用 NaN
s 完成。
同
我不是 NaN
s 的性能专家,也不是 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 开始循环会有所不同。