试用版代码在 Windows 上的 32 位运行速度比在 Linux 上的 64 位快 2 倍

Trial-division code runs 2x faster as 32-bit on Windows than 64-bit on Linux

本文关键字:上的 Linux 位快 运行 Windows 代码 试用版 速度      更新时间:2023-10-16

我有一段代码在Windows上的运行速度比在Linux上快2倍。 以下是我测量的时间:

g++ -Ofast -march=native -m64
29.1123
g++ -Ofast -march=native
29.0497
clang++ -Ofast -march=native
28.9192
visual studio 2013 Debug 32b
13.8802
visual studio 2013 Release 32b
12.5569

这似乎真的太大了。

这是代码:

#include <iostream>
#include <map>
#include <chrono>
static std::size_t Count = 1000;
static std::size_t MaxNum = 50000000;
bool IsPrime(std::size_t num)
{
for (std::size_t i = 2; i < num; i++)
{
if (num % i == 0)
return false;
}
return true;
}
int main()
{
auto start = std::chrono::steady_clock::now();
std::map<std::size_t, bool> value;
for (std::size_t i = 0; i < Count; i++)
{
value[i] = IsPrime(i);
value[MaxNum - i] = IsPrime(MaxNum - i);
}
std::chrono::duration<double> serialTime = std::chrono::steady_clock::now() - start;
std::cout << "Serial time = " << serialTime.count() << std::endl;
system("pause");
return 0;
}

所有这些都是在同一台机器上测量的,Windows 8 vs Linux 3.19.5(gcc 4.9.2,clang 3.5.0)。Linux和Windows都是64位的。

这可能是什么原因呢?一些调度程序问题?

你没有说Windows/Linux操作系统是32位还是64位。

在 64 位 Linux 机器上,如果将size_t更改为 int,您会发现 Linux 上的执行时间下降到与 Windows 类似的值。

size_t是Win32上的int32,win64上的int64。

编辑:刚刚看到你的窗户被拆解了。

您的 Windows 操作系统是 32 位变体(或者至少您已为 32 位编译)。

size_t是 Linux 上的 x86-64 System V ABI 中的 64 位无符号类型,您正在编译 64 位二进制文件。 但是在 32 位二进制文件中(就像你在 Windows 上制作的那样),它只有 32 位,因此试除循环只执行 32 位除法。 (size_t适用于C++对象的大小,而不是文件的大小,因此它只需要是指针宽度。

在x86-64 Linux上,-m64是默认的,因为32位基本上被认为是过时的。 要制作 32 位可执行文件,请使用g++ -m32


与大多数整数运算不同,Ice Lake 之前的英特尔 CPU 上的除法吞吐量(和延迟)取决于操作数大小:64 位除法比 32 位除法慢。(https://agner.org/optimize/和 https://uops.info/用于端口的指令吞吐量/延迟/UOPS表)。 相关:代码审查问答:检查一个数字在NASM Win64程序集中是否是素数。 AMD没有这个问题(或者64位div/idiv不合理地慢),Intel Ice Lake使64位div/idiv的uop数量与32位相似。

与其他操作(如乘法或加法)相比,它非常慢:您的程序完全瓶颈是整数除法吞吐量的瓶颈,而不是map运算的瓶颈。 (使用 Skylake 上 32 位二进制文件的性能计数器,arith.divider_active计算除法执行单元处于活动状态的24.03亿个周期,总共24.84亿个内核时钟周期。 是的,没错,除法非常慢,以至于只有一个性能计数器专门针对该执行单元。 这也是一种特殊情况,因为它没有完全流水线化,所以即使在这样有独立除法的情况下,它也不能像其他多周期运算(如 FP 或整数乘法)那样在每个时钟周期启动一个新的除法。

不幸的是,G++ 无法基于数字是编译时常量这一事实进行优化,因此范围有限。 对于g++ -m64来说,优化为div ecx而不是div rcx将是合法的(和巨大的加速)。 此更改使 64 位二进制文件的运行速度与 32 位二进制文件一样快。 (它的计算完全相同,只是没有那么多高零位。 结果是隐式零扩展以填充 64 位寄存器,而不是由分频器显式计算为零,在这种情况下要快得多。

我在Skylake上通过编辑二进制文件来替换0x48REX来验证这一点。带有0x40的 W前缀,将div rcx更改为带有无操作 REX 前缀的div ecx。 总周期在g++ -O3 -m32 -march=native的 32 位二进制的 1% 以内。 (还有时间,因为两次运行 CPU 碰巧以相同的时钟速度运行。 (Godbolt 编译器资源管理器上的 g++7.3 asm 输出。

32 位代码,gcc7.3 -O3 在运行 Linux 的 3.9GHz Skylake i7-6700k 上

$ cat > primes.cpp     # and paste your code, then edit to remove the silly system("pause")
$ g++ -Ofast -march=native -m32 primes.cpp -o prime32
$ taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,instructions,uops_issued.any,uops_executed.thread,arith.divider_active  ./prime32 
Serial time = 6.37695

Performance counter stats for './prime32':
6377.915381      task-clock (msec)         #    1.000 CPUs utilized          
66      context-switches          #    0.010 K/sec                  
0      cpu-migrations            #    0.000 K/sec                  
111      page-faults               #    0.017 K/sec                  
24,843,147,246      cycles                    #    3.895 GHz                    
6,209,323,281      branches                  #  973.566 M/sec                  
24,846,631,255      instructions              #    1.00  insn per cycle         
49,663,976,413      uops_issued.any           # 7786.867 M/sec                  
40,368,420,246      uops_executed.thread      # 6329.407 M/sec                  
24,026,890,696      arith.divider_active      # 3767.201 M/sec                  

6.378365398 seconds time elapsed

64 位相比,使用 REX 时。W=0(手动编辑二进制)

Performance counter stats for './prime64.div32':
6399.385863      task-clock (msec)         #    1.000 CPUs utilized          
69      context-switches          #    0.011 K/sec                  
0      cpu-migrations            #    0.000 K/sec                  
146      page-faults               #    0.023 K/sec                  
24,938,804,081      cycles                    #    3.897 GHz                    
6,209,114,782      branches                  #  970.267 M/sec                  
24,845,723,992      instructions              #    1.00  insn per cycle         
49,662,777,865      uops_issued.any           # 7760.554 M/sec                  
40,366,734,518      uops_executed.thread      # 6307.908 M/sec                  
24,045,288,378      arith.divider_active      # 3757.437 M/sec                  
6.399836443 seconds time elapsed

原始 64 位二进制文件相比:

$ g++ -Ofast -march=native primes.cpp -o prime64
$ taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,instructions,uops_issued.any,uops_executed.thread,arith.divider_active  ./prime64
Serial time = 20.1916
Performance counter stats for './prime64':
20193.891072      task-clock (msec)         #    1.000 CPUs utilized          
48      context-switches          #    0.002 K/sec                  
0      cpu-migrations            #    0.000 K/sec                  
148      page-faults               #    0.007 K/sec                  
78,733,701,858      cycles                    #    3.899 GHz                    
6,225,969,960      branches                  #  308.310 M/sec                  
24,930,415,081      instructions              #    0.32  insn per cycle         
127,285,602,089      uops_issued.any           # 6303.174 M/sec                  
111,797,662,287      uops_executed.thread      # 5536.212 M/sec                  
27,904,367,637      arith.divider_active      # 1381.822 M/sec                  
20.193208642 seconds time elapsed

IDK 为什么arith.divider_active的性能计数器没有上升更多。div 64的 uops 明显多于div r32,因此可能会损害乱序执行并减少周围代码的重叠。 但我们知道,没有其他指令的背靠背div具有类似的性能差异。

无论如何,这段代码大部分时间都花在那个可怕的试验除法循环中(它检查每个奇数和偶数除数,即使我们已经可以在检查低位后排除所有偶数除数......并且一直检查到num而不是sqrt(num),所以对于非常大的素数来说,它非常慢

根据perf record,99.98%的CPU周期事件在第二个试验分区循环中触发,即MaxNum - i的循环,所以div仍然是整个瓶颈,这只是性能计数器的一个怪癖,并不是所有时间都被记录为arith.divider_active

3.92 │1e8:   mov    rax,rbp
0.02 │       xor    edx,edx
95.99 │       div    rcx
0.05 │       test   rdx,rdx 
│     ↓ je     238     
... loop counter logic to increment rcx

来自Agner Fog的Skylake说明表:

uops    uops      ports          latency     recip tput
fused   unfused
DIV r32     10     10       p0 p1 p5 p6     26           6
DIV r64     36     36       p0 p1 p5 p6     35-88        21-83

(div r64本身实际上取决于其输入的实际大小的数据,较小的输入速度更快。真正缓慢的情况是商数非常大的,IIRC。 当 RDX:RAX 中 128 位股息的上半部分不为零时,也可能更慢。 C 编译器通常只将divrdx=0一起使用。

循环计数的比率(78733701858 / 24938804081 = ~3.15)实际上小于最佳情况下吞吐量的比率(21/6 = 3.5)。 它应该是纯粹的吞吐量瓶颈,而不是延迟,因为下一个循环迭代可以在不等待最后一个除法结果的情况下开始。 (感谢分支预测+推测执行。 也许在那个划分循环中有一些分支失误。

如果您只找到 2 倍的性能比,那么您拥有不同的 CPU。 可能是 Haswell,其中 32 位div吞吐量为 9-11 个周期,64 位div吞吐量为 21-74 个周期。

可能不是AMD:即使对于div r64,最好的吞吐量仍然很小。 例如,蒸汽压路机的吞吐量 = 1div r32每 13-39 个循环,div r64= 13-70。 我猜想,使用相同的实际数字,即使将它们提供给更宽寄存器的分频器,您也可能获得相同的性能,这与英特尔不同。 (最坏的情况会增加,因为输入和结果的可能大小更大。 AMD整数除法只有2 uops,不像英特尔的在Skylake上被微编码为10或36 uops。 (对于 57 uops 的签名idiv r64甚至更多。 这可能与AMD在宽寄存器中对少量有效有关。

顺便说一句,FP 除法始终是单 uop,因为它在普通代码中对性能更关键。 (提示:在现实生活中,没有人使用完全幼稚的试验除法来检查多个素数,如果他们关心性能的话。 筛子什么的。


排序map的键是size_t,指针在 64 位代码中更大,使每个红黑树节点明显更大,但这不是瓶颈

顺便说一句,与两个bool prime_low[Count], prime_high[Count]数组相比,map<>在这里是一个糟糕的选择:一个用于低Count元素,一个用于高Count。 你有 2 个连续的范围,键可以按位置隐含。 或者至少使用std::unordered_map哈希表。 我觉得有序版本应该被称为ordered_map,并且map = unordered_map,因为您经常看到代码使用map而不利用排序。

您甚至可以使用std::vector<bool>来获取位图,使用 1/8 的缓存占用空间。

有一个"x32"ABI(长模式下的 32 位指针),对于不需要超过 4G 虚拟地址空间的进程,它具有两全其美的优势:小指针用于更高的数据密度/指针密集型数据结构中的缓存占用空间更小,但现代调用约定的优势、更多寄存器、基线 SSE2、 和 64 位整数寄存器,用于当您确实需要 64 位数学运算时。 但不幸的是,它不是很受欢迎。 它只是快了一点,所以大多数人不想要每个库的第三个版本。

在这种情况下,您可以修复源以使用unsigned int(如果要移植到int仅为 16 位的系统,则可以uint32_t)。 或者uint_least32_t以避免需要固定宽度的类型。 您只能对要IsPrime的 arg 或数据结构执行此操作。 (但是,如果要进行优化,则键是按数组中的位置隐式的,而不是显式的。

您甚至可以制作具有 64 位循环和 32 位循环的IsPrime版本,后者根据输入的大小进行选择。

从编辑的问题中提取答案:

这是由于在Windows上构建32b二进制文件而不是Linux上的64b二进制文件引起的,以下是Windows的64b数字:

Visual studio 2013 Debug 64b
29.1985
Visual studio 2013 Release 64b
29.7469