SPEC CPU 2026 负载特性分析(INT Rate 篇)¶
本文同步发布到本人的知乎。
背景¶
最近用 SPEC CPU 2026 跑了一些基准测试,打算结合测试结果做一些深入的负载特性分析。本篇主要是分析 SPEC INT 2026 Rate 的负载特性,SPEC FP 2026 Rate 的分析请看 FP Rate 篇。
本文测试环境:CPU 为 Intel i9-14900K P-Core @ 5.7 GHz,Linux 发行版为 Debian Trixie,编译器是 GCC 14.2.0,默认编译选项是 -O3。其实这颗 CPU 最快能 Boost 到 6.0 GHz,但时不时因为未知原因(防缩缸?)在单核负载下也 Boost 不上去,具体表现为每跑一段时间后 CPU 核心就会强制降频到 4.7 GHz。故退而求其次,选择在更容易稳定达到的 5.7 GHz 频率来跑。能稳定跑到 6.0 GHz 的只有那一个物理 P 核,其他 P 核也都能上 5.7 GHz,降频了换一个核心即可。6.0 GHz 下的性能可以参考之前的测试结果:INT 和 FP,基本上,从 5.7 GHz 到 6.0 GHz,性能可以按频率线性放缩。本文可能针对同一个负载给出多个不同的运行时间,这可能是因为多次运行导致的性能波动,也可能是因为部分数字包含了 perf record 的开销,不过误差都很小,可以放心对比。本文所用的脚本已开源到 jiegec/spec2026。
推荐阅读:Evaluating SPEC CPU2026 和 SPEC CPU2026: Characterization, Representativeness, and Cross-Suite Comparison
SPEC INT 2026 Rate 分析¶
706.stockfish_r¶
stockfish 是一个著名的国际象棋引擎,该基准测试包括如下三个负载:
# 1. 1to6_classical
stockfish bench 1600 1 26 spec_ref_pos_1to6.fen depth classical
# 2. 1to6_nnue
stockfish bench 1600 1 26 spec_ref_pos_1to6.fen depth nnue
# 3. 7to11_nnue
stockfish bench 1600 1 26 spec_ref_pos_7to11.fen depth nnue
实测数据显示,三个负载耗费的时间分别是 47s、77s 和 72s,共计 196s。reftime 是 1260s,对应 6.4 分。开启 -march=native 后,1to6_classical 时间缩短 10% 到 43s,而 1to6_nnue 和 7to11_nnue 时间明显缩短到 32s 和 31s,总时间 105s,对应 12 分,分数提升显著。下面逐一分析这三个负载的性能特性。
1. 1to6_classical¶
通过 perf 观察性能瓶颈,以下列出 1to6_classical 的主要热点函数及其时间占比(后续各基准测试均采用相同表示方法):
Stockfish::Eval::evaluate(const Position& pos)来自src/evaluate.cpp: 19.16%,inline 了Evaluation<NO_TRACE>(pos).value()的调用,里面主要是对局面的评估,涉及比较多零散的访存和计算,没有特别集中的热点指令;Stockfish::TranspositionTable::probe(const Key key, bool& found)来自src/tt.cpp: 17.91%,主要的瓶颈来自于随机访存,在first_entry(key)当中有&table[mul_hi64(key, clusterCount)].entry[0]的代码,其中mul_hi64计算两个 64 位整数乘法结果的高 64 位,因此访存地址是根据参数计算得出;对于mul_hi64,GCC 14 会忠实地按照源码把 64 位拆分成高低 32 位分别计算,而 LLVM 22 能够正确识别出这段代码的意图,并直接用 AMD64 的 mul 指令实现,这个功能在 PR #168396 中实现,mul_hi64对应 PR 描述中的 Ladder;事实上,Stockfish 原本的代码里会用 __int128,此时 GCC 14 也能生成高效的代码,只可惜因为用到了 C 语法扩展,被 SPEC 禁用了(汇编对比见 Godbolt);Stockfish::MovePicker::next_move(bool skipQuiets)来自src/movepick.cpp: 10.36%,里面比较慢的是partial_insertion_sort,找到插入位置后,还要把原来数组里靠后的元素往后挪,留出空间用于插入元素;Stockfish::search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, bool cutNode)来自src/search.cpp: 9.49%,搜索逻辑主要在这里实现;__popcountdi2来自 libgcc: 7.52%,被Stockfish::Eval::evaluate(const Position& pos)调用,用来判断局面上满足某种条件,内部实现就是位运算,有兴趣的读者可以阅读 Hacker's Delight 这本书。
开了 -march=native 后,能观察到 __popcountdi2 被内联为 popcnt 指令。经过测试,开 -mpopcnt 后时间即从 47s 降低到 44s,接近 -march=native 的性能。可见仅开启 popcnt 指令集并消除 __popcountdi2 的函数调用开销,就能带来明显的性能提升。
-O3 编译选项下,1to6_classical 执行的指令数为 531.8B(instructions 性能计数器),其中 Load 指令有 135.7B 条(mem_inst_retired.all_loads 性能计数器),Store 有 59.7B 条(mem_inst_retired.all_stores 性能计数器),分支指令有 56.0B 条(branch-instructions 性能计数器),其中有 2622.8M 次错误预测(branch-misses 性能计数器)。可见,1to6_classical 的 MPKI 还是比较高的:2622.8M/531.8B*1000=4.93。即使是在 SPEC INT 2017 当中,这一数值也高于 531.deepsjeng_r 的 3.16 和 557.xz_r 的 3.49,低于 505.mcf_r 的 6.24 和 541.leela_r 的 7.71。
使用 perf record -e branch-misses:pp,观察到主要的分支错误预测来自于 Stockfish::MovePicker::next_move() 函数,贡献了 27.48% 的错误预测,主要是插入排序的部分,一是循环找到插入的位置,二是循环搬运数组内原有元素。其次是 Stockfish::Eval::evaluate() 函数,贡献了 17.42% 的错误预测。再其次是 Stockfish::search() 函数,贡献了 13.06% 的错误预测。
开 -O3 -mpopcnt 后,指令数减少到 453.9B,其中 Load 有 124.2B 条,Store 有 53.1B 条,分支指令有 46.1B 条,错误预测还是 2.6B 次,光是内联 __popcountdi2 的调用,便可减少 77.9B 条指令,约占原来的 15%。__popcountdi2 本身的实现包括 21 条指令,此外还有 __popcountdi2@plt 里的一次 jmp,和 call __popcountdi2@plt 本身和前后保存和恢复寄存器的开销。
2. 1to6_nnue¶
后两个负载的引擎从 classical 变为了 nnue,涉及神经网络,因此它的计算模式会不太一样。通过 perf 观察到 1to6_nnue 的主要耗时函数:
Stockfish::Eval::NNUE:evaluate(const Position& pos, bool adjusted)来自src/nnue/evaluate_nnue.cpp:80.59%,主要耗时在affine_transform_non_ssse3的sum += weights[offset + j] * input[j],即神经网络的推理过程,它的计算过程是,进行 int8_t 乘 uint8_t,再累加到 int32_t 类型的结果,默认编译选项下,只能用基础的 SSE 指令如 pmaddwd/paddd,而不能用 AVX;Stockfish::TranspositionTable::probe(const Key key, bool& found)来自src/tt.cpp: 仅 4.81%,瓶颈和前面分析的一样是随机访存。
分析 Stockfish::Eval::NNUE:evaluate 的指令,可以看到,它为了实现上述逻辑,核心思路是采用 pmaddwd 指令,进行 4 次 16 位有符号的乘法计算,累加到 32 位的结果。但是,在这之前,需要先把输入的 8 位有符号 weights 和无符号 input 转换到 16 位有符号数。其中 8 位有符号 weights 转换比较简单,而 8 位无符号 input 的处理逻辑比较复杂。首先,它对 input 的每个元素加上 128,然后当成有符号数来看待,这相当于对每个元素减去了 128,把 uint8_t 映射到了 int8_t。这样,input 就可以用和 weights 相同的方法进行符号扩展。但是,这样会导致结果计算错误,为了纠正这个偏差,又减去了 128 倍的 weights 之和。汇编代码如下(Godbolt):
1:
# 加载有符号 weights 的 16 个元素
movdqu (%rdx,%rcx,1),%xmm2
movdqa %xmm5,%xmm8
# 加载无符号 input 的 16 个元素
movdqa (%r12,%rcx,1),%xmm10
add $0x10,%rcx
# 对 weights 进行符号扩展
pcmpgtb %xmm2,%xmm8
movdqa %xmm2,%xmm9
# 每个 input 元素加上 128,即减去 128 转为有符号 int8_t
paddb %xmm6, %xmm10
# 符号扩展 weights
punpckhbw %xmm8,%xmm2
punpcklbw %xmm8,%xmm9
movdqa %xmm2,%xmm11
movdqa %xmm9,%xmm8
# 计算 weights 之和乘以 128
pmaddwd %xmm3,%xmm11
pmaddwd %xmm7,%xmm8
paddd %xmm11,%xmm0
paddd %xmm8,%xmm0
paddd %xmm11,%xmm0
movdqa %xmm5,%xmm11
# 对 input 进行符号扩展
pcmpgtb %xmm10,%xmm11
paddd %xmm8,%xmm0
movdqa %xmm10,%xmm8
punpckhbw %xmm11,%xmm10
punpcklbw %xmm11,%xmm8
# 计算 weights * input
pmaddwd %xmm10,%xmm2
pmaddwd %xmm8,%xmm9
# 结果累加
paddd %xmm2,%xmm0
paddd %xmm9,%xmm0
cmp $0x400,%rcx
jne 1b
对于这种适合 SIMD 的代码,开启 -march=native 后通常会有明显的性能提升,实际测试也证明了这一点,开了 -march=native 后,时间从 77s 降低到 32s,Stockfish::Eval::NNUE::evaluate 时间占比降到 54.20%,此时主要的计算指令变为 AVX-VNNI 扩展的 vpdpbusd (Multiply and Add Unsigned and Signed Bytes) 指令,即针对字节(weights 数组元素是 int8_t 类型,input 数组元素是 uint8_t 类型)元素的整数乘加融合指令,和的类型是 int32_t。核心循环如下(Godbolt):
1:
# 加载无符号 input
vmovdpa (%r8,%rcx,1),%ymm0
# 加载有符号 weights 并计算 sum += weights[offset + j] * input[j]
{vex} vpdpbusd (%rdx,%rcx,1),%ymm0,%ymm2
add $0x20,%rcx
cmp $0x400,%rcx
jne 1b
如果 CPU 支持 AVX512-VNNI,还能进一步扩展到 512 的位宽:vpdpbusd (%rdx,%rax), %zmm1, %zmm0。需要注意的是,单纯开 -mavx2 仅能把时间从 77s 减少到 50s,距离 -march=native 的 32s 还有明显的差距:即使开启了 AVX(Godbolt),由于没有开 AVX-VNNI,不能用 vpdpbusd 指令,还是需要先格式转换到 16 位,再用 32 位累加器的 16 位整数乘加指令。Stockfish 的 NNUE 这样的计算方式,就是奔着 vpdpbusd 这条指令去的。因此缺乏这类指令的 CPU,或者虽有指令但编译器未加利用,性能就会明显落后。
例如在 ARM64 下,对应的 USDOT (Dot product with unsigned and signed integers (vector)) 指令被包括在 i8mm 扩展当中,有这个扩展的话,-march=native 性能提升显著(Godbolt),例如 Apple M2;而如果没有这个扩展,开不开 -march=native 就没什么区别,例如 Apple M1,此时就要回退到类似 AMD64 那样,先扩展到 16 位,再求和(Godbolt)。RISC-V Vector 指令集扩展则有 vwmulsu.vv 指令可以使用,得到 16 位乘法结果之后,再用 vwadd.wv 指令累加到 32 位(Godbolt)。LoongArch 也有对应的 xvmulwev.h.b/xvmulwod.h.b 指令,得到 16 位乘法结果之后,用 xvhaddw.w.h 指令累加到 32 位(Godbolt),还可以进一步优化为用 xvmulwev.h.bu.b 指令,优化后的 transform 函数性能相比 GCC 16 快 37%。
除了是否开启对应指令集扩展以外,还观察到 GCC 15 在 1to6_nnue 上相比 GCC 14 有明显的性能提升(编译选项为 -O3),时间从 77s 降低到了 49s。观察生成的指令,虽然仍使用 SSE 指令,但指令序列更简洁(Godbolt):
# %xmm5 初始化为全零
1:
# 加载有符号 weights 的 16 个元素
movdqu (%rdx,%rcx,1),%xmm4
movdqa %xmm5,%xmm8
# 加载无符号 input 的 16 个元素
movdqa (%r12,%rcx,1),%xmm2
add $0x10,%rcx
# 将 weights 和零比较,非负得 0,负数得 0xFF
pcmpgtb %xmm4,%xmm8
movdqa %xmm2,%xmm6
movdqa %xmm4,%xmm7
# 把 input 从 8 位无符号扩展到 16 位,保存到 %xmm2 和 %xmm6
punpckhbw %xmm5,%xmm2
punpcklbw %xmm5,%xmm6
# 结合前面的 pcmpgtb,把 weights 从 8 位有符号扩展到 16 位,保存到 %xmm4 和 %xmm7
punpckhbw %xmm8,%xmm4
punpcklbw %xmm8,%xmm7
# 每条 pmaddwd 指令进行 4 次 16-bit * 16-bit + 16-bit * 16-bit = 32-bit 的计算
# 两条 pmaddwd 共完成 8 次 16-bit 乘法和 8 次 32-bit 加法
pmaddwd %xmm4,%xmm2
pmaddwd %xmm7,%xmm6
# 每条 paddd 指令进行 4 次 32 bit 的累加
paddd %xmm2,%xmm0
paddd %xmm6,%xmm0
cmp $0x400,%rcx
jne 1b
可见,即使没有专用的 vpdpbusd 指令,仅用 SSE 也仍有优化空间。GCC 15 通过 SSE 高效实现了有符号和无符号数的符号扩展,获得了介于 GCC 14 次优指令序列与专用 vpdpbusd 指令之间的性能。这在 SPEC CPU2026: Characterization, Representativeness, and Cross-Suite Comparison 论文中也有提及:For example, gcc-15 reduces the instruction count of 706.stockfish_r by up to 3x,不过这个数字是相比 GCC 13 的;相比 GCC 14 也有减少,不过没有那么明显,详情见论文中的 Figure 10 和 Figure 16,这里实测下来是从 GCC 14 的 1342B 条指令降低到 GCC 15 的 1015B。相比之下,LLVM 22 生成的 SSE(-O3,Godbolt)或 AVX(-O3 -march=alderlake,Godbolt)指令都没有 GCC 15 高效。
-O3 编译选项下,1to6_nnue 执行的指令数为 1342.1B,其中 Load 指令有 182.2B 条,Store 指令有 61.8B 条,128 位整数向量指令(如 SSE)有 229.1B 条(int_vec_retired.128bit 性能计数器),分支指令有 77.6B 条,其中有 1612.9M 次错误预测。它的 MPKI 只有 1612.9M/1342.1B*1000=1.20,主要瓶颈还是在上述的神经网络推理当中。
GCC 15 用 -O3 编译选项下,1to6_nnue 执行的指令数减少到 1015.3B,其中 Load 指令有 175.0B 条,Store 指令有 57.8B 条,128 位整数向量指令只有 97.0B 条,分支指令有 77.4B 条,优化效果明显。
GCC 14 用 -march=native 编译选项下,1to6_nnue 执行的指令数锐减到 446.8B,只剩下三分之一的指令数了,其中 Load 指令有 119.6B 条,Store 指令有 44.4B 条,分支指令有 48.7B 条,256 位的 AVX VNNI 指令有 13.2B 条(int_vec_retired.vnni_256 性能计数器),优化效果明显。
3. 7to11_nnue¶
7to11_nnue 的行为与 1to6_nnue 类似,瓶颈也是在 Stockfish::Eval::NNUE:evaluate 函数上。开启 -march=native 后,时间从 72s 降到了 31s。GCC 15 的性能提升也和 1to6_nnue 类似,从 72s 降低到 46s。
-O3 编译选项下,7to11_nnue 执行的指令数为 1253.2B,其中 Load 指令有 176.1B 条,Store 指令有 61.6B 条,128 位整数向量指令有 212.5B 条,分支指令有 75.4B 条,其中有 1547.5M 次错误预测。它的 MPKI 只有 1547.5M/1253.2B*1000=1.23,主要瓶颈还是在神经网络推理当中。
GCC 15 用 -O3 编译选项下,7to11_nnue 执行的指令数减少到 955.3B,其中 Load 指令有 169.4B 条,Store 指令有 57.8B 条,128 位整数向量指令只有 92.3B 条,分支指令有 75.2B 条,优化效果明显。
GCC 14 用 -march=native 编译选项下,7to11_nnue 执行的指令数锐减到 425.9B,只剩下三分之一的指令数了,其中 Load 指令有 115.1B 条,Store 指令有 43.7B 条,分支指令有 47.1B 条,256 位的 AVX VNNI 指令有 12.0B 条,优化效果明显。
小结¶
各负载在不同编译选项下的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测次数 (M) | MPKI | 128 位整数向量 (B) | 256 位 整数向量 (B) |
|---|---|---|---|---|---|---|---|---|---|---|
| 1. 1to6_classical | GCC 14 -O3 |
47 | 531.8 | 135.7 | 59.7 | 56.0 | 2622.8 | 4.93 | 0.13 | 0.00 |
| 1. 1to6_classical | GCC 14 -O3 -mpopcnt |
44 | 453.9 | 124.2 | 53.1 | 46.1 | 2639.3 | 5.81 | 0.13 | 0.00 |
| 2. 1to6_nnue | GCC 14 -O3 |
77 | 1342.1 | 182.2 | 61.8 | 77.6 | 1612.9 | 1.20 | 229.1 | 0.00 |
| 2. 1to6_nnue | GCC 15 -O3 |
49 | 1015.3 | 175.0 | 57.8 | 77.4 | 1258.2 | 1.24 | 97.0 | 0.00 |
| 2. 1to6_nnue | GCC 14 -march=native |
32 | 446.8 | 119.6 | 44.4 | 48.7 | 953.8 | 2.13 | 5.1 | 36.3 |
| 3. 7to11_nnue | GCC 14 -O3 |
72 | 1253.2 | 176.1 | 61.6 | 75.4 | 1547.5 | 1.23 | 212.5 | 0.00 |
| 3. 7to11_nnue | GCC 15 -O3 |
46 | 955.3 | 169.4 | 57.8 | 75.2 | 1224.7 | 1.28 | 92.3 | 0.00 |
| 3. 7to11_nnue | GCC 14 -march=native |
31 | 425.9 | 115.1 | 43.7 | 47.1 | 922.9 | 2.17 | 4.6 | 35.0 |
1to6_classical 类似传统的棋类引擎,有比较复杂的分支和访存,所以它的 MPKI=4.93 比较类似 SPEC CPU 2017 的 531.deepsjeng_r(MPKI=3.16),属于比较高的一类。而 1to6_nnue 和 7to11_nnue 的主要瓶颈在于 i8 的矩阵运算,能否用上硬件的加速指令(这里是 AVX-VNNI)对性能影响很大,分支预测瓶颈就明显小了。整体平均下来的 MPKI 是 1.85,并不算高。
707.ntest_r¶
ntest 是黑白棋的引擎,该基准测试包括如下负载:
实测数据显示,运行这个负载耗费的时间是 140s。reftime 是 592s,对应 4.2 分。开启各项优化编译选项,-O3 -flto 相比 -O3 能带来 4% 的性能提升,进一步 -O3 -flto -march=native 相比 -O3 -flto 还能带来 10% 的性能提升。下面分析它的具体负载特性。黑白棋的规则很简单:只有在某个空位落子能翻转至少一个对方棋子时,才能下子,否则就要轮空。翻转的规则是,沿横、竖、斜八个方向检查,如果该方向上从新落子到另一颗己方棋子之间全是对方棋子,则这些对方棋子全部翻转。通过 perf 观察性能瓶颈,这几个函数耗费的时间占比较多:
flips(int sq, u64 mover, u64 enemy)来自src/flips.cpp:34.80%,最主要的开销,根据棋盘状态,经过一系列的访存和位运算,先通过neighbors[sq]&enemy判断是否有敌方邻居棋子(无则无法下子),再计算下子后会翻转哪些棋子,主要是一些数据依赖的访存,混合了一堆位运算;solveNParity(int alpha, int beta, u64 mover, u64 enemy, u64 parity, EndgameSearch* search, bool hasPassed)来自src/solve.cpp:14.21%,进行 alpha-beta 减枝的 minimax 算法(negamax 变种),遍历棋盘上的空位置,首先找到那些满足 good parity 的位置(用bitSet()函数,汇编上是用 AMD64 的bt指令判断,因为黑白棋里,双方轮流下子,走最后一步的玩家获得一定的优势,所以先找那些能让自己走最后一步的位置),调用上述flips()看看是否会出现翻转,如果会出现翻转就尝试下子并进行递归,之后再遍历一次,这次遍历 bad parity 的位置,流程相同,主要的瓶颈在访存以及依赖访存结果的分支;__popcountdi2:9.65%,因为没开-mpopcnt/-march=native,故需要它来代替 popcnt 指令,用来计算场面上各颜色棋子的数量等等;solveNFlipParity:8.95%,与 solveNParity 配合完成 minimax 算法;solve2:5.38%,minimax 算法的一部分,处理棋盘只有两个空位的最终局面,此时判断最终胜败是比较容易的,不需要再递归。
这也是典型的棋类引擎模式,整个 minimax 算法占了 70%+ 的时间,为了搜索局面,有大量的位运算和访存,还有根据访存结果决定方向的分支。果不其然,执行 2688.3B 条指令,其中有 647.8B 条 Load 指令,255.2B 条 Store 指令,228.2B 条是分支指令,有 6.1B 次错误预测,MPKI 达到了 6.1B/2688B*1000=2.27。通过 perf record -e branch-misses:pp,看到 solveNParity 和 solveNFlipParity 一起贡献了 60.37% 的错误预测,主要就是上面说的,循环内对 good 还是 bad parity 的判断,以及链表插入时是否为 NULL 的判断,都是方向依赖数据的分支。
和 706.stockfish_r 类似,它也有不少的 popcnt 调用,那么打开 -mpopcnt 就会得到不错的性能提升:时间从 140s 降低到 126s,减少 11% 时间,指令数减少到 2286.9B,其中有 586.9B 条 Load 指令,206.7B 条 Store 指令,187.6B 条分支指令。而即使开 -march=native,性能也只是进一步降到 122s,只有少量的地方用到了 AVX2。
另一方面,LLVM 22 的性能在 707.ntest_r 上比 GCC 14 要快:同样是 -O3 的编译选项,运行时间从 GCC 14 的 140s 降低到 126s。深入研究汇编发现,LLVM 22 在没有开 -mpopcnt 的时候,它的行为是,直接把类似 libgcc 的 __popcountdi2 的代码内联到了程序当中,省去了 call libgcc 的开销,不过代价就是代码体积会增加,实际执行了 2416.9B 条指令,其中有 542.7B 条 Load 指令,202.9B 条 Store 指令,168.2B 条分支指令。类似地,706.stockfish_r 的 1to6_classical 也是 LLVM 22 比 GCC 14 快,从 47s 降低到 44s。
同时,GCC 15 相比 GCC 14 也有性能提升,运行时间从 140s 降低到了 130s。分析汇编,发现主要优化点在 flips(int sq, u64 mover, u64 enemy) 函数当中。性能区别有两点:
- 首先是对 callee-saved 寄存器的使用,GCC 14 会在 epilogue/prologue 直接进行一系列的 push/pop,而 GCC 15 更加聪明,仅在
if (neighbors[sq]&enemy)条件成立的情况下,需要执行复杂函数体,需要 callee-saved 寄存器时才会进行 push/pop,否则就直接 ret,因为检查条件的时候并没有用到 callee-saved 寄存器,避免了保存和恢复。 - 自己编译的 GCC 15 默认是 -no-pie 模式,而发行版的 GCC 14 默认是 -pie,而 -no-pie 模式因为采用绝对地址,可以在 imul 等指令的操作数直接访问内存,节省寄存器,此时不再需要 callee-saved register,直接免去了 push/pop 的开销,开启 -static 也能带来类似的效果。上面的第一条分析是手动给 GCC 15 开 -pie 后观察到的。不过主要的性能提升还是来自于减少 push/pop 的执行次数。
GCC 15 编译的 707.ntest_r,实际执行 2429.3B 条指令,其中有 610.9B 的 Load 指令,206.2B 的 Store 指令,224.7B 的分支指令。707.ntest_r 在不同编译器和编译选项下的情况如下:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) |
|---|---|---|---|---|---|
GCC 14 -O3 |
140 | 2688.3 | 647.8 | 255.2 | 228.2 |
GCC 14 -O3 -flto |
134 | 2656.3 | 623.4 | 251.3 | 200.9 |
GCC 14 -O3 -mpopcnt |
126 | 2286.9 | 586.9 | 206.7 | 187.6 |
GCC 14 -O3 -march=native |
122 | 2230.0 | 588.2 | 206.4 | 185.2 |
LLVM 22 -O3 |
126 | 2416.9 | 542.7 | 202.9 | 168.2 |
GCC 15 -O3 |
130 | 2429.3 | 610.9 | 206.2 | 224.7 |
结合 706.stockfish_r 和 707.ntest_r 可以看到,popcnt 还是比较常用的。但可惜 AMD64 的基线并不提供这条指令,因此开了 x86-64-v2 或以上的编译优化选项后,这类应用便可以通过一条 popcnt 指令免去 libgcc 的 __popcountdi2 调用开销,节省因额外 call 及 PLT 带来的性能损失。相比 AVX-VNNI,popcnt 的普及程度就要大得多了。
708.sqlite_r¶
sqlite 就是大名鼎鼎的数据库了,不必多介绍。该基准测试包括三个负载:
# 1. main
sqlite_r --memdb --size 2000 --testset main --verify
# 2. cte
sqlite_r --memdb --size 2000 --testset cte --verify
# 3. fp
sqlite_r --memdb --size 1000 --testset fp --verify
实测数据显示,三个负载耗费的时间分别是 69s、12s 和 25s,共计 106s。reftime 是 528s,对应 5.0 分。开启 -flto/-ljemalloc 对性能影响很小,-march=native 甚至带来了负优化。下面逐一分析这三个负载的性能特性。
1. main¶
通过 perf 观察性能瓶颈,这几个函数耗费的时间占比较多:
sqlite3BtreeMovetoUnpacked(BtCursor *pCur, UnpackedRecord *pIdxKey, i64 intKey, int biasRight, int *pRes)来自src/sqlite3.c:24.66%,在 Btree 上进行搜索,根据 key,查找对应的 entry,中间一个比较耗时的部分是逐字节扫描 pCell 指向的内存,此外还会经常调用sqlite3GetVarint获取 pCell 保存的变长 int 来实现二分搜索;sqlite3VdbeExec(Vdbe *p)来自src/sqlite3.c:22.36%,用 Loop+Switch 实现的执行字节码的虚拟机,执行编译好的 SQL 语句,VDBE 是 SQLite 的执行引擎,全称是 Virtual Database Engine,模拟过程会维护一个pc,从aOp数组里扫描字节码,每个字节码是一个struct VdbeOp结构体,根据它的opcode字段进行一个大的 switch-case,一共有 176 种不同的 Op;gcc 把这个巨大的 switch-case 编译成了跳转表,也就是把各个 case 的地址保存到一个数组当中,根据opcode计算出对应 case 的地址,再jmp *%rax过去,执行完 case 的代码后,再跳回 switch 开头,读取下一个 opcode,再跳转;目前有一些解释器会直接用 C 的扩展,用 computed goto label 的写法来帮助编译器做这个优化,或者更进一步直接在每个 case 的最后跳转到下一个opcode对应的 case,拓展阅读: Android Runtime 解释器的实现探究;pcache1Fetch(sqlite3_pcache *p, unsigned int iKey, int createFlag)来自src/sqlite3.c:8.26%,对应一个用哈希表维护的 Page Cache,用于在内存里缓存硬盘上的数据,主要瓶颈在pcache1FetchNoMutex里的pPage = pCache->apHash[iKey % pCache->nHash]; while( pPage && pPage->iKey!=iKey ){ pPage = pPage->pNext; },对哈希表的桶里的链表做一个扫描,随机访存比较多;sqlite3GetVarint(const unsigned char *p, u64 *v)来自src/sqlite3.c:3.70%,恢复内存中可变长度的整数,比如[0,127]范围的数字用一个字节保存,[128,16383]范围的数字用两个字节保存,更大的数字则要更长,最多到九个字节,这种压缩表示还挺常见的,多数时候可以节省空间。
都是一些比较经典的数据结构和算法的应用,Btree,Loop+Switch 的解释执行,加哈希表查询。一段 Vdbe 指令序列的例子如下:
sqlite> CREATE TABLE test(key INT, value INT);
sqlite> EXPLAIN SELECT * FROM test WHERE key = 1;
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 10 0 0 Start at 10
1 OpenRead 0 2 0 2 0 root=2 iDb=0; test
2 Rewind 0 9 0 0
3 Column 0 0 1 0 r[1]= cursor 0 column 0
4 Ne 2 8 1 BINARY-8 84 if r[1]!=r[2] goto 8
5 Column 0 0 3 0 r[3]= cursor 0 column 0
6 Column 0 1 4 0 r[4]= cursor 0 column 1
7 ResultRow 3 2 0 0 output=r[3..4]
8 Next 0 3 0 1
9 Halt 0 0 0 0
10 Transaction 0 0 1 0 1 usesStmtJournal=0
11 Integer 1 2 0 0 r[2]=1
12 Goto 0 1 0 0
能看到它的实现方式是,扫描 test 表的每一行,读取 key 列,如果不等于 1,则直接进入下一行;如果等于 1,则把所有列读出来,加入到结果当中。
这个负载的主要瓶颈在内存上。执行了 896.3B 条指令,其中 252.4B 是 Load 指令,105.1B 是 Store 指令,178.0B 是分支指令,错误预测了 1.5B 次,MPKI 是 1.5B/896.3B*1000=1.67。
2. cte¶
通过 perf 观察性能瓶颈,这几个函数耗费的时间占比较多:
sqlite3VdbeExec(Vdbe *p)来自src/sqlite3.c:41.15%,主要时间花费在查询的执行,因为这个 cte 负载,其计算过程比较复杂,用 SQL 实现了数独(递归和非递归版本)、Mandelbrot,还测试了 EXCEPT SELECT 语法;sqlite3VdbeRecordCompareWithSkip(int nKey1, const void *pKey1, UnpackedRecord *pPKey2, int bSkip)来自src/sqlite3.c:7.37%,比较表里的两个行,会调用sqlite3VdbeSerialGet获取行内的数据,再根据数据类型进行对应的比较;sqlite3VdbeSerialGet(const unsigned char *buf, u32 serial_type, Mem *pMem)来自src/sqlite3.c:5.95%,反序列化,根据内存中保存的数据类型,解析对应的数据,比如整数或者浮点,它的 switch-case 也被 GCC 编译成了跳转表;vdbeSorterSort(SortSubtask *pTask, SorterList *pList)来自src/sqlite3.c:5.95%,实现归并排序,主要时间是在通过函数指针调用比较器函数,以及根据比较结果进行归并。
瓶颈主要在解释器上,与 CPython 解释器的行为模式类似。执行了 306.0B 条指令,其中 82.8B 是 Load 指令,39.6B 是 Store 指令,62.6B 是分支指令,错误预测了 40.9M 次,MPKI 是 40.9M/306.0B*1000=0.13,处于很低的水平。
3. fp¶
通过 perf 观察性能瓶颈,这几个函数耗费的时间占比较多:
sqlite3VdbeExec(Vdbe *p)来自src/sqlite3.c:30.66%,主要时间花费在查询的执行,因为这个 fp 负载,其计算过程引入了不少浮点运算;sqlite3AtoF(const char *z, double *pResult, int length, u8 enc)来自src/sqlite3.c:19.18%,实现从字符串到浮点数的转换,因为 SQL 内有很多浮点字面量;vdbeSorterSort(SortSubtask *pTask, SorterList *pList)来自src/sqlite3.c:10.44%,描述见上;sqlite3VdbeRecordCompareWithSkip(int nKey1, const void *pKey1, UnpackedRecord *pPKey2, int bSkip)来自src/sqlite3.c:6.76%,描述见上。
瓶颈主要在解释器上,不过因为 SQL 语句的设计,有很多时间花在字符串转浮点数上。执行了 554.7B 条指令,其中 132.3B 是 Load 指令,61.3B 是 Store 指令,111.5B 是分支指令,错误预测了 392.6M 次,MPKI 是 392.6M/554.7B*1000=0.71。
小结¶
各负载在不同编译选项下的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | MPKI |
|---|---|---|---|---|---|---|---|
| 1. main | GCC 14 -O3 |
69 | 896.3 | 252.4 | 105.1 | 178.0 | 1.67 |
| 1. main | GCC 14 -O3 -march=native |
73 | 905.3 | 273.7 | 109.9 | 177.2 | 1.62 |
| 2. cte | GCC 14 -O3 |
12 | 306.0 | 82.8 | 39.6 | 62.6 | 0.13 |
| 2. cte | GCC 14 -O3 -march=native |
13 | 303.6 | 88.9 | 40.0 | 62.6 | 0.13 |
| 3. fp | GCC 14 -O3 |
25 | 554.7 | 132.3 | 61.3 | 111.5 | 0.71 |
| 3. fp | GCC 14 -O3 -march=native |
27 | 555.8 | 142.7 | 62.6 | 111.6 | 0.69 |
通过上面的分析,可见 sqlite_r 确实是比较难优化的那一类,大量访存、计算和分支混合在一起,对内存子系统的负担比较重,难以向量化,开 -O3 -march=native 后运行时间从 106s 增加到 113s,产生了负优化。整体来看,执行了 1760B 条指令,其中有 353B 条是分支指令,MPKI 仅有 1.08,主要由 main 贡献。
710.omnetpp_r¶
SPEC INT 2017 就有的老面孔 520.omnetpp_r,不过运行的内容也和以往不同。520.omnetpp_r 做的是 10 Gbps 网络的模拟,而 710.omnetpp_r 有足足十项负载,负载的多样性有了明显的增强。十项负载的命令行参数如下:
omnetpp_r -f randomMesh.ini -c General
omnetpp_r -f queuenet.ini -c OneFifo
omnetpp_r -f queuenet.ini -c TandemFifos
omnetpp_r -f queuenet.ini -c SmallCQN
omnetpp_r -f queuenet.ini -c Ring
omnetpp_r -f queuenet.ini -c Terminal
omnetpp_r -f queuenet.ini -c CallCenter
omnetpp_r -f queuenet.ini -c ForkJoin
omnetpp_r -f queuenet.ini -c ResourceAllocation
omnetpp_r -f queuenet.ini -c AllocDealloc
实测数据显示,十个负载耗费的时间分别是 24.6s、7.8s、3.8s、4.6s、9.1s、3.7s、2.6s、9.4s、6.6s 和 14.0s,共计 86.2s。reftime 是 486s,对应 5.6 分。
1. randomMesh¶
首先分析第一个负载的热点函数:
omnetpp::cTopology::calculateUnweightedSingleShortestPathsTo(Node *_target)来自src/simulator/sim/ctopology.c:16.22%,实现了经典的单源最短路算法,且由于每条边的权重都是一,实际上就是 BFS,主要瓶颈来自于随机访存和计算距离的双精度浮点运算;__do_dyncast和__dynamic_cast来自 libstdc++.so:4.73%+3.24%+2.22%+0.81%=11.0%,代码中有一些 dynamic_cast 的使用,如Routing::handleMessage;Routing::handleMessage(cMessage *msg)来自src/model/Routing.cc:7.10%,模拟路由表的功能,主要逻辑是内联了一个std::map<int, int>的find操作(Godbolt),在一个红黑树上进行查询,读取结点,比较 key,走左子树或右子树继续查询;cEvent::shouldPrecede(const cEvent *other)来自src/simulator/sim/cevent.cc:4.64%,一个 cEvent 结构体的多关键字比较函数。
整体来看,它的瓶颈分散在比较多的地方。执行了 306.4B 条指令,其中有 98.7B 条 Load 指令,50.2B 条 Store 指令,62.1B 条分支指令,错误预测 661.2M 次,MPKI 为 661.2M/306.4B*1000=2.16。开 -O3 -flto 后,指令数减少到 284.6B,其中有 91.3B 条 Load 指令,45.4B 条 Store 指令,55.7B 条分支指令。进一步开 -O3 -flto -ljemalloc,指令数进一步减少到 279.8B,其中有 90.3B 条 Load 指令,44.4B 条 Store 指令,54.3B 条分支指令。
randomMesh 在不同编译选项下的情况如下:
| 编译器 + 选项 | 指令 (B) | Load (B) | Store (B) | 分支 (B) |
|---|---|---|---|---|
GCC 14 -O3 |
306.4 | 98.7 | 50.2 | 62.1 |
GCC 14 -O3 -flto |
284.6 | 91.3 | 45.4 | 55.7 |
GCC 14 -O3 -flto -ljemalloc |
279.8 | 90.3 | 44.4 | 54.3 |
其余的 2-10 共 9 个 queuenet 负载¶
用 perf 观察,其余 9 个 queuenet 负载的瓶颈主要集中在这些函数:
- strcmp(
__strcmp_avx2) - dynamic_cast(
__do_dyncast和__dynamic_cast) - malloc、free 和 operator new
- printf(
__printf_buffer)
还有些 omnetpp 自己的函数(如 omnetpp::common::StringPool::obtain(const char *s),主要是对 std::unordered_map<const char *,int,str_hash, str_eq> pool 进行查询和修改操作),散落各处,每个函数都只占用不到 5% 的时间。对于这么大比例使用 libc/libstdc++ 中函数的情况,标准库和内存分配器的实现就很重要了。
小结¶
基于以上分析,尝试了不同的编译选项,结果如下:
- 开
-O3 -ljemalloc后,十个负载的性能都有了一定的提升,总时间从 86.2s 降低到 80.6s,分数从 5.6 分提升到 6.0 分。 - 开
-O3 -flto也能带来不错的提升,总时间从 86.2s 降低到 76.1s,分数从 5.6 分提升到 6.4 分。 - 开
-O3 -flto -ljemalloc,则总时间从 86.2s 降低到 69.7s,分数从 5.6 分提升到 7.0 分。
类似现象在 SPEC INT 2017 中就曾出现,-O3 -flto 比 -O3 快 3%,-O3 -flto -ljemalloc 比 -O3 -flto 快 20%。
-O3 下,执行的指令数是 1447B,其中 291B 是分支指令,MPKI 是 0.78。虽然 randomMesh 因为图计算,MPKI 比较高,但整体的 MPKI 被其余负载拉低了。相比之下,SPEC INT 2017 Rate 的 520.omnetpp_r 的 MPKI 足足有 4.33。虽然还是同一个框架,但是负载行为还是出现了明显的变化。
714.cpython_r¶
前面才提到过解释器,这就到 CPython 了。该基准测试包含三个负载:
# 1. resnet
cpython_r -I -B coreml_pb.py -i 2 -a -m Resnet50Headless.mlmodel -d 10
# 2. mobilenet
cpython_r -I -B coreml_pb.py -i 5 -a -c -m MobileNetV2.mlmodel -d 20
# 3. dna
cpython_r -I -B dna_bench.py 600000
三个负载的运行时间分别为 31s、20s 和 20s,总时间 71s,reftime 是 479s,对应 6.7 分。开启 -O3 -flto 后,三个负载的运行时间分别为 29s、19s 和 18s,总时间 66s,对应 7.3 分。-O3 -ljemalloc 影响很小,-O3 -march=native 有负优化。下面具体分析三个负载的负载特性。
1. resnet¶
还是用 perf,统计出热点函数:
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)来自src/cpython/Python/ceval.c:24.09%,解释器中的 Loop + Switch 核心代码,对 Python 字节码进行解释执行,主要的瓶颈也是跳转表,根据 opcode 计算 case 地址然后jmp *%rax;PyUnicode_FromFormatV(const char *format, va_list vargs)来自src/cpython/Objects/unicodeobject.c,4.51%,把结果写到 Python 字符串的 sprintf 版本,主要瓶颈是格式化字符串的解析,找%的位置;_PyObject_Free(void *ctx, void *p)来自src/cpython/Objects/obmalloc.c:3.48%,释放 PyObject,Python 有一个自己的针对 PyObject 的内存分配器,而不是直接使用 malloc/free;_PyObject_Malloc(void *ctx, size_t nbytes)来自src/cpython/Objects/obmalloc.c:3.15%,分配 PyObject。
剩下就比较零散了,主要还是围绕着解释器的循环。执行了 651.6B 条指令,其中有 180.4B 是 Load 指令,104.1B 是 Store 指令,136.6B 是分支指令,错误预测仅 7.9M 次,MPKI 等于 7.9M/651.6B*1000=0.01,可以忽略不计。开启 -O3 -flto 后,热点函数不变,指令数降低为 618.0B,其中 Load 有 176.6B,Store 有 93.9B,分支有 128.6B,错误预测 48.6M 次。
2. mobilenet¶
统计出热点函数,发现前四依然是上面四个,且时间占比差不多。可能是因为,resnet 和 mobilenet 负载用的是同一个 .py 源码,只是用的模型不同。执行了 438.9B 条指令,其中有 121.4B 是 Load 指令,70.5B 是 Store 指令,91.6B 是分支指令,错误预测 9.1M 次,MPKI 等于 9.1M/438.9B*1000=0.02,可以忽略不计。开启 -O3 -flto 后,热点函数不变,指令数降低为 416.4B,其中 Load 指令有 119.0B,Store 指令有 63.8B,分支有 86.2B,错误预测 35.0M 次。
3. dna¶
统计热点函数:
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)来自src/cpython/Python/ceval.c:36.75%,描述见上;_PyObject_Free(void *ctx, void *p)来自src/cpython/Objects/obmalloc.c:5.31%,描述见上;PyUnicode_Contains(PyObject *str, PyObject *substr)来自src/cpython/Objects/unicodeobject.c,4.59%,Python 字符串的 contains 操作,对应data/all/input/knucleotide.py代码中的chat in "GATC"判断;_PyObject_Malloc(void *ctx, size_t nbytes)来自src/cpython/Objects/obmalloc.c:3.52%,描述见上。
主要热点还是解释执行,不过因为字符串的 contains 调用次数较多,所以 PyUnicode_Contains 时间占比有所上升。执行了 394.9B 条指令,其中有 113.3B 是 Load 指令,62.1B 是 Store 指令,77.1B 是分支指令,错误预测 228.1M 次,MPKI 等于 228M/394B*1000=0.58,也还是很低。开启 -O3 -flto 后,热点函数不变,指令数降低为 379.3B,其中 Load 有 113.4B,Store 有 58.5B,分支有 71.6B,错误预测 223.8M 次。
小结¶
各负载在不同编译选项下的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) |
|---|---|---|---|---|---|---|---|
| 1. resnet | GCC 14 -O3 |
31 | 651.6 | 180.4 | 104.1 | 136.6 | 7.9 |
| 1. resnet | GCC 14 -O3 -flto |
29 | 618.0 | 176.6 | 93.9 | 128.6 | 48.6 |
| 2. mobilenet | GCC 14 -O3 |
20 | 438.9 | 121.4 | 70.5 | 91.6 | 9.1 |
| 2. mobilenet | GCC 14 -O3 -flto |
19 | 416.4 | 119.0 | 63.8 | 86.2 | 35.0 |
| 3. dna | GCC 14 -O3 |
20 | 394.9 | 113.3 | 62.1 | 77.1 | 228.1 |
| 3. dna | GCC 14 -O3 -flto |
18 | 379.3 | 113.4 | 58.5 | 71.6 | 223.8 |
714.cpython_r 就是一个典型的基于字节码的解释器,在一个 Loop + Switch 结构当中完成解释执行。整体 MPKI 很低,只有 0.17,即使开了 -O3 -flto,虽然预测错误多了,总指令数少了,MPKI 会变大,但绝对数字也还是很小,只有 0.23。
721.gcc_r¶
SPEC INT 2017 中的 502.gcc_r 便已存在,当时基于 GCC 4.5.0,针对 gcc-pp.c、gcc-smaller.c 和 ref32.c 进行五次编译,这次 721.gcc_r 对着三个同名文件(其中 gcc-pp.c 内容更新了,其余两个不变)分别进行一次编译,基于 GCC 11.2.0 版本,命令行参数如下,相比 502.gcc_r 有所简化:
# 1. gcc-pp
cc1_r gcc-pp.c -O2 -fpic -o gcc-pp.c.opts-O2_-fpic.s
# 2. gcc-smaller
cc1_r gcc-smaller.c -O3 -fipa-pta -o gcc-smaller.c.opts-O3_-fipa-pta.s
# 3. ref32
cc1_r ref32.c -O3 -finline-limit=12000 -fno-tree-vrp -o ref32.c.opts-O3_-finline-limit_12000_-fno-tree-vrp.s
-O3 运行时间分别为 44s、21s 和 51s,总时间 116s,reftime 是 686s,对应 5.9 分。开了 -O3 -flto 后,时间略微降低到 115s,开 -O3 -flto -ljemalloc 后时间进一步降低到 111s,主要针对的是占用时间约 2% 的 malloc/free。开 -march=native 对性能几乎没有影响。
与 502.gcc_r 的行为类似(见 The Alberta Workloads for the SPEC CPU® 2017 Benchmark Suite 的分析),721.gcc_r 的时间分布在大量函数,除了 ref32 花费了 10.76% 的时间在 dominated_by_p、5.92% 的时间在 bitmap_set_bit 以外,其他函数的占用时间基本都在 3% 以下,没有一个特别明显的热点函数。
其中 bitmap_set_bit(bitmap head, int bit) 函数来自 src/gcc/bitmap.cc,通过位运算,在 bitmap 里把一个 bit 设为一,比较特别的是,这个 bitmap 可以有二叉树(splay tree)和链表两种保存格式。从 perf record -e branch-misses:pp 来看,这个函数主要是在设置 bit 的时候出现了一些分支预测的错误:它首先读取 bitmap 原来的数值,判断该 bit 是否已经设置,只有之前没设置的情况下,才会更新 bitmap。这样的好处是,可以节省一些 Store 指令,但也带来了一些分支的错误预测。此外就是链表的插入逻辑,需要判断指针是否为空。
另外,dominated_by_p(enum cdi_direction dir, const_basic_block bb1, const_basic_block bb2) 函数来自 src/gcc/dominance.cc,做的是基本块的 dominance 查询,A dom B 代表从函数入口到 B 一定会经过 A,这是编译器中很常见的一个查询,由于查询次数很多,会预先通过两遍 dfs(一遍从上往下,一遍从下往上,上对应入口,下对应出口)找到基本块的拓扑顺序,然后根据拓扑排序的结果来判断是否有 A dom B 的关系:DFS_Number_In(A) <= DFS_Number_In(B) && DFS_Number_Out(A) >= DFS_Number_Out(B),也就是从上往下遍历(In)的时候,先到达 A,然后从下往上遍历(Out)的时候,先到达 B。其实这个函数并不复杂,而且 DFS 已经提前算好了,这里只需要读取计算好的结果,但是因为它把两次比较做成了一次 cmp+jl 和一次 cmp+setle,导致容易出现分支预测错误。从逻辑上来说,这里可以改成完成两次比较,再对结果取 AND,但由于代码里是 && 有短路的性质,理论上第一个条件成立了,就不该进行第二个条件,更何况第二个条件里还涉及两次访存。这种实现确实可能省下一些访存,但分支预测也变难了。如果改写代码,先进行两次比较,再进行 && 操作,就没有分支指令了,不过访存次数也确实变多了:Godbolt。
三次运行的性能计数器如下:
- gcc-pp: 执行 470.2B 条指令,其中有 125.6B 条 Load 指令,58.8B 条 Store 指令,99.9B 条分支指令,错误预测 2.2B 次,MPKI 等于
2.2B/470.2B*1000=4.68 - gcc-smaller: 执行 243.4B 条指令,其中有 65.0B 条 Load 指令,30.3B 条 Store 指令,51.8B 条分支指令,错误预测 0.91B 次,MPKI 等于
0.91B/243.4B*1000=3.74 - ref32: 执行 403.7B 条指令,其中有 118.9B 条 Load 指令,45.8B 条 Store 指令,86.1B 条分支指令,错误预测 0.61B 次,MPKI 等于
0.61B/403.7B*1000=1.51
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (B) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. gcc-pp | GCC 14 -O3 |
44 | 470.2 | 125.6 | 58.8 | 99.9 | 2.2 | 4.68 |
| 1. gcc-pp | GCC 14 -O3 -ljemalloc |
42 | 467.2 | 125.2 | 58.7 | 98.5 | 2.2 | 4.71 |
| 2. gcc-smaller | GCC 14 -O3 |
21 | 243.2 | 65.0 | 30.3 | 51.8 | 0.91 | 3.74 |
| 2. gcc-smaller | GCC 14 -O3 -ljemalloc |
21 | 242.1 | 64.7 | 30.2 | 51.2 | 0.90 | 3.72 |
| 3. ref32 | GCC 14 -O3 |
51 | 403.8 | 118.9 | 45.8 | 86.1 | 0.61 | 1.51 |
| 3. ref32 | GCC 14 -O3 -ljemalloc |
49 | 405.2 | 119.4 | 46.2 | 85.8 | 0.61 | 1.51 |
整体指令数是 1120B,其中有 238B 条分支指令,MPKI 等于 3.37,在 SPEC INT 2026 中属于比较高的了。作为对比,SPEC INT 2017 Rate 中 502.gcc_r 的 MPKI 是 3.13,两者差异不大。
意料之中的是,用 GCC 14 编译的 721.gcc_r,运行得比用 LLVM 22 编译的 721.gcc_r 更快。
723.llvm_r¶
随着 LLVM 的发展,SPEC CPU 2026 终于是把 LLVM 也加入了进来。和 721.gcc_r 类似,也是跑 LLVM 的优化器,只不过输入直接就是 .bc 中间代码文件,而不是 C 代码。它包括两个负载:
# 1. transformsplus
llvm-opt_r transformsplus.bc -S -O3 -mcpu=pwr9
# 2. codegen
llvm-opt_r codegen.bc -S -O3 -mcpu=pwr9
-O3 运行时间分别为 62s 和 53s,总时间 115s,reftime 是 507s,对应 4.4 分。开 -O3 -flto 性能反而变差,不过开 -O3 -ljemalloc 有明显性能提升,运行时间降低为 59s 和 47s,总时间 106s,分数提高到 4.8 分。开 -march=native 对性能几乎没有影响。
有意思的是,用 GCC 14 编译的 723.llvm_r 比用 LLVM 22 编译的运行更快,不过优势并不大。下面针对这两个负载进行具体的分析。
1. transformsplus¶
使用 perf 观察热点函数:
llvm::InstCombinerImpl::foldIntegerTypedPHI(llvm::PHINode& PN)来自src/lib/Transforms/InstCombine/InstCombinePHI.cpp: 4.06%,对 IR 中的 PHI 结点进行处理,这个函数还挺复杂的,主要瓶颈在内层循环,遍历 use 链表,有比较多的随机访存和通过分支来判断 LLVM 自制 RTTI 的类型;_int_malloc/cfree/malloc:2.38%+0.89%+0.82%=4.09%,大量的内存分配和释放,因此-ljemalloc能带来不错的性能提升;llvm::DenseMapBase::FindAndConstruct(): 1.69%,LLVM 自己用数组实现的哈希表,主要瓶颈在读取哈希桶内的 entry 并比较 key,随机访存比较慢,近期 LLVM 也在做相关的优化。
其他有很多小的函数,占时间比例不高,和 721.gcc_r 类似,也是时间分散得比较开。执行指令数为 572.8B,其中 Load 指令有 137.7B,Store 指令有 78.6B,分支指令有 118.7B,错误预测有 3.5B 次,MPKI 等于 3.5B/572.8B*1000=6.11,挺高的。
从 perf record -e branch-misses:pp 来看,错误预测挺分散在很多个函数,每个函数比例也不高。从 Top down 来看,有 40% 都在 Frontend Bound,有 19.2% 在 Bad Speculation。更进一步分析,发现它的 L1 ICache 缺失次数为 12.6B(L1-icache-load-misses 性能计数器),对应的 L1IC MPKI 足足有 12.6B/572.8B*1000=22.0,可见主要问题是 723.llvm_r 的代码量太大了,L1IC 存不下,BTB 也够呛。
2. codegen¶
使用 perf 观察热点函数:
llvm::InstCombinerImpl::foldIntegerTypedPHI(llvm::PHINode& PN)来自src/lib/Transforms/InstCombine/InstCombinePHI.cpp: 20.85%,描述见上;_int_malloc/cfree/malloc:1.91%+0.72%+0.65%=3.28%,描述见上;llvm::DenseMapBase::FindAndConstruct(): 1.29%,描述见上。
整体的情况和 transformsplus 类似,只不过 foldIntegerTypedPHI 时间占比更高,其他还是有很多函数耗费很短的时间,分散得比较开。执行指令数为 415.9B,其中 Load 指令有 100.4B,Store 指令有 57.5B,分支指令有 86.0B,错误预测有 2.4B 次,MPKI 等于 2.4B/415.9B*1000=5.77,依然很高。
小结¶
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (B) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. transformsplus | GCC 14 -O3 |
62 | 572.8 | 137.7 | 78.6 | 118.7 | 3.5 | 6.11 |
| 1. transformsplus | GCC 14 -O3 -ljemalloc |
59 | 563.2 | 135.7 | 77.2 | 115.2 | 3.3 | 5.86 |
| 2. codegen | GCC 14 -O3 |
53 | 415.9 | 100.4 | 57.5 | 86.0 | 2.4 | 5.77 |
| 2. codegen | GCC 14 -O3 -ljemalloc |
47 | 411.0 | 99.3 | 56.6 | 84.1 | 2.3 | 5.60 |
LLVM 和 GCC 同为编译器领域的双子星,在负载特性上也有相似之处:有很多的内存分配和释放,受益于 -ljemalloc;时间分布在大量小函数当中,热点不明显;MPKI 较高,尤其是 723.llvm_r 直接一跃成为 SPEC INT 2026 Rate 中 MPKI 最高的一个基准测试,可能是因为它有大量数据依赖的分支。723.llvm_r 整体的指令数有 991B,其中有 205B 是分支指令,MPKI 达到 5.98,即使放在 SPEC INT 2017 Rate 里,也能紧跟在 505.mcf_r 和 541.leela_r 两位大哥身后,成为 MPKI 第三高的项目。
727.cppcheck_r¶
cppcheck 是一个 cpp 静态分析工具,输入 C++ 文件,提供代码的分析报告,汇报数组越界访问或变量未初始化等等问题。它会分析三个不同的代码,根据命名看,应该是从其他基准测试里找的。747.dealii(成为了 766.femflow_r 的一部分)和 770.7z 不在 SPEC CPU 2026 当中,应该没被选上,只有 738 diamond 以 838.diamond_s 保留了下来:
# 1. 738_diamond
cppcheck_r --force 738-diamond-record.cpp --checkers-report=738_report.txt --enable=all --output-file=738_bogey.txt
# 2. 747_dealii
cppcheck_r --force 747-dealii-data_out_base.cc --checkers-report=747_report.txt --enable=all --output-file=747_bogey.txt
# 3. 770_7z
cppcheck_r --force 770-7z-SystemPage.cpp --checkers-report=770_report.txt --output-file=770_bogey.txt
三条指令的运行时间分别为 27s、22s 和 33s,共 82s,reftime 是 359s,对应 4.4 分。开 -O3 -flto 或 -O3 -march=native 仅能略微提升 1% 的性能,但 -O3 -ljemalloc 能显著提升性能,运行时间缩短到 24s、18s 和 29s,总时间 71s,对应 5.1 分。
下面对这三个负载进行深入的分析。
1. 738_diamond¶
热点函数如下:
multiCompareImpl(const Token *tok, const char *haystack, nonneg int varid)来自src/lib/token.cpp:40.82%,字符串匹配函数,比如用abc|def去匹配一个 token,逐字符比较 token 和 haystack,匹配不上时跳到下一个|尝试 haystack 的下一个候选模式;Token::Match(const Token *tok, const char pattern[], nonneg int varid)来自src/lib/token.cpp:12.08%,也是类似的字符串匹配函数,语法有些不同,类似自研正则表达式子集,它会调用上面的multiCompareImpl函数来做部分匹配;ScopeInfo3::findScope(const std::string & scope)来自src/lib/tokenize.cpp:5.49%,循环,从当前作用域开始寻找对应的符号,如果没有,则检查更高一级的作用域,一般用于从变量名找到作用域里定义的符号,主要时间花在对std::list的遍历以及std::string的比较;Tokenizer::simplifyUsing():3.57%,把using N::x;变为using x = N::x,里面就会用到上面说的Token::Match,参数如"using ::| %name% ::",来做一些模式的匹配并进行相应的简化;cfree/malloc/_int_malloc:0.47%+0.33%+0.45%=1.25%,内存分配相关。
可以看到,主要瓶颈在字符串匹配上,它的实现就是一个循环,用指针去扫描字符串,没有做数据结构上的优化。执行了 399.9B 条指令,其中有 81.2B 条 Load 指令,35.5B 条 Store 指令,108.9B 条分支指令,错误预测 173.2M 次,MPKI 等于 173M/399.9B*1000=0.43,不算高。
2. 747_dealii¶
热点函数类似:
multiCompareImpl(const Token *tok, const char *haystack, nonneg int varid)来自src/lib/token.cpp:27.42%,描述见上;Token::Match(const Token *tok, const char pattern[], nonneg int varid)来自src/lib/token.cpp:14.55%,描述见上;cfree/malloc/_int_malloc:2.14%+1.57%+0.53%=4.24%,内存分配的比例更高;Token::simpleMatch(const Token *tok, const char pattern[], size_t pattern_len)来自src/lib/token.cpp:3.88%,又一个字符串匹配函数,换了种格式,比如"abc def"代表匹配abc或def,这次的瓶颈是strncmp和memchr;TemplateSimplifier::addInstantiation(Token *token, const std::string &scope)来自src/lib/templatesimplifier.cpp:2.98%,在 token 级别上做一些代码简化的变换,主要的耗时在对std::list的遍历;isAliasOf(const Token* tok, const Token* expr, int* indirect, bool* inconclusive)来自src/lib/astutils.cpp:2.55%,判断两个变量是否 alias。
依然有大量的字符串匹配,不太理解为何要设计多种语法,并分别实现多个字符串匹配函数。执行了 303.9B 条指令,其中有 67.3B 条 Load 指令,31.5B 条 Store 指令,82.5B 条分支指令,错误预测 298.9M 次,MPKI 等于 298.9M/303.9B*1000=0.98,也不算高。
3. 770_7z¶
热点如下:
multiCompareImpl(const Token *tok, const char *haystack, nonneg int varid)来自src/lib/token.cpp:32.25%,描述见上;Token::Match(const Token *tok, const char pattern[], nonneg int varid)来自src/lib/token.cpp:18.82%,描述见上;__memcmp_avx2_movbe:8.99%,被用于字符串匹配;std::map<std::string>::equal_range:7.34%,红黑树上的查询,外加字符串匹配;__strchr_avx2:7.34%,被用于字符串匹配;cfree/malloc/_int_malloc:0.37%+0.27%+0.17%=0.81%,这次内存分配的比例较低。
依然是字符串匹配为主。执行了 505.2B 条指令,其中有 111.0B 条 Load 指令,43.8B 条 Store 指令,137.5B 条分支指令,错误预测 421.0M 次,MPKI 等于 421M/505.2B*1000=0.83,也不算高。
小结¶
整体看下来,727.cppcheck_r 就是在不断地做字符串匹配。一个值得思考的问题是,为何不直接通过 tokenizer 将 token 转为数字,这样比较起来快得多。在 token 级别上做各种变换,就在不停地对 token 进行字符串比较,导致最后的性能瓶颈,不是在 cppcheck 自己写的字符串比较,就是在 libc 的字符串比较里了。
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. 738_diamond | GCC 14 -O3 |
27 | 399.9 | 81.2 | 35.5 | 108.9 | 173.2 | 0.43 |
| 1. 738_diamond | GCC 14 -O3 -ljemalloc |
24 | 395.0 | 80.2 | 34.7 | 107.5 | 171.8 | 0.43 |
| 2. 747_dealii | GCC 14 -O3 |
22 | 303.9 | 67.3 | 31.5 | 82.5 | 298.9 | 0.98 |
| 2. 747_dealii | GCC 14 -O3 -ljemalloc |
18 | 291.0 | 64.5 | 29.2 | 79.0 | 287.3 | 0.99 |
| 3. 770_7z | GCC 14 -O3 |
33 | 505.2 | 111.0 | 43.8 | 137.5 | 421.0 | 0.83 |
| 3. 770_7z | GCC 14 -O3 -ljemalloc |
29 | 501.5 | 110.1 | 43.2 | 136.6 | 409.8 | 0.82 |
整体执行了 1211B 指令,其中有 329B 分支指令,分支指令的比例足足有 27%,傲视 SPEC INT 2026 Rate 全场,这都是拜字符串匹配所赐,读一点就比较一点。但同时,MPKI 仅为 0.71,在 SPEC INT 2026 Rate 中倒数第三,仅高于 714.cpython_r 的 0.17 和 750.sealcrypto_r 的 0.14,说明大部分字符串匹配的结果都是很好预测的,比如比较到第一个字节就对不上了。
729.abc_r¶
之前第一次看到 abc 还是在 yosys,它是一个 EDA 软件,和后面的 734.vpr_r 都是开源 EDA 工具里的重量级人物,分别实现了逻辑综合以及布局布线。该基准测试包括 6 个负载:
# 1. twoexact
./abc_r -F twoexact.in
# 2. beem6
./abc_r -F beem6-fraig.in
# 3. mem
./abc_r -F mem_ctrl.in
# 4. vga
./abc_r -F vga_lcd_miter.in
# 5. mcml
./abc_r -F mcml.in
# 6. des
./abc_r -F des_system90.in
六个负载运行时间都不长,分别是 6.3s、10.1s、13.5s、32.3s、13.6s 和 17.0s,总时间 92.8s,reftime 是 459s,对应 4.9 分。
开 -flto、-march=native 或 -ljemalloc 都没有什么提升,性能差距在 1% 以内,属于是油盐不进,各种优化都难以生效。下面进行具体热点分析。
1. twoexact¶
主要的热点函数:
sat_solver_propagate(sat_solver* s)来自src/berkeley-abc/src/sat/bsat/satSolver.c:75.33%,应该是 SAT Solver 中的 Unit Propagation,寻找那些只剩下一个变量还没确定的语句,给它进行赋值,然后传播到其他语句;sat_solver_analyze(sat_solver* s, int h, veci* learnt)来自src/berkeley-abc/src/sat/bsat/satSolver:15.85%,应该是针对出现冲突的语句进行分析,属于 CDCL(Conflict Driven Clause Learning)的一部分;sat_solver_solve_internal(sat_solver* s)来自src/berkeley-abc/src/sat/bsat/satSolver.c:3.80%,是 SAT Solver 的入口函数。
很少能见到这种瓶颈如此高度集中的情况了,不过确实,SAT Solver 大部分时间都在做 Unit Propagation,出现冲突了就做 CDCL。唤起了很久以前在《软件分析与验证》课上写 DPLL SAT Solver 的回忆,当然了,abc 的实现肯定比我那课程作业要更加复杂和高级。主要的瓶颈就是一堆访存以及依赖内存结果的分支,在 SAT 问题的解空间内进行搜索。
指令数 53.2B,其中 Load 指令 13.8B,Store 指令 3.2B,分支指令 8.4B,错误预测 606.2M,MPKI 等于 606.2M/53.2B*1000=11.39,非常的高,接近 SPEC INT 2017 的 541.leela_r 大帝。
通过 perf record -e branch-misses:pp,可以看到主要的分支预测错误来自 sat_solver_propagate 的几处变量取值的判断逻辑,都是依赖数据的分支,难以预测。
2. beem6¶
主要的热点函数:
Cec4_ManPackAddPatterns(Gia_Man_t * p, int iBit, Vec_Int_t * vLits)来自src/berkeley-abc/src/proof/cec/cecSatG2.c:54.65%,CEC 指的是 Combinational Equivalence Checking,该函数内层循环遍历 vLits 中的每个 Entry,通过位运算按一定条件更新p->vSims;Cec4_ManGeneratePatterns_rec(Gia_Man_t * p, Gia_Obj_t * pObj, int Value, Vec_Int_t * vPat, Vec_Int_t * vVisit)来自src/berkeley-abc/src/proof/cec/cecSatG2.c:29.01%,根据 pObj 的类型进行分类讨论和递归。
热点依然很集中,不过因为缺少领域知识,不太明白它在跑什么。运行 255.5B 条指令,其中 Load 有 57.2B,Store 有 7.3B,分支有 40.3B,错误预测 192.0M 次,MPKI 等于 192.0M/255.5B*1000=0.75,相比 SAT 来说低了很多。
3. mem¶
热点函数依然是 sat solver 相关,相比 twoexact,sat_solver_canceluntil 时间占比高了一些,达到了 8.46%,不过整体的特性基本是一样的。运行 151.0B 条指令,其中 Load 指令有 43.4B,Store 指令有 15.4B,分支有 24.2B,错误预测 1213.7M,MPKI 等于 1213.7M/151.0B*1000=8.03,非常高。
4. vga¶
热点函数依然是 sat solver,整体特性一致。运行 490.0B 条指令,Load 指令有 143.9B,Store 指令有 54.4B,分支有 76.9B,错误预测 2092.8M 次,MPKI 等于 2092.8M/490B*1000=4.27,还是很高。
5. mcml¶
热点函数终于有了新面孔:
Abc_ObjDeleteFanin(Abc_Obj_t * pObj, Abc_Obj_t * pFanin)来自src/berkeley-abc/src/base/abc/abcFanio.c:12.57%,逻辑很简单,就是调用Vec_IntRemove从数组里删除一个元素,遍历数组,找到匹配的元素,把后面的元素都往前挪,这个遍历匹配逻辑是主要的瓶颈,其次就是移动数据;Gia_ManSwiSimulate(Gia_Man_t * pAig, Gia_ParSwi_t * pPars)来自src/berkeley-abc/src/aig/gia/giaSwitch.c:8.87%,实现模拟过程,很大一部分时间花在一个自己实现的 popcount 函数Gia_WordCountOnes,它没有被识别并转化为 popcnt 指令,而是用 SSE 向量指令做软件 popcount;Abc_AigAndLookup(Abc_Aig_t * pMan, Abc_Obj_t * p0, Abc_Obj_t * p1)来自src/berkeley-abc/src/base/abc/abcAig.c:7.03%,计算 p0 AND p1,先做特判(如p0 == p1时直接返回p0),若都不命中则走哈希表链表遍历,中间有大量的多级指针访问:pObj->pNtk->vObjs->pArray;If_ObjPerformMappingAnd(If_Man_t * p, If_Obj_t * pObj, int Mode, int fPreprocess, int fFirst)来自src/map/if/ifMap.c:6.72%,依然有不少时间花在 popcount 的软件实现If_WordCountOnes上;Lpk_NodeCutsOneFilter(Lpk_Cut_t * pCuts, int nCuts, Lpk_Cut_t * pCutNew)来自src/berkeley-abc/src/opt/lpk/lpkCut.c:5.47%,瓶颈在数据依赖的比较分支上。
运行 208.0B 条指令,其中 50.1B 条 Load 指令,15.4B 条 Store 指令,39.8B 条分支指令,错误预测 534.8M 次,MPKI 等于 534.8M/208.0B*1000=2.57,不低。
6. des¶
再次出现了新的热点函数:
__strcmp_avx2来自 libc:22.04%,没想到瓶颈居然又出现在了 strcmp 上;Nm_ManTableLookupId(Nm_Man_t * p, int ObjId)来自src/misc/nm/nmTable.c:21.56%,遍历一个哈希表,哈希表的每个桶是个链表,遍历链表中的元素,寻找匹配,主要瓶颈也是这个访问链表和匹配;Nm_ManTableAdd(Nm_Man_t * p, Nm_Entry_t * pEntry)来自src/misc/nm/nmTable.c:12.19%,经典的哈希表插入算法,把新元素插入到对应桶的链表当中,主要瓶颈在判断哈希表中是否已经有相同 key 的元素;Nm_ManTableLookupName(Nm_Man_t * p, char * pName, int Type)来自src/misc/nm/nmTable.c:5.78%,同样是遍历哈希表查询,只不过这次用的是字符串匹配,解释了为啥 strcmp 调用次数那么多,其实是在找哈希表的字符串匹配;Gia_ManSwiSimulate来自src/aig/gia/giaSwitch.c:5.49%,描述见上;spec_qsort:3.98%,好久不见的熟悉面孔,在 SPEC INT 2017 年代,在 505.mcf_r 中有出色表现(指瓶颈在 qsort 上,且很大一部分开销来自于调用 comparator 函数指针,开 -flto 后因为把函数指针调用内联,性能直接提升 13%)。
这次又回归到经典的哈希表数据结构,且混入了大量字符串匹配,最终瓶颈落在哈希表查询上,然后对链表的访问的空间局部性也很差。
运行 135.7B 条指令,其中有 29.7B 是 Load 指令,11.5B 是 Store 指令,23.3B 是分支指令,错误预测 372.9M 次,MPKI 等于 372.9M/135.7B*1000=2.75,依然不低,从 perf record -e branch-misses:pp 来看,错误预测主要出自 __strcmp_avx2 和 spec_qsort。
小结¶
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. twoexact | GCC 14 -O3 |
6.3 | 53.2 | 13.8 | 3.2 | 8.4 | 606.2 | 11.39 |
| 2. beem6 | GCC 14 -O3 |
10.1 | 255.5 | 57.2 | 7.3 | 40.3 | 192.0 | 0.75 |
| 3. mem | GCC 14 -O3 |
13.5 | 151.0 | 43.4 | 15.4 | 24.2 | 1213.7 | 8.03 |
| 4. vga | GCC 14 -O3 |
32.3 | 490.0 | 143.9 | 54.4 | 76.9 | 2092.8 | 4.27 |
| 5. mcml | GCC 14 -O3 |
13.6 | 208.0 | 50.1 | 15.4 | 39.8 | 534.8 | 2.57 |
| 6. des | GCC 14 -O3 |
17.0 | 135.7 | 29.7 | 11.5 | 23.3 | 372.9 | 2.75 |
综合以上六个负载,可以看到它触碰了 abc 不同地方的代码,所以热点不尽相同,有 SAT,有看不懂的一些 EDA 相关逻辑,还有带字符串匹配的哈希表查询,其中 SAT 的占比是最大的。由于 SAT 的存在,最终的 MPKI 足足有 3.87,在 SPEC INT 2026 Rate 当中仅次于 723.llvm_r,超过了 721.gcc_r 和 777.zstd_r。
734.vpr_r¶
接下来就到了 EDA 的下一步,逻辑综合后,进行布局(place)布线(route),这就是 vpr_r 干的活。该基准测试分为四个负载:
# 1. jpeg_place
vpr stratixiv_arch.timing.xml JPEG_stratixiv_arch_timing.blif --RL_agent_placement off --place_algorithm bounding_box --max_criticality 0.0 --init_t 512 --alpha_t 0.75 --exit_t 1 --router_initial_timing all_critical --routing_failure_predictor off --route_chan_width 300 --max_router_iterations 20 --router_lookahead classic --initial_pres_fac 1.0 --pres_fac_mult 2.0 --astar_fac 1.5 --router_profiler_astar_fac 1.5 --seed 3 --sdc_file JPEG_stratixiv_arch_timing.sdc --pack_verbosity 0 --netlist_verbosity 0 --base_cost_type demand_only --inner_num 4 --read_initial_place_file ref_JPEG_stratixiv_arch_timing.init.place --place
# 2. jpeg_route
vpr stratixiv_arch.timing.xml JPEG_stratixiv_arch_timing.blif --place_algorithm bounding_box --place_static_notiming_move_prob 50 25 25 --max_criticality 0.0 --router_initial_timing all_critical --routing_failure_predictor off --route_chan_width 300 --max_router_iterations 20 --router_lookahead classic --initial_pres_fac 1.0 --pres_fac_mult 2.0 --astar_fac 1.5 --router_profiler_astar_fac 1.5 --seed 3 --sdc_file JPEG_stratixiv_arch_timing.sdc --pack_verbosity 0 --netlist_verbosity 0 --base_cost_type demand_only --place_file ref_JPEG_stratixiv_arch_timing.place --analysis --route
# 3. smithwaterman_place
vpr stratixiv_arch.timing.xml smithwaterman_stratixiv_arch_timing.blif --RL_agent_placement off --place_algorithm bounding_box --max_criticality 0.0 --init_t 512 --alpha_t 0.75 --exit_t 1 --router_initial_timing all_critical --routing_failure_predictor off --route_chan_width 300 --max_router_iterations 20 --router_lookahead classic --initial_pres_fac 1.0 --pres_fac_mult 2.0 --astar_fac 1.5 --router_profiler_astar_fac 1.5 --seed 3 --sdc_file smithwaterman_stratixiv_arch_timing.sdc --pack_verbosity 0 --netlist_verbosity 0 --base_cost_type demand_only --inner_num 1.8 --read_initial_place_file ref_smithwaterman_stratixiv_arch_timing.init.place --place
# 4. smithwaterman_route
vpr stratixiv_arch.timing.xml smithwaterman_stratixiv_arch_timing.blif --place_algorithm bounding_box --place_static_notiming_move_prob 50 25 25 --max_criticality 0.0 --router_initial_timing all_critical --routing_failure_predictor off --route_chan_width 300 --max_router_iterations 20 --router_lookahead classic --initial_pres_fac 1.0 --pres_fac_mult 2.0 --astar_fac 1.5 --router_profiler_astar_fac 1.5 --seed 3 --sdc_file smithwaterman_stratixiv_arch_timing.sdc --pack_verbosity 0 --netlist_verbosity 0 --base_cost_type demand_only --place_file ref_smithwaterman_stratixiv_arch_timing.place --analysis --route
这里涉及的 Stratix IV 是经典的 Altera FPGA,如今已经是时代的眼泪了。四个负载的运行时间分别是 21s、29s、18s 和 19s,总时间 87s,reftime 是 461s,对应 5.3 分。开 -O3 -flto 后,时间降低到 19s、25s、17s 和 17s,总时间 78s,对应 5.9 分,提升显著。如果进一步开到 -O3 -flto -ljemalloc,时间进一步降低到 17s、24s、15s 和 16s,总时间 72s,对应 6.4 分,相比 -O3 提升了 20%。开 -march=native 只能带来不到 1% 的提升。
下面进行具体分析。
1. jpeg_place 和 3. smithwaterman_place¶
因为这两个负载都是做的布局(place),所以就放在一起分析了。它们的热点函数是类似的:
get_non_updateable_bb(ClusterNetId net_id, t_bb* bb_coord_new)来自src/vtr-vpr/vpr/src/place/place.cpp:jpeg_place 占比 13.98%,smithwaterman_place 占比 18.26%,遍历 pin,根据它的 x 和 y 坐标,找到 bounding box,即 xmin/xmax/ymin/ymax,主要时间花在读取 x 和 y 上;try_swap(...)来自src/vtr-vpr/vpr/src/place/place.cpp:jpeg_place 占比 12.39%,smithwaterman_place 占比 11.46%,选一个 block 挪到空位置或与另一 block 交换,评估移动后的 cost,如果新的 cost 更优,就接受;physical_tile_type(ClusterBlockId blk)来自src/vtr-vpr/vpr/src/util/vpr_utils.cpp:jpeg_place 占比 7.59%,smithwaterman_place 占比 7.75%,看起来是一些间接索引访存,先读取block_loc里的坐标,再从grid读取对应坐标的 type,这个函数会在get_non_updateable_bb和get_bb_from_scratch等地方被频繁调用;get_bb_from_scratch(ClusterNetId net_id, t_bb* coords, t_bb* num_on_edges)来自src/vtr-vpr/vpr/src/place/place.cpp:jpeg_place 占比 6.73%,smithwaterman_place 占比 2.78%,和get_non_updateable_bb类似,也是求 bounding box;malloc/_int_malloc/cfree来自 libc:jpeg_place 占比 1.62%+1.26%+1.06%=3.94%,smithwaterman_place 占比 1.76%+1.42%+1.11%=4.29%。
开 -O3 -flto 后,能看到的是 physical_tile_type 被内联了进去,节省了频繁调用函数的开销。考虑到这个内存分配和释放的时间占比,-O3 -ljemalloc 提升性能并不意外。
-O3 下,jpeg_place 执行了 273.7B 条指令,其中 Load 有 84.5B 条,Store 有 26.9B 条,分支有 51.9B 条,错误预测 781.0M 次,MPKI 等于 781.0M/273.7B*1000=2.85,不低。smithwaterman_place 执行了 245.0B 条指令,其中 Load 有 76.4B 条,Store 有 24.7B 条,分支有 45.4B 条,错误预测 661.9M 次,MPKI 等于 661.9M/245.0B*1000=2.70。在 bounding box 计算 min/max 过程中,能看到一些 cmov 指令的使用,因此实际上已经少了一些容易预测错误的分支了。在一些没有 cmov 指令的 ISA 下,可能 MPKI 还会更高。
2. jpeg_route 和 4. smithwaterman_route¶
到了布线,热点函数出现了一些不同:
ConnectionRouter<BinaryHeap>::evaluate_timing_driven_node_costs(...)来自src/vtr-vpr/vpr/src/route/connection_router.cpp:jpeg_route 占比 9.35%,smithwaterman_route 占比 6.91%,计算 cost,有一些浮点计算;ConnectionRouter<BinaryHeap>::timing_driven_add_to_heap(...)来自src/vtr-vpr/vpr/src/route/connection_router.cpp:jpeg_route 占比 9.34%,smithwaterman_route 占比 6.82%,会调用evaluate_timing_driven_node_costs计算 cost,然后插入到 Binary Heap 当中;ConnectionRouter<BinaryHeap>::timing_driven_expand_neighbours(...)来自src/vtr-vpr/vpr/src/route/connection_router.cpp:jpeg_route 占比 8.14%,smithwaterman_route 占比 4.00%,搜索算法中的一步,遍历当前结点的邻居结点,若满足条件则调用timing_driven_add_to_heap入堆;ClassicLookahead::get_expected_delay_and_cong(...)来自src/vtr-vpr/vpr/src/route/router_lookahead.cpp:jpeg_route 占比 7.86%,smithwaterman_route 占比 5.14%,计算延迟和拥塞,也有不少浮点计算;BinaryHeap::get_heap_head()来自src/vtr-vpr/vpr/src/route/binary_heap.cpp:jpeg_route 占比 3.14%,smithwaterman_route 占比 1.64%,就是经典的最小二叉堆的实现,获取最小值,用的是浮点数做比较;malloc/_int_malloc/cfree来自 libc:jpeg_route 占比 1.10%+1.02%+0.78%=2.90%,smithwaterman_route 占比 1.62%+1.49%+1.08%=4.19%。
虽然不清楚具体算法,但看起来,就像是在做一些 cost 计算,然后通过 BinaryHeap 选择最小的 cost 去做一些扩展,有点类似搜索算法。
开 -O3 -flto 后,能看到的是 evaluate_timing_driven_node_costs 和 timing_driven_add_to_heap 被内联进 timing_driven_expand_neighbours,节省了频繁调用函数的开销,这个函数的时间占比提升到 jpeg_route 的 21.40% 和 smithwaterman_route 的 12.48%,类似的事情应该也发生在 get_expected_delay_and_cong 身上。考虑到这个内存分配和释放的时间占比,-O3 -ljemalloc 提升性能并不意外。
-O3 下,jpeg_route 执行了 424.1B 条指令,其中 Load 有 130.6B,Store 有 50.6B,分支有 79.0B 条,错误预测 1094.2M 次,MPKI 等于 1094.2M/424.1B*1000=2.58,不低。smithwaterman_route 执行了 305.8B 条指令,其中 Load 有 91.0B 条,Store 有 36.0B 条,分支有 59.4B 条,错误预测 609.3M 次,MPKI 等于 609.3M/305.8B*1000=1.99。
小结¶
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. jpeg_place | GCC 14 -O3 |
21 | 273.7 | 84.5 | 26.9 | 51.9 | 781.0 | 2.85 |
| 1. jpeg_place | GCC 14 -O3 -flto |
19 | 247.0 | 69.2 | 22.2 | 47.8 | 774.2 | 3.13 |
| 1. jpeg_place | GCC 14 -O3 -ljemalloc |
19 | 261.5 | 81.9 | 25.1 | 47.9 | 764.5 | 2.92 |
| 2. jpeg_route | GCC 14 -O3 |
29 | 424.1 | 130.6 | 50.6 | 79.0 | 1094.2 | 2.58 |
| 2. jpeg_route | GCC 14 -O3 -flto |
26 | 356.6 | 103.2 | 33.5 | 66.3 | 1075.5 | 3.02 |
| 2. jpeg_route | GCC 14 -O3 -ljemalloc |
28 | 411.5 | 127.9 | 48.8 | 74.9 | 1080.0 | 2.62 |
| 3. smithwaterman_place | GCC 14 -O3 |
18 | 245.0 | 76.4 | 24.7 | 45.4 | 661.9 | 2.70 |
| 3. smithwaterman_place | GCC 14 -O3 -flto |
17 | 222.1 | 63.1 | 20.8 | 21.8 | 662.7 | 2.98 |
| 3. smithwaterman_place | GCC 14 -O3 -ljemalloc |
17 | 232.9 | 73.8 | 23.0 | 41.4 | 648.7 | 2.78 |
| 4. smithwaterman_route | GCC 14 -O3 |
19 | 305.8 | 91.0 | 36.0 | 59.4 | 609.3 | 1.99 |
| 4. smithwaterman_route | GCC 14 -O3 -flto |
17 | 264.3 | 72.9 | 25.5 | 51.5 | 590.9 | 2.24 |
| 4. smithwaterman_route | GCC 14 -O3 -ljemalloc |
18 | 293.6 | 88.4 | 34.2 | 55.3 | 594.7 | 2.03 |
734.vpr_r 的负载分为两部分,place 和 route,其中 place 主要在做 bounding box 的计算,route 主要在做搜索和优化。开 -flto 和 -ljemalloc 后有明显的性能提升,主要是靠内联了热点函数以及更快的内存分配。整体指令数为 1254B,分支指令数 237B,MPKI 是 2.51,处于中游偏高的水平。
735.gem5_r¶
gem5 是大家很熟悉的模拟器了,在 GEM5 里跑 SPEC CPU 2017 养活了很多博士生,这下终于完成闭环,在 GEM5 里跑 SPEC INT 2026 的 GEM5,自己跑自己。当然,735.gem5_r 的 workload 就不是 SPEC CPU 2026 了,没有继续套娃,而是跑的 RISC-V Linux 内核,以及生成访存序列对内存子系统进行测试。这也是唯一一个看到函数名就知道函数来自哪个文件的项目了,实在太熟悉了。包括如下四个负载:
# 1. o3
gem5sim --stats-file=run_riscv_boot.py_o3_10_--max-ticks_10_000_000_000_stats.stats.txt run_riscv_boot.py o3 10 --max-ticks 10_000_000_000
# 2. timing
gem5sim --stats-file=run_riscv_boot.py_timing_4_--max-ticks_20_000_000_000.stats.txt run_riscv_boot.py timing 4 --max-ticks 20_000_000_000
# 3. traffic_21
gem5sim --stats-file=synthetic_traffic.py_LinearGenerator_21.stats.txt synthetic_traffic.py LinearGenerator 21
# 4. traffic_74_ruby
gem5sim --stats-file=synthetic_traffic.py_LinearGenerator_74_--ruby.stats.txt synthetic_traffic.py LinearGenerator 74 --ruby
运行时间分别为 16s、21s、21s 和 31s,总时间 89s,reftime 是 487s,对应 5.4 分。各种编译选项的优化效果:
- 开
-O3 -flto后运行时间降为 15s、20s、20s 和 29s,共 84s,对应 5.8 分,相比-O3提升 6%。对四个负载都有加速效果。 - 开
-O3 -flto -ljemalloc后降为 14s、18s、16s 和 26s,共 74s,对应 6.6 分,相比-O3提升 20%。对四个负载都有比较显著的加速效果。 - 开
-O3 -march=native -flto -ljemalloc后 12s、18s、16s 和 26s,共 72s,对应 6.8 分,相比-O3提升 24%。仅对第一个负载有加速效果。
看到这个性能提升的幅度,结合前面的经验,已经可以预估一下后面会见到的瓶颈大概是什么类型了。
1. o3¶
第一个负载是用 O3 CPU 模拟 RISC-V Linux 内核启动,热点函数如下:
malloc/_int_malloc/cfree/_int_free_chunk/operator new来自 libc/libstdc++:4.78%+3.46%+3.29%+1.35%+1.16%=13.29%,这个比例无敌了,不过确实,Gem5 有大量的动态内存分配,比如各种内存请求,都要 new 一个 Packet 出来;gem5::TimeBuffer<*>::advance()来自src/gem5/cpu/timebuf.hh:3.05%+2.43%+2.39%+2.28%+1.98%=12.13%,用于在各流水线级之间传递数据,维护一个滚动的时间窗口,主要的时间花在了rep stos或用 SSE 指令movups对内存进行初始化,还有调用构造/析构函数,涉及到一些引用计数的更新;gem5::o3::IEW::tick()来自src/gem5/cpu/o3/iew.cc:3.32%,IEW 代表 Issue Execute Writeback,后端各执行单元的时序在这里模拟,瓶颈主要是rep stos指令,用于初始化数据。
其他就是很多零散的函数了,每个函数的耗时都不高。开启 -O3 -flto 后,热点函数变为:
std::_Function_handler<void (), gem5::o3::CPU::CPU(gem5::BaseO3CPUParams const&)::{lambda()#1}>::_M_invoke(std::_Any_data const&):20.80% 实际上是tickEvent([this]{ tick(); }, "O3CPU tick", false, Event::CPU_Tick_Pri)当中调用tick()的 lambda,就是整个 O3 CPU 各种组件的单步模拟被融合到了一个巨大的函数里,仔细看里面的热点指令,其实还是gem5::TimeBuffer<*>::advance()相关的比较多;gem5::o3::IEW::tick()来自src/gem5/cpu/o3/iew.cc:8.58%,描述见上;malloc/_int_malloc/cfree/_int_free_chunk/operator new来自 libc/libstdc++:5.55%+3.88%+3.72%+1.45%+1.22%=15.83%,随着其余部分被优化,内存分配的瓶颈更加明显了。
进一步开启 -O3 -flto -ljemalloc 后,内存分配时间减少,热点函数:
std::_Function_handler<void (), gem5::o3::CPU::CPU(gem5::BaseO3CPUParams const&)::{lambda()#1}>::_M_invoke(std::_Any_data const&):23.20%,描述见上;gem5::o3::IEW::tick()来自src/gem5/cpu/o3/iew.cc:9.19%,描述见上;gem5::o3::Commit::commit()来自src/gem5/cpu/o3/commit.cc:4.56%,模拟 CPU 的 Commit 阶段;malloc/_int_malloc/cfree/_int_free_chunk/operator new/operator delete来自 libjemalloc:3.12%+1.02%+0.53%=4.67%,明显变少。
开启 -O3 -march=native 带来的效果是,用 memset 调用取代了之前的 rep stos,进而可以用更加高效的 AVX2 版本的 memset 来进行初始化,优化了 gem5::TimeBuffer<*>::advance() 的性能。
-O3 下,执行 211.1B 条指令,其中有 69.9B 条 Load 指令,31.7B 条 Store 指令,43.2B 条分支指令,错误预测 175.5M 次,MPKI 等于 175.5M/211.1B*1000=0.83,比较低。
2. timing¶
第二个负载则是把 O3 换成了 TimingSimpleCPU,相比 O3 模拟的复杂度低很多,此时主要的瓶颈挪到了 RISC-V 架构相关的代码、缓存模拟,以及内存分配上:
cfree/malloc/operator new来自 libc:5.92%+4.56%+1.55%=12.03%,依然有很多内存分配的瓶颈;gem5::RiscvISA::Decoder::decode(ExtMachInst mach_inst, Addr addr)来自src/gem5/arch/riscv/decoder.cc:8.97%,实现 RISC-V 指令集的 Decode,有很大一部分实现是自动生成的,在src/gem5/arch/riscv/generated/decode-method.cc.inc文件里,这里为了加速 Decode,用了一个decode_cache::InstMap<ExtMachInst>(实际上就是std::map<ExtMachInst, StaticInstPtr>)来加速,因此大部分的时间其实是在用红黑树实现的缓存中寻找已经 Decode 过的指令编码;gem5::BaseTags::findBlock(Addr addr, bool is_secure)来自src/gem5/mem/cache/tags/base.cc:5.19%,用来实现组相连的 tag 比较,就是一个循环比较 tag 找匹配的算法,主要瓶颈就是 tag 比对;gem5::PMAChecker::check(const RequestPtr &req)来自src/gem5/arch/riscv/pma_checker.cc:4.86%,实现 RISC-V 的 PMA 检查,属于 MMU 的一部分,逻辑很简单,就是循环判断一下请求地址是否属于某个 Uncacheable 地址区间,如果是,就标记 STRICT_ORDER,避免重排;gem5::RiscvISA::ISA::readMiscReg(RegIndex idx)来自src/gem5/arch/riscv/isa.cc:3.34%,用于读取 RISC-V 的 CSR,GCC 这次是用若干 branch 来分别进入不同的 case 处理代码;gem5::BaseCache::access(PacketPtr pkt, CacheBlk *&blk, Cycles &lat, PacketList &writebacks)来自src/gem5/mem/cache/base.cc:2.84%,用于模拟缓存的访问;gem5::PMP::pmpCheck(const RequestPtr &req, BaseMMU::Mode mode, RiscvISA::PrivilegeMode pmode, ThreadContext *tc, Addr vaddr)来自src/gem5/arch/riscv/pmp.cc:2.66%,实现 RISC-V 的 PMP 检查,属于 MMU 的一部分,扫描 PMP 配置,逐个判断是否匹配。
开 -O3 -flto 后,readMiscReg 被内联。开 -O3 -flto -ljemalloc 后,内存分配的开销降低到 4.48%+1.34%=5.82%。-march=native 影响比较小。
-O3 下,执行 333.9B 条指令,其中有 113.9B 条 Load 指令,57.8B 条 Store 指令,69.8B 条分支指令,错误预测 202.9M 次,MPKI 等于 202.9M/333.9B*1000=0.61,比较低。
3. traffic_21¶
热点函数:
cfree/malloc/operator new来自 libc:6.01%+4.62%+1.44%+1.40%=13.47%,依然有很多内存分配的瓶颈;gem5::SnoopFilter::lookupRequest(const Packet* cpkt, const ResponsePort& cpu_side_port)来自src/gem5/mem/snoop_filter.c:5.93%,在总线上对 Snoop 请求进行 Filter,减少缓存一致性开销;它用一个std::map来维护状态,查询和更新耗费了不少时间,是主要的瓶颈;gem5::AddrRange::removeIntlvBits(Addr a)来自src/gem5/base/addr_range.hh:3.39%,针对地址的 interleaving,进行一系列位运算,把 interleaving 的那部分比特去掉,保留其他的,具体实现方法是,找到要去掉的比特的位置,从小到大进行排序,然后把要保留的比特分段插入到结果当中,主要的瓶颈是src/gem5/base/bitfield.hh的ctz64()函数,GCC 14 会忠实地生成循环,GCC 15 会生成rep bsfq指令,如果进一步给 GCC 15 开-mbmi,会生成tzcnt指令,应该会变快一些(Godbolt);gem5::BaseTags::findBlock(Addr addr, bool is_secure)来自src/gem5/mem/cache/tags/base.cc:3.18%,描述见上。
开启 -O3 -flto 后,热点函数中 removeIntlvBits 消失,时间转移到了 gem5::memory::DRAMInterface::decodePacket 和 gem5::memory::DRAMInterface::chooseNextFRFCFS。开 -O3 -flto -ljemalloc 后,内存分配的开销降低到 4.08%+1.39%=5.47%。-march=native 影响比较小。
-O3 下,执行 226.4B 条指令,其中有 65.5B 条 Load 指令,31.3B 条 Store 指令,50.8B 条分支指令,错误预测 749.3M 次,MPKI 等于 749.3M/226.4B*1000=3.31,明显变高。
4. traffic_74_ruby¶
相比 traffic_21,traffic_74_ruby 开启了 ruby(不是那个 ruby 编程语言),因此瓶颈来到了 gem5::ruby 相关:
cfree/malloc/operator new来自 libc:4.43%+3.52%+1.29%+0.98%=10.22%,依然有很多内存分配的瓶颈;gem5::ruby::Cache_Controller::processNextState(Cache_TBE*& m_tbe_ptr, Cache_CacheEntry*& m_cache_entry_ptr, Addr addr)来自src/gem5/mem/ruby/protocol/Cache_Controller.cc:4.44%,维护缓存的状态机,还挺复杂的;gem5::ruby::NetDest::intersectionIsNotEmpty(const NetDest& other_netDest)来自src/gem5/mem/ruby/common/NetDest.cc:4.03%,做的是一些 std::bitset 的与操作,这也是主要的瓶颈;gem5::ruby::MessageBuffer::isReady(Tick current_time)来自src/gem5/mem/ruby/network/MessageBuffer.cc:3.94%,维护了消息队列,判断当前时间是否有 ready 的消息;gem5::ruby::Cache_Controller::getDirEntry(const Addr& param_addr)来自src/gem5/mem/ruby/protocol/Cache_Controller.cc:3.80%,根据地址找到 cache 对应的 entry,对std::map调用operator []。
开启 -O3 -flto 后,gem5::ruby::NetDest::intersectionIsNotEmpty 被内联到 gem5::ruby::WeightBased::route 函数里,成为占时间最多的函数,占 6.45%。开启 -O3 -flto -ljemalloc 后,内存分配开销降低到 3.01%+0.83%=3.84%。-march=native 影响比较小。
-O3 下,执行 391.5B 条指令,其中有 103.2B 条 Load 指令,54.4B 条 Store 指令,82.1B 条分支指令,错误预测 1246.0M 次,MPKI 等于 1246.0M/391.5B*1000=3.18,依然较高。
小结¶
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. o3 | GCC 14 -O3 |
16 | 211.1 | 69.9 | 31.7 | 43.2 | 175.5 | 0.83 |
| 1. o3 | GCC 14 -O3 -ljemalloc |
15 | 189.5 | 65.0 | 28.0 | 37.0 | 204.8 | 1.08 |
| 1. o3 | GCC 14 -O3 -flto |
15 | 193.8 | 65.0 | 27.4 | 39.6 | 163.5 | 0.84 |
| 2. timing | GCC 14 -O3 |
21 | 333.9 | 113.9 | 57.8 | 69.8 | 202.9 | 0.61 |
| 2. timing | GCC 14 -O3 -ljemalloc |
19 | 301.8 | 106.9 | 51.8 | 60.5 | 202.9 | 0.67 |
| 2. timing | GCC 14 -O3 -flto |
21 | 324.4 | 111.6 | 56.2 | 67.0 | 194.7 | 0.60 |
| 3. traffic_21 | GCC 14 -O3 |
21 | 226.4 | 65.5 | 31.3 | 50.8 | 749.3 | 3.31 |
| 3. traffic_21 | GCC 14 -O3 -ljemalloc |
18 | 198.0 | 59.2 | 26.1 | 42.7 | 723.3 | 3.65 |
| 3. traffic_21 | GCC 14 -O3 -flto |
20 | 216.1 | 62.8 | 29.2 | 48.1 | 745.4 | 3.45 |
| 4. traffic_74_ruby | GCC 14 -O3 |
31 | 391.5 | 103.2 | 54.4 | 82.1 | 1246.0 | 3.18 |
| 4. traffic_74_ruby | GCC 14 -O3 -ljemalloc |
28 | 363.6 | 97.1 | 49.5 | 74.1 | 1200.3 | 3.30 |
| 4. traffic_74_ruby | GCC 14 -O3 -flto |
29 | 361.3 | 96.7 | 48.6 | 75.5 | 1204.0 | 3.33 |
735.gem5_r 四个测试跑的是挺不一样的代码路径,第一个 o3 的主要瓶颈就是 O3CPU,第二个 timing 的主要瓶颈是 RISC-V 指令集相关的代码,第三个 traffic_21 主要是缓存和内存控制器,而 traffic_74_ruby 主要是用 ruby 模拟的内存子系统。由于 gem5 高度模块化,有些时候一些可以 inline 函数没有被 inline,所以 -flto 可以带来不错的性能提升。此外,gem5 很喜欢动态分配内存,运行过程中有很多动态产生的对象,比如 Packet 等等,所以用 -ljemalloc 能带来不错的提升。-march=native 确实不太有用武之地。
整体下来,执行 1164B 条指令,其中有 246B 条分支指令,MPKI 等于 2.05,不算高,主要由后两个 traffic 负载贡献。
750.sealcrypto_r¶
sealcrypto 做的是同态加密,只有一个负载做测试:
运行时间 108s,reftime 是 536s,对应 5.0 分。
很奇特的是,开 -O3 -flto 性能倒退,-O3 -flto -ljemalloc 性能没啥变化,开 -O3 -march=native -flto -ljemalloc 性能进一步倒退。但是,LLVM 22 异军突起,以接近两倍的性能超越了 GCC 和 LLVM 的其他版本,仅用 50.5s 跑完,对应 10.6 分。可以说,完全就靠 750.sealcrypto_r,才让 LLVM 22 在 SPEC INT 2026 整体性能上超越了 GCC 14。下面就来看看是怎么一回事。
首先还是对 -O3 的 GCC 14 进行热点分析:
seal::util::DWTHandler::transform_to_rev(ValueType *values, int log_n, const RootType *roots, const ScalarType *scalar = nullptr)来自src/seal/util/dwthandler.h:25.65%,这里 DWT 是离散小波变换 Discrete Wavelet Transform,上一次看到小波变换还是 Ghost Hunter,没想到在这里又遇到了,具体到指令上,就是一堆 imul/add/shr/shl 的运算指令;seal::util::DWTHandler::transform_from_rev(ValueType *values, int log_n, const RootType *roots, const ScalarType *scalar = nullptr)来自src/seal/util/DWTHandler.h:16.58%,应该是 DWT 的逆过程,计算模式基本一样;seal::util::multiply_uint64_generic(T operand1, S operand2, unsigned long long *result128)来自src/seal/util/uintarith.h:11.60%,实现了 64 位乘以 64 位得到 128 位结果的乘法,也是一堆乘法、加法和位运算;seal::util::dot_product_mod(const uint64_t *operand1, const uint64_t *operand2, size_t count, const Modulus &modulus)来自src/seal/util/uintarithsmallmod.cpp:11.48%,实现的是点乘后取模的操作,调用multiply_accumulate_uint64函数进行乘法和累加,最后用barrett_reduce_128进行取模;seal::util::dyadic_product_coeffmod(ConstCoeffIter operand1, ConstCoeffIter operand2, size_t coeff_count, const Modulus &modulus, CoeffIter result)来自src/seal/util/polyarithsmallmod.cpp:9.08%,实现的是 element wise 的模乘;seal::util::BaseConverter::fast_convert_array(ConstRNSIter in, RNSIter out, MemoryPoolHandle pool)来自src/seal/util/rns.cpp:5.88%,这里的 RNS 应该是 Residue Number System 的缩写,指令上还是大量的 imul/add 等运算;seal::util::RNSTool::sm_mrq(ConstRNSIter input, RNSIter destination, MemoryPoolHandle pool)来自src/seal/util/rns.cpp:5.40%,不确定在做什么,也是大量的运算。
总而言之,既然是密码学,就会有大量的整数运算,其中有不少的乘法和位运算,在素数域下做各种操作。执行指令数足足有 3113.4B,其中有 385.7B 条 Load 指令,161.3B 条 Store 指令,78.5B 条分支指令,错误预测 450.0M 次,MPKI 只有 450.0M/3113.4B*1000=0.14,全场最低,甚至低于 714.cpython_r,同时 IPC 全场最高,达到了 5.09。从 Top down 分析来看,80.7% 属于 Retiring,13.5% 属于 Backend Bound,说明处理器基本在全速跑指令。
开了 -O3 -march=native 后,确实生成了不少 AVX2 指令,但看下来,生成的指令序列还是挺复杂的,有大量的 vpunpcklqdq/vpunpckhqdq/vpermq/vpblendvb/vperm2i128 等指令,并没有在进行计算,而是在不断地倒腾向量寄存器里数据的位置,见 Godbolt。此时指令数降低到 2757.7B,其中有 370.0B 条 Load 指令,126.7B 条 Store 指令,268.6B 条 256 位整数向量指令(int_vec_retired.256bit 性能计数器),76.1B 条分支指令,错误预测 431.0M 次,MPKI 等于 431.0M/2757.7B*1000=0.16。虽然指令数减少了,但 IPC 降低更多,最后性能反而倒退,实际从 108s 增加到 116s。原来的 -O3 版本虽然每次只处理一个元素,但指令的并行度更高,IPC 弥补了指令数多的劣势。GCC 16 的 -march=native 就好多了,生成的指令少了很多数据重排的指令,基本都是 vpaddq/vpsubq/vpmuludq/vpsllq/vpsrlq 这类计算指令,向量化方法不一样,见 Godbolt。
那么,LLVM 22 做了什么优化呢?执行的指令数直接降低到 1213.6B,其中 Load 指令有 302.8B,Store 指令有 109.2B,分支只有 57.2B,错误预测 1093.9M,MPKI 等于 1093.9M/1213.6B*1000=0.90。以 seal::util::DWTHandler::transform_to_rev 为例,可以看到:seal 为了实现 64 位乘 64 位到 128 位的乘法,它自己实现了这个过程,不仅在 seal::util::multiply_uint64_generic 中有实现,实际上也内联到了 seal::util::DWTHandler::transform_to_rev 当中;GCC 14 忠实地实现了这个算法,因此指令数很多(见 Godbolt);但其实,AMD64 的 mul 指令本来就是一个 64 位乘 64 位得到 128 位的乘法,所以 LLVM 22 直接识别出这段代码做的事情,然后编译成了 mul 指令(见 Godbolt,甚至如果开了 BMI2 扩展,还有 mulx 指令可以用),而且这种 64 位乘法保留高位的指令在各种 ISA 都挺常见的,比如 ARM64 的 umulh,RISC-V 的 mulhu,LoongArch 的 mulh.du。当然,seal 的源码其实已经考虑了这个问题,在编译器支持的情况下,直接用 __int128 来完成这件事情。类似的事情在 706.stockfish_r 的 1to6_classical 中也出现了。然而,这类依赖编译器行为或具体指令集扩展的代码,由于 SPEC CPU 2026 的编译器中立性,都被去掉了,都会回落到最通用的写法上。此时,就只能依赖编译器去自己识别和优化了。
但这样某种意义上也无法反映真实场景中应用的优化情况,因为很多应用已经实际上和处理器的指令集扩展/编译器扩展共进化,实现的时候,脑子里是默认有这些东西,再去做的调优,甚至会写一些指令集相关的优化,用一些 intrinsics,比如原版 stockfish 就有针对 AVX512/AVX2/SSSE3/NEON_DOTPROD/LASX/LSX 的优化。到最后,就是编译器又实现各种 pass,识别程序里的 fallback generic 代码,再映射回高效的实现。其实类似的事情之前就出现过,网上用来证明编译器很聪明的一个例子,就是说识别 popcount 的循环,直接翻译成 popcnt 指令,然而很多程序直接用 __builtin_popcount 而不会真的去手写,这次只不过是换了个 pattern 罢了。当然,好消息是,C++20 引入了 std::popcount,可以一定程度避免类似的情况发生,只是来得太晚了。
相比之下,Geekbench 对这类指令集扩展的优化就比较持开放态度,愿意针对指令集扩展进行针对性的优化,比如经典引入 AMX/SME 对分数的巨大影响,当然这也让它被人骂 AppleBench,只能说见仁见智了。
与此同时,LLVM 22 明显生成了更多的错误预测,用 perf record -e branch-misses:pp 找了一下问题,有 46.81% 的错误预测都出在 sm_mrq 函数当中,主要问题出在它内联的来自 src/seal/util/uintarithsmallmod.h 的 multiply_uint_mod 函数,它最后有一步,如果结果大于模 p,就要减去 p:SEAL_COND_SELECT(tmp2 >= p, tmp2 - p, tmp2),学过 Montgomery Multiplication 的话应该很熟悉,因为它只能保证优化后的计算结果与真实结果在模 p 结果下相等,但是范围会更大,最大不会超过两倍的 p,所以需要最后做一个处理,这里是 Barrett Reduction,原理是类似的。这个 SEAL_COND_SELECT 宏是这么定义的,此处 SEAL_AVOID_BRANCHING 没有被定义,实际用的是上面的 ternary operator:
// Conditionally select the former if true and the latter if false
// This is a temporary solution that generates constant-time code with all compilers on all platforms.
#ifndef SEAL_AVOID_BRANCHING
#define SEAL_COND_SELECT(cond, if_true, if_false) (cond ? if_true : if_false)
#else
#define SEAL_COND_SELECT(cond, if_true, if_false) \
((if_false) ^ ((~static_cast<uint64_t>(cond) + 1) & ((if_true) ^ (if_false))))
#endif
LLVM 22 使用分支实现上面的逻辑,只有在 tmp2 >= p 的情况下才会进行 tmp2 - p 的计算,否则就是计算 tmp2 - 0,指令序列大概是这样:
# 初始化 rax = 0
mov $0x0,%eax
# 比较 tmp2(rcx) 和 p(r10)
cmp %r10,%rcx
# 如果 p > tmp2,跳转到下面的 label:
jb label
# rax = r10,即 rax = p
mov %r10,%rax
label:
# 计算 tmp2 - rax
sub %rax,%rcx
如此计算确实少了,但是分支预测错误率又很高,除非硬件上做 Short Forward Branch 转 Predication 的逻辑(详见 浅谈乱序执行 CPU(三:前端))。GCC 14 是这么实现的:
# tmp2 保存在 rax 寄存器,p 保存在 rdx 寄存器
# rcx = rax,即 rcx = tmp2
mov %rax,%rcx
# rcx -= rdx,即 rcx = tmp2 - p
sub %rdx,%rcx
# 比较 tmp2 和 p
cmp %rdx,%rax
# 如果 tmp2 >= p,则 rax = rcx = tmp2 - p,否则 rax 保持原来的 tmp2 不变
cmovae %rcx,%rax
GCC 14 通过 cmov 指令避免了大量的错误预测,就是这点差别,造成了 LLVM 22 相比 GCC 14 巨大的 MPKI 差距。如果 LLVM 22 在这里选择用 cmov,那性能还能继续往上提一提。事实上,LLVM 22 确实也能在很多地方用 cmov 代替分支,但为什么在这个具体场景下,最后放弃了这个优化,还需要进一步的研究。
LLVM 22 开 -O3 -march=native 后分支预测有所改善,错误预测从 1093.9M 降到 612.7M(MPKI=0.54)。不过改进不在 sm_mrq 函数(它依然用分支而非 cmov),而是 DWTHandler::transform_from_rev 和 RNSTool::fastbconv_sk。这两个函数同样有 SEAL_COND_SELECT 宏,但此时 cond ? if_true : if_false 被编译成了 vpcmpgtq + vblendvpd,相当于把 cmov 向量化了。标量时 LLVM 22 不愿意用 cmov,为了向量化反而自己给实现了出来。
750.sealcrypto_r 在不同编译器和编译选项下的情况如下:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|
GCC 14 -O3 |
108 | 3113.4 | 385.7 | 161.3 | 78.5 | 450.0 | 0.14 |
GCC 14 -O3 -march=native |
116 | 2757.7 | 370.0 | 126.7 | 76.1 | 431.0 | 0.16 |
GCC 15 -O3 |
106.4 | 3071.3 | 379.1 | 161.4 | 80.0 | 416.1 | 0.14 |
GCC 15 -O3 -march=native |
117.7 | 2701.9 | 379.4 | 130.6 | 77.6 | 406.9 | 0.15 |
GCC 16 -O3 |
105.9 | 3020.1 | 381.1 | 158.5 | 80.7 | 430.3 | 0.14 |
GCC 16 -O3 -march=native |
99.3 | 2492.3 | 328.0 | 123.2 | 81.8 | 433.3 | 0.17 |
LLVM 22 -O3 |
50.5 | 1213.6 | 302.8 | 109.2 | 57.2 | 1093.9 | 0.90 |
LLVM 22 -O3 -march=native |
48.2 | 1126.0 | 299.2 | 108.7 | 53.4 | 612.7 | 0.54 |
753.ns3_r¶
753.ns3_r 和 710.omnetpp_r 做的事情类似,也是网络中的离散事件模拟器。它包括这些负载:
# 1. mobile
ns3_r mobile-scenario --simTimeMinutes=3 --RngSeed=1 --RngRun=1
# 2. tcp
ns3_r tcp-pacing --simulationEndTime=500 --useEcn=false --RngSeed=1 --RngRun=1
# 3. lena
ns3_r lena-radio-link-failure --numberOfEnbs=2 --interSiteDistance=800 --simTime=200 --RngSeed=1 --RngRun=1
# 4. dctcp
ns3_r dctcp-example --enableSwitchEcn=true --flowStartupWindow=0.4 --convergenceTime=0.4 --measurementWindow=0.4 --RngSeed=1 --RngRun=1
# 5. wifi_mixed
ns3_r wifi-mixed-network --isUdp=0 --payloadSize=3072 --simulationTime=25 --RngSeed=1 --RngRun=1
# 6. wifi_eht
ns3_r wifi-eht-network --simulationTime=0.2 --frequency=5 --useRts=1 --minExpectedThroughput=6 --maxExpectedThroughput=547 --RngSeed=1 --RngRun=1
六个负载的耗时分别为 18s、15s、3s、19s、23s 和 14s,一共 92s,reftime 是 613s,对应 6.7 分。各编译选项对性能影响:
-O3 -flto:时间降到 16s、14s、3s、17s、19s 和 13s,一共 82s,对应 7.5 分,相比-O3提升 12% 的性能;-O3 -flto -ljemalloc:时间进一步降到 14s、12s、3s、13s、18s 和 11s,一共 71s,对应 8.6 分,相比-O3 -flto又提升 15% 性能。
都有巨大提升,只有 -march=native 影响很小,仅 0.5%。下面来进行具体的分析。
1. mobile¶
热点分析:
cfree/malloc/_int_malloc/_int_free_chunk/operator new来自 libc/libstdc++:6.99%+5.66%+4.15%+1.83%+1.81%=20.44%,又是内存分配密集型应用;ns3::LteMiErrorModel::GetTbDecodificationStats(const SpectrumValue& sinr, const std::vector<int>& map, uint16_t size, uint8_t mcs, HarqProcessInfoList_t miHistory)来自src/ns-3.38/src/lte/model/lte-mi-error-model.cc:9.57%,首先是一个循环,带有一些浮点运算,做一些累加和乘加操作,然后是一段二分查找,看起来主要瓶颈是在二分查找上面,此外在函数开头还会调用下面的Mib函数;ns3::LteMiErrorModel::Mib(const SpectrumValue& sinr, const std::vector<int>& map, uint8_t mcs)来自src/ns-3.38/src/lte/model/lte-mi-error-model.cc:4.39%,又是一些浮点运算,不知道在算什么,还会调用ns3::SpectrumValue::operator[],做一些浮点比较;ns3::LteMiErrorModel::MappingMiBler(double mib, uint8_t ecrId, uint16_t cbSize)来自src/ns-3.38/src/lte/model/lte-mi-error-model.cc:3.53%,主要的开销是浮点运算、调用 erf 函数和做一些查表,__erf函数占了总时间的 1.63%;ns3::MapScheduler::Insert(const Event& ev)来自src/ns-3.38/src/core/model/map-scheduler.cc:2.66%,主要瓶颈在对std::map红黑树的插入。
首先能看到的是,又是一个内存分配密集型应用。开了 -O3 -flto 后,GetTbDecodificationStats 把 Mib 内联了进去,时间占比提升到 12.68%,但还是内存分配占了最多的时间:7.82%+6.22%+4.51%+1.90%=20.45%。进一步开 -O3 -flto -ljemalloc,内存分配的时间占比终于降低到 6.23%+1.78%=8.01%,其实还是挺高的。
比较少见的是,作为 SPEC INT 2026 Rate 的一员,mobile 涉及不少浮点运算,还包括一些对 libm 的调用,比如 erf/atan2/pow/log,但实际瓶颈又是内存分配,算是半步踏入了 SPEC FP 2026 的领域,但又因为大量 libc 调用而被拉了回来。
-O3 下,执行指令 257.2B,其中 Load 指令有 66.6B,Store 指令有 35.4B,分支指令有 54.4B,错误预测 631.1M,MPKI 等于 631.1M/257.2B*1000=2.45,并不低。从 perf record -e branch-misses:pp 来看,主要的错误预测来自于内存分配器以及 std::map 红黑树的插入算法。
2. tcp¶
第二个负载测的又是不一样的代码了,这次的热点函数:
cfree/malloc/_int_malloc/_int_free_chunk/operator new来自 libc/libstdc++:7.02%+5.20%+3.68%+2.29%+1.56%=19.75%,又是内存分配密集型应用;ns3::TcpTxBuffer::NextSeg(SequenceNumber32* seq, SequenceNumber32* seqHigh, bool isRecovery)来自src/ns-3.38/src/internet/model/tcp-tx-buffer.cc:4.35%,是一个 TCP 协议栈实现,这里做的是 RFC 6675 SACK 的部分,想起来之前设计的 TCP 实验,这里主要的瓶颈是循环里对 sequence number 的更新;ns3::MapScheduler::Insert(const Event& ev)来自src/ns-3.38/src/core/model/map-scheduler.cc:4.05%,描述见上;__do_dyncast/__dynamic_cast来自 libstdc++:1.80%+1.55%=3.35%。
-O3 下,执行指令 204.8B,其中 Load 指令有 63.5B,Store 指令有 41.4B,分支指令有 45.4B,错误预测 148.1M,MPKI 等于 148.1M/204.8B*1000=0.72,比较低。从 perf record -e branch-misses:pp 来看,主要的错误预测来自于内存分配器以及 std::map 红黑树的插入和删除算法。
3. lena¶
第三个负载测的又是不一样的代码了,这次的热点函数:
cfree/malloc/_int_malloc/_int_free_chunk/operator new来自 libc/libstdc++:7.78%+6.13%+3.13%+2.08%+1.52%=20.64%,又是内存分配密集型应用;ns3::MapScheduler::Insert(const Event& ev)来自src/ns-3.38/src/core/model/map-scheduler.cc:2.41%,描述见上;__do_dyncast/__dynamic_cast来自 libstdc++:1.73%+0.82%=2.55%。
-O3 下,执行指令 46.6B,其中 Load 指令有 14.2B,Store 指令有 9.6B,分支指令有 10.4B,错误预测 53.4M,MPKI 等于 53.4M/46.6B*1000=1.15,不高。从 perf record -e branch-misses:pp 来看,主要的错误预测来自于内存分配器以及 std::map 红黑树的插入和删除算法。
4. dctcp¶
第四个负载测的又是不一样的代码了,这次的热点函数:
cfree/malloc/_int_malloc/_int_free_chunk/operator new来自 libc/libstdc++:6.30%+5.56%+4.03%+1.53%+1.43%+1.12%=40.61%,又是内存分配密集型应用;ns3::MapScheduler::Insert(const Event& ev)来自src/ns-3.38/src/core/model/map-scheduler.cc:6.94%,描述见上。
-O3 下,执行指令 225.3B,其中 Load 指令有 71.1B,Store 指令 43.9B,分支指令有 52.3B,错误预测 295.8M,MPKI 等于 295.8M/225.3B*1000=1.31,略高一点。从 perf record -e branch-misses:pp 来看,主要的错误预测来自于内存分配器以及 std::map 红黑树的插入和删除算法。
5. wifi_mixed¶
热点函数就不列举了,基本还是内存分配,外加 ns3::TcpTxBuffer::NextSeg。-O3 下,执行指令 291.8B,其中 Load 指令有 88.8B,Store 指令有 52.7B,分支指令有 66.5B,错误预测 201.9M,MPKI 等于 201.9M/291.8B*1000=0.69,不高,错误预测的主要来源除了内存分配器和 std::map,还多了一个 __memcmp_avx2_movbe。
6. wifi_eht¶
热点函数除了内存分配,多了 ns3::InterferenceHelper::AppendEvent 和 ns3::WifiSpectrumValueHelper::GetBandPowerW。-O3 下,执行指令 194.3B,其中 Load 指令有 58.1B,Store 指令有 32.6B,分支指令有 44.0B,错误预测 372.0M,MPKI 等于 372.0M/194.3B*1000=1.91,略高,从 perf record -e branch-misses:pp 来看,错误预测主要来自于 ns3::InterferenceHelper::AppendEvent 内联的 std::map 的查询代码。
小结¶
各负载的情况如下:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. mobile | GCC 14 -O3 |
18 | 257.2 | 66.6 | 35.4 | 54.4 | 631.1 | 2.45 |
| 2. tcp | GCC 14 -O3 |
15 | 204.8 | 63.5 | 41.4 | 45.4 | 148.1 | 0.72 |
| 3. lena | GCC 14 -O3 |
3 | 46.6 | 14.2 | 9.6 | 10.4 | 53.4 | 1.15 |
| 4. dctcp | GCC 14 -O3 |
19 | 225.3 | 71.1 | 43.9 | 52.3 | 295.8 | 1.31 |
| 5. wifi_mixed | GCC 14 -O3 |
23 | 291.8 | 88.8 | 52.7 | 66.5 | 201.9 | 0.69 |
| 6. wifi_eht | GCC 14 -O3 |
14 | 194.3 | 58.1 | 32.6 | 44.0 | 372.0 | 1.91 |
与 727.cppcheck_r 类似,753.ns3_r 又是一个内存分配器 benchmark,大量时间花在 malloc/free 上了,此外还有不少 std::map 或 libm 的调用。-O3 下,执行指令 1221B,分支指令 273B,MPKI 是 1.39。
777.zstd_r¶
作为 SPEC INT 2026 中唯一一个压缩算法,把 SPEC INT 2017 的 557.xz_r 替换掉了,也能见到压缩算法的变迁。从没有被选中的 770.7z_r 来看,zstd 也是成功杀出重围,被认为是更加重要的压缩算法。它一共包括八个负载,但其实压缩的都是同一个文件,不像 557.xz_r 那样会压缩不同的输入文件,只是在代码里对输入数据做了随机修改:
# 1. b3
zstd -b3 -e3 --verbose -i40 cld.tar
# 2. b5
zstd -b5 -e5 --verbose -i25 cld.tar
# 3. b7
zstd -b7 -e7 --verbose -i12 cld.tar
# 4. b10
zstd -b10 -e10 --verbose -i6 cld.tar
# 5. b14
zstd -b14 -e14 --verbose -i4 cld.tar
# 6. b16
zstd -b16 -e16 --verbose -i1 cld.tar
# 7. b18
zstd -b18 -e18 --verbose -i1 cld.tar
# 8. b19
zstd -b19 -e19 --verbose -i1 cld.tar
这里的 -b 代表 compression level 下界,-e 代表 compression level 上界,都相等,其实就是每次只测一种 compression level 的意思。8 个负载的运行时间:11.0s、14.5s、13.0s、11.6s、24.5s、10.9s、20.1s 和 25.5s,一共是 131.2s,reftime 是 644s,对应 4.9 分。
开 -O3 -flto 或 -O3 -ljemalloc 没有什么性能提升,但 -O3 -march=native 提升不错,运行时间降低到 10.5s、13.7s、12.6s、11.4s、23.4s、10.3s、18.6s 和 23.5s,一共是 124.0s,对应 5.2 分,提升 6%。
以第一个负载 b3 为例,热点函数:
ZSTD_compressBlock_doubleFast_noDict_generic来自src/zstd-1.5.6/lib/compress/zstd_double_fast.c:56.82%,主要在对数据计算哈希,寻找匹配,进而用于压缩,具体算法没有仔细看,挺复杂的;ZSTD_decompressBlock_internal.part.0来自src/zstd-1.5.6/lib/decompress/zstd_decompress_block.c:16.63%,解压缩的主要逻辑,会调用ZSTD_decompressSequences,挺复杂的;ZSTD_encodeSequences来自src/zstd-1.5.6/lib/compress/zstd_compress_sequences.c:10.91%,分为 bmi2 和 generic 版本,不出意外 bmi2 版本也被 SPEC 禁用了,只能用 generic 版本,逻辑也挺复杂的,没有仔细看。
-O3 下,b3 执行 181.4B 条指令,其中有 49.9B 条 Load 指令,17.7B 条 Store 指令,19.1B 分支指令,错误预测 543.9M 次,MPKI 等于 543.9M/181.4B*1000=3.00,属于比较高的。从 perf record -e branch-misses:pp 来看,有 78.98% 的错误预测来自 ZSTD_compressBlock_doubleFast_noDict_generic,主要是在一些数据依赖的分支上,比如 if (MEM_read64(matchl0) == MEM_read64(ip));其余有 14.91% 来自 ZSTD_decompressBlock_internal.part.0,主要是 if (ofBits > 1) 等分支。
第二个负载 b5 的热点函数:
ZSTD_RowFindBestMatch.constprop.0来自src/zstd-1.5.6/lib/compress/zstd_lazy.c:67.91%,对数组进行循环,找到匹配最长的一项;ZSTD_compressBlock_lazy_generic.constprop.0来自src/zstd-1.5.6/lib/compress/zstd_lazy.c:9.12%,也是比较复杂的匹配算法;ZSTD_decompressBlock_internal.part.0来自src/zstd-1.5.6/lib/decompress/zstd_decompress_block.c:7.80%,描述见上。
-O3 下,b5 执行 273.6B 条指令,其中有 61.3B 条 Load 指令,35.1B 条 Store 指令,28.4B 分支指令,错误预测 562.4M 次,MPKI 等于 562.4M/273.6B*1000=2.06,属于比较高的。错误的分支预测有 78.92% 来自 ZSTD_RowFindBestMatch.constprop.0。
第五个负载 b14 的热点函数:
ZSTD_DUBT_findBestMatch来自src/zstd-1.5.6/lib/compress/zstd_lazy.c:85.74%,也是在循环中做最长匹配;ZSTD_searchMax.constprop.0来自src/zstd-1.5.6/lib/compress/zstd_lazy.c:9.04%,根据 dict mode 派发到不同的实现,实现也挺复杂。
-O3 下,b14 执行 197.6B 条指令,其中有 48.8B 条 Load 指令,16.5B 条 Store 指令,29.1B 分支指令,错误预测 1609.6M 次,MPKI 等于 1609.6M/197.6B*1000=8.15,属于特别高的。错误的分支预测有 94.94% 来自 ZSTD_DUBT_findBestMatch,比如 if (match[matchLength] < ip[matchLength]) 的分支。
第六个负载 b16 的热点函数:
ZSTD_insertBtAndGetAllMatches来自src/zstd-1.5.6/lib/compress/zstd_opt.c:38.62%,这里 Bt 代表的是 binary tree 二叉树;ZSTD_insertBt1来自src/zstd-1.5.6/lib/compress/zstd_opt.c:35.15%;ZSTD_compressBlock_opt_generic.constprop.1来自src/zstd-1.5.6/lib/compress/zstd_opt.c:16.50%。
-O3 下,b16 执行 129.1B 条指令,其中有 29.9B 条 Load 指令,11.2B 条 Store 指令,18.0B 条分支指令,错误预测 652.1M 次,MPKI 等于 652.1M/129.1B*1000=5.05,也是属于特别高的。错误的分支预测有 40.69% 来自 ZSTD_insertBtAndGetAllMatches,37.45% 来自 ZSTD_insertBt1,比如 if (match[matchLength] < ip[matchLength]) 的分支。
第三/四个负载 b7/b10 的热点与第二个负载 b5 类似;第七/八个负载 b18/b19 的热点函数和第六个负载 b16 类似,就不重复了。可见 zstd 会根据 compression level 选择不同路径,从而在压缩率和性能之间做出权衡。
那么开 -march=native 以后,发生了什么?能看到的是,由于 BMI 指令的引入,一些位运算的指令数变少了,比如 bzhi 和 tzcnt,还有一些是三操作数且不影响 flags 的运算,如 shrx,有点类似一些 RISC 指令集(如 RISC-V)的对应指令。开 -march=native 前后各负载的情况如下表:
| 负载 | 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|
| 1. b3 | GCC 14 -O3 |
11.0 | 181.4 | 49.9 | 17.7 | 19.1 | 543.9 | 3.00 |
| 1. b3 | GCC 14 -O3 -march=native |
10.5 | 170.4 | 49.9 | 18.3 | 18.9 | 543.8 | 3.19 |
| 2. b5 | GCC 14 -O3 |
14.5 | 273.6 | 61.3 | 35.1 | 28.4 | 562.4 | 2.06 |
| 2. b5 | GCC 14 -O3 -march=native |
14.0 | 250.5 | 59.7 | 35.4 | 28.3 | 559.1 | 2.23 |
| 3. b7 | GCC 14 -O3 |
13.0 | 228.5 | 48.9 | 25.8 | 29.8 | 599.3 | 2.62 |
| 3. b7 | GCC 14 -O3 -march=native |
12.7 | 207.4 | 46.6 | 26.0 | 29.8 | 596.7 | 2.88 |
| 4. b10 | GCC 14 -O3 |
11.6 | 207.2 | 41.5 | 17.6 | 32.6 | 516.3 | 2.49 |
| 4. b10 | GCC 14 -O3 -march=native |
11.5 | 184.0 | 37.8 | 17.8 | 32.6 | 569.6 | 3.10 |
| 5. b14 | GCC 14 -O3 |
24.5 | 197.6 | 48.8 | 16.5 | 29.1 | 1609.6 | 8.15 |
| 5. b14 | GCC 14 -O3 -march=native |
23.7 | 190.1 | 46.7 | 15.9 | 27.8 | 1612.5 | 8.48 |
| 6. b16 | GCC 14 -O3 |
10.9 | 129.1 | 29.9 | 11.2 | 18.0 | 652.1 | 5.05 |
| 6. b16 | GCC 14 -O3 -march=native |
10.2 | 124.7 | 30.7 | 12.0 | 17.3 | 646.5 | 5.18 |
| 7. b18 | GCC 14 -O3 |
20.1 | 265.8 | 57.0 | 17.0 | 32.6 | 987.7 | 3.72 |
| 7. b18 | GCC 14 -O3 -march=native |
18.4 | 259.2 | 57.0 | 17.2 | 31.4 | 980.7 | 3.78 |
| 8. b19 | GCC 14 -O3 |
25.5 | 342.0 | 72.9 | 19.1 | 41.8 | 1060.6 | 3.10 |
| 8. b19 | GCC 14 -O3 -march=native |
23.4 | 332.8 | 72.7 | 19.1 | 40.1 | 1050.2 | 3.16 |
整体来看,-O3 下 777.zstd_r 执行 1827B 指令,其中 232B 是分支指令,但 MPKI 有 3.58,仅次于 729.abc_r 和 723.llvm_r。
讨论¶
编译器选项对比¶
综合下来,编译选项对 SPEC INT 2026 Rate 的性能影响还是不小的,比如:
-flto对 707.ntest_r、710.omnetpp_r、714.cpython_r、734.vpr_r、735.gem5_r、753.ns3_r 都有一定的性能提升,当热点分散在多个函数,且很多函数都很小时,开 LTO 能带来一定程度的优化,本质上挽回了因可读性而拆分文件带来的性能开销-ljemalloc对 710.omnetpp_r、721.gcc_r、723.llvm_r、727.cppcheck_r、734.vpr_r、735.gem5_r、753.ns3_r 有性能提升,只能说这些软件做了太多的动态内存分配,有一些 benchmark 直接就是内存分配器 benchmark 了,此时替换 glibc 为 jemalloc/mimalloc 都有不错的性能提升,不过最新 glibc 也在改进 malloc 性能,不知道改进得怎样了?-march=native对 706.stockfish_r、707.ntest_r、735.gem5_r、777.zstd_r 有不错的提升,一方面是诸如 AVX 等 SIMD 指令(对 ARM64 来说,比如 Apple M2,就是针对 706.stockfish_r nnue 的 USDOT 指令,开-march=native直接给 706.stockfish_r 加了 33% 的分数,而如果没有这个指令集扩展,那么-march=native对 ARM64 没啥性能影响),另一方面就是一些位运算指令,比如 popcnt 和 BMI 扩展;事实上,现在很多软件在实现的时候,就已经考虑了硬件的加速指令,实际编译的时候,往往会直接用对应的 intrinsics,但 SPEC 禁用了这些 intrinsics,退而使用它的 generic 版本,此时就非常依赖 -march=native,以及需要编译器正确识别并翻译为对应的优化指令
还有一些常用的编译参数,比如 -static、-fomit-frame-pointer、-Ofast、-ffast-math 等等,目前没有做太多测试,以后说不定会加上。
编译器版本对比¶
本测试的主要编译器是 GCC 14.2.0,因为它是 Debian Trixie 的编译器版本。有意思的是,即使在 2026 年,随着编译器版本更新,硬件不变的情况下软件性能还在持续增长。GCC 15 能给 706.stockfish_r 生成更快的 SSE/AVX 指令序列,LLVM 22 能识别出 750.sealcrypto_r 的 64 位乘法模式,这些都是很好的例子。此外 LLVM 默认内联 popcount 的优化实现,而 GCC 会转化为对 libgcc 的 popcount 调用,前者代码体积膨胀,后者有额外的 call 开销,这些都会带来可观的性能差距。这些优化其实很具体,完全可以互相移植。在 SPEC INT 2017 时代,基本是 GCC 性能压制 LLVM,而目前 LLVM 凭借 750.sealcrypto_r 的优化相比 GCC 14 扳回一城,又被 GCC 15/16 反超。随着对 SPEC CPU 2026 的研究深入,未来还会编译出更快的程序。
分支预测¶
SPEC INT 2026 Rate 中 MPKI 较高的有:
- 723.llvm_r MPKI=5.98
- 729.abc_r MPKI=3.87
- 777.zstd_r MPKI=3.58
- 721.gcc_r MPKI=3.37
- 734.vpr_r MPKI=2.52
- 707.ntest_r MPKI=2.27
- 735.gem5_r MPKI=2.05
作为对比,SPEC INT 2017 Rate 的情况:
- 505.mcf_r MPKI=14.39
- 541.leela_r MPKI=12.62
- 557.xz_r MPKI=5.29
- 531.deepsjeng_r MPKI=4.40
- 520.omnetpp_r MPKI=4.33
- 502.gcc_r MPKI=3.13
SPEC INT 2026 Rate 整体低了不少。当然,这是每个 benchmark 的平均值,个别负载可能更高。但无论如何,终于不用和 505.mcf_r 的 spec_qsort 以及 541.leela_r 的 if(randint(2) == 0) 搏斗了。当然,SPEC INT 2026 Rate 也有很多的 MPKI 是来自于 std::map 的红黑树或者其他数据结构,有很多数据依赖的分支,也未必很好从硬件上优化性能。能看到的是,应用程序开始意识到分支预测,并通过 ternary operator 来提示编译器生成 cmov 指令来避免分支的错误预测。
局限性¶
目前的测试仅限于 Intel i9-14900K P-Core,还需要在 ARM64/RISC-V/LoongArch 上做类似的分析。指令集不同,结论应该也会不一样。此外,目前的分析集中在 perf 统计的热点函数上,还可以做更细粒度的分析,比如统计各类指令的使用比例,以及 POPCNT/BMI/AVX 等指令扩展的使用情况。
本文只跑了 Rate 1(单副本)。多副本下内存带宽和缓存竞争会更激烈,MPKI、IPC 等指标可能会有较大差异。此外,分析集中在指令级和分支预测层面,缺少微架构级的深入分析,例如 L1/L2/LLC 的缓存缺失率、TLB miss 等,这些对处理器设计者来说更直接。功耗数据也未纳入考量,综合能效比还需要用 RAPL 等工具进一步测量。最后,PGO(-fprofile-generate / -fprofile-use)也没有尝试,PGO 或许能带来不错的性能提升。
总结¶
本文深入分析了 SPEC CPU 2026 中 INT Rate 的负载,供编译器和处理器的设计者参考。从编译器的角度来说,可以集 GCC 和 LLVM 之长,进一步提升性能;从处理器的角度来说,针对程序的瓶颈进行优化,也能进一步提高分数。