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。
与大多数整数运算不同,现代 x86 CPU 上的除法吞吐量(和延迟)取决于操作数大小:64 位除法比 32 位除法慢。(https://agner.org/optimize/ 用于表哪些端口的指令吞吐量/延迟/微指令)。
与乘法或特别是加法等其他操作相比,它非常慢:您的程序完全是整数除法吞吐量的瓶颈,而不是map 操作。 (在 Skylake 上使用 32 位二进制的性能计数器,arith.divider_active 计数 24.03 十亿个除法执行单元处于活动状态的周期,总共有 24.84 十亿个核心时钟周期。是的,没错,除法太慢了有一个专门用于该执行单元的性能计数器。这也是一种特殊情况,因为它没有完全流水线化,所以即使在这种情况下,你有独立的划分,它也不能像其他时钟周期一样在每个时钟周期启动一个新的FP 或整数乘法等多周期操作。)
g++ 不幸的是,由于数字是编译时常量,因此范围有限,因此未能优化。 g++ -m64 优化到 div ecx 而不是 div rcx 是合法的(并且是一个巨大的加速)。这种变化使 64 位二进制文件的运行速度与 32 位二进制文件一样快。 (它计算完全相同的东西,只是没有那么多高零位。结果是隐式零扩展以填充 64 位寄存器,而不是由除法器显式计算为零,在这种情况下要快得多。)
我在 Skylake 上验证了这一点,方法是编辑二进制文件以将 0x40 替换为 0x48 REX.W 前缀,将 div rcx 更改为 div ecx 并使用无操作的 REX 前缀。从g++ -O3 -m32 -march=native 开始,所花费的总周期在 32 位二进制文件的 1% 以内。 (还有时间,因为 CPU 恰好在两次运行时都以相同的时钟速度运行。)(g++7.3 asm output on the Godbolt compiler explorer.)
运行 Linux 的 3.9GHz Skylake i7-6700k 上的 32 位代码,gcc7.3 -O3
$ 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 比 div r32 的微指令多得多,因此可能会损害乱序执行并减少周围代码的重叠。但我们知道,没有其他指令的背靠背div 具有类似的性能差异。
无论如何,这段代码大部分时间都在那个可怕的试除循环中(它检查每个奇数和偶数除数,即使我们在检查低位后已经可以排除所有偶数除数...... 它一直检查到num 而不是sqrt(num),所以对于非常大的素数来说它非常慢。)
根据perf record,99.98% 的 cpu 周期事件在 2nd 试分循环中触发,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 编译器通常只使用 div 和 rdx=0。)
循环计数的比率 (78733701858 / 24938804081 = ~3.15) 实际上小于最佳情况吞吐量的比率 (21/6 = 3.5)。它应该是一个纯粹的吞吐量瓶颈,而不是延迟,因为下一个循环迭代可以开始而无需等待最后一个除法结果。 (感谢分支预测 + 推测执行。)也许在那个除法循环中有一些分支未命中。
如果您只发现 2 倍的性能比,那么您有不同的 CPU。可能是 Haswell,其中 32 位 div 吞吐量为 9-11 个周期,而 64 位 div 吞吐量为 21-74。
可能不是 AMD:即使对于 div r64,最佳情况下的吞吐量仍然很小。例如Steamroller 有 div r32 吞吐量 = 1 每 13-39 个周期,div r64 = 13-70。我猜想使用相同的实际数字,即使您将它们提供给更宽寄存器中的除法器,您也可能会获得相同的性能,这与英特尔不同。 (最坏的情况会上升,因为输入和结果的可能大小会更大。)AMD 整数除法只有 2 微秒,不像英特尔在 Skylake 上微编码为 10 或 36 微秒。 (在 57 微秒时签名 idiv r64 甚至更多。)这可能与 AMD 对宽寄存器中的小数字有效。
顺便说一句,FP 除法始终是单微指令,因为它在普通代码中对性能更为关键。 (提示:如果他们关心性能根本,没有人在现实生活中使用完全幼稚的试验除法来检查多个素数。筛子或其他东西。)
有序map 的键是size_t,并且在 64 位代码中指针更大,使得每个红黑树节点显着变大,但这不是瓶颈。
顺便说一句,map<> 与两个bool prime_low[Count], prime_high[Count] 数组相比,这里的选择糟糕:一个用于低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 版本,根据输入的大小进行选择。