跳转至

SPEC CPU 2026 负载特性分析(INT Rate 篇)

本文同步发布到本人的知乎

English version

背景

最近用 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 下的性能可以参考之前的测试结果:INTFP,基本上,从 5.7 GHz 到 6.0 GHz,性能可以按频率线性放缩。本文可能针对同一个负载给出多个不同的运行时间,这可能是因为多次运行导致的性能波动,也可能是因为部分数字包含了 perf record 的开销,不过误差都很小,可以放心对比。本文所用的脚本已开源到 jiegec/spec2026

推荐阅读:Evaluating SPEC CPU2026SPEC 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_ssse3sum += 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(-O3Godbolt)或 AVX(-O3 -march=alderlakeGodbolt)指令都没有 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 是黑白棋的引擎,该基准测试包括如下负载:

ntest_r Othello.154.ggf 20 16

实测数据显示,运行这个负载耗费的时间是 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,看到 solveNParitysolveNFlipParity 一起贡献了 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) 函数当中。性能区别有两点:

  1. 首先是对 callee-saved 寄存器的使用,GCC 14 会在 epilogue/prologue 直接进行一系列的 push/pop,而 GCC 15 更加聪明,仅在 if (neighbors[sq]&enemy) 条件成立的情况下,需要执行复杂函数体,需要 callee-saved 寄存器时才会进行 push/pop,否则就直接 ret,因为检查条件的时候并没有用到 callee-saved 寄存器,避免了保存和恢复。
  2. 自己编译的 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

三次运行的性能计数器如下:

  1. gcc-pp: 执行 470.2B 条指令,其中有 125.6B 条 Load 指令,58.8B 条 Store 指令,99.9B 条分支指令,错误预测 2.2B 次,MPKI 等于 2.2B/470.2B*1000=4.68
  2. gcc-smaller: 执行 243.4B 条指令,其中有 65.0B 条 Load 指令,30.3B 条 Store 指令,51.8B 条分支指令,错误预测 0.91B 次,MPKI 等于 0.91B/243.4B*1000=3.74
  3. 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" 代表匹配 abcdef,这次的瓶颈是 strncmpmemchr
  • 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_avx2spec_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_bbget_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_coststiming_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.hhctz64() 函数,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::decodePacketgem5::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 做的是同态加密,只有一个负载做测试:

sealcrypto_r refrate ecuador_province_capitals_refrate.csv Galapagos

运行时间 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.hmultiply_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_revRNSTool::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 后,GetTbDecodificationStatsMib 内联了进去,时间占比提升到 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::AppendEventns3::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 指令的引入,一些位运算的指令数变少了,比如 bzhitzcnt,还有一些是三操作数且不影响 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 之长,进一步提升性能;从处理器的角度来说,针对程序的瓶颈进行优化,也能进一步提高分数。

评论 / Comments