2026-05-29 08:00:00
继 INT Rate 篇 后,本文继续分析 SPEC FP 2026 Rate 的负载特性。
测试环境与先前的 INT Rate 篇 相同,这里不再赘述。
推荐阅读:Evaluating SPEC CPU2026 和 SPEC CPU2026: Characterization, Representativeness, and Cross-Suite Comparison
Cactus 是一个计算框架,这里用它来求解真空中的爱因斯坦方程。命令参数如下:
实测数据显示,运行时间为 103.4s,reftime 是 858s,对应 8.30 分。不同编译器和编译选项对 709.cactus_r 的优化情况如下:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) |
|---|---|---|---|
GCC 14 -O3
| 103.4 | 8.30 | 0 |
GCC 14 -O3 -march=native
| 83.9 | 10.23 | 23 |
GCC 14 -O3 -ffast-math
| 101.2 | 8.48 | 2 |
GCC 14 -O3 -ljemalloc
| 100.7 | 8.52 | 3 |
LLVM 22 -O3
| 94.6 | 9.07 | 9 |
LLVM 22 -O3 -march=native
| 90.5 | 9.48 | 14 |
可见 -march=native 能提供巨大的性能提升,LLVM 22 在 -O3 下比 GCC 14 快,不过 GCC 14 的 -O3 -march=native 又反超了 LLVM 22 的 -O3 -march=native,后面会具体分析。
通过 perf 观察性能瓶颈:
ML_CCZ4::ML_CCZ4_EvolutionInteriorSplitBy2_Body 来自 src/repos/mclachlan/ML_CCZ4/src/ML_CCZ4_EvolutionInteriorSplitBy2.cc:占总时间 41.30%,下同;ML_CCZ4::ML_CCZ4_EvolutionInteriorSplitBy3_Body 来自 src/repos/mclachlan/ML_CCZ4/src/ML_CCZ4_EvolutionInteriorSplitBy3.cc:31.26%;ML_CCZ4::ML_CCZ4_ConstraintsInterior_Body 来自 src/repos/mclachlan/ML_CCZ4/src/ML_CCZ4_ConstraintsInterior_Body.cc:6.71%;ML_CCZ4::ML_CCZ4_EvolutionInteriorSplitBy1_Body 来自 src/repos/mclachlan/ML_CCZ4/src/ML_CCZ4_EvolutionInteriorSplitBy3.cc:6.44%。这些热点函数的代码模式都是类似的:在三层循环里,读取对应三维空间中的点的数据,进行一系列的 Stencil 访存和浮点运算,包括大量的浮点乘法加法减法、pow 和 fabs,最后把结果写入对应数组。从指令来看,就是用大量的 SSE 指令来进行标量的双精度浮点运算,没有进行向量化。实验的时候,还观察到了编译器对 pow 和 fabs 的优化。在 -O3 时,pow(a, 1) 被编译成 a,pow(a, 2) 被编译成 a * a,pow(a, -1) 被编译成 1.0 / a,不过其他的例如 pow(a, 3) 和 pow(a, -2) 就只能转为 libm 的 pow 实现了。如果开了 -O3 -ffast-math,那么 pow(a, 3) 会编译成 a * a * a,pow(a, -2) 会被编译为 1.0 / (a * a)。两种编译选项的对比见 Godbolt。代码中,出现的主要就是 pow(a, -1),pow(a, 2)、pow(a, -2) 和 pow(a, runtimeVariable),其中 runtimeVariable 指一个在运行时才知道的数,在代码中对应 shiftAlphaPower 或 harmonicN。fabs 被编译成了位运算 andpd 指令,直接把符号位置零。
开启 -O3 -march=native 后,其实依然没有向量化,用 AVX2 指令计算双精度标量浮点,依然能看到对 libm 的 pow 的调用,就是上面提到的 pow(a, -2) 或 pow(a, runtimeVariable),不过其余的计算部分因为能用 vfmadd132sd/vfnmadd132sd 而获得了性能提升,同时 vaddsd 相比 addsd 从两操作数变为三操作数,还允许访存,进一步节省了指令数。而在 ARM64 平台上,开 -march=native 就没有性能提升,这是因为它的浮点乘加融合指令即使在没开 -march=native 的情况下也是可以使用的,见 Godbolt。某种意义上来说,AMD64 上开 -march=native 有性能巨大提升,也是吃了先发劣势的亏:基线对应的处理器太早,缺少很多重要的指令集扩展,这种兼容性负担在很多其他指令集上不会出现,例如乘加融合 FMA 指令很多指令集里已经在基线当中,在这些指令集上,开 -march=native 的提升就会相对来说更低。所以现在很多软件会曲线救国,为了保证兼容性,针对多个不同指令集扩展分别做手动适配,在运行时根据可用性选择性能最好的那一个。如果编译器能很好地自动完成这一点,将会在保持兼容性和开发便捷性的前提下,带来不错的系统整体性能提升。
不同编译选项的情况对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) |
|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 103.4 | 1423.6 | 747.8 | 110.1 | 9.8 | 677.0 | 5.2 |
GCC 14 -O3 -march=native
| 83.9 | 988.5 | 711.9 | 89.5 | 8.9 | 686.1 | 2.6 |
GCC 14 -O3 -ffast-math
| 101.8 | 1387.7 | 742.2 | 103.4 | 5.3 | 641.0 | 5.6 |
GCC 14 -O3 -ljemalloc
| 100.7 | 1423.6 | 747.8 | 110.1 | 9.8 | 677.0 | 5.2 |
LLVM 22 -O3
| 94.6 | 1323.1 | 659.1 | 96.6 | 6.1 | 659.0 | 15.2 |
LLVM 22 -O3 -march=native
| 90.5 | 1054.5 | 690.7 | 119.4 | 5.4 | 681.4 | 5.4 |
其中总指令数来自 instructions,Load 指令数来自 mem_inst_retired.all_loads,Store 指令数来自 mem_inst_retired.all_stores,分支指令数来自 branch-instructions,浮点标量指令数用 fp_arith_inst_retired.scalar,浮点向量指令数用 fp_arith_inst_retired.vector 性能计数器,下同。需要注意的是,vfmadd132sd 等乘加融合指令在 fp_arith_inst_retired.scalar/vector 计数器中会被计算两次。
从表里可以看出,-O3 下基本是一半指令在 Load,另一半指令在做浮点标量运算,这个计算访存比还是挺低的,这是 Stencil 计算的典型特征,在网格邻域里,Load 一个值进来,做一次乘加。开 -O3 -march=native 后,因为乘加融合指令的加持,指令数减少了很多,但因为乘加融合会算两倍的贡献,并且那些同时进行访存和计算的 AVX2 指令也会被同时计入到 Load 和浮点指令数,估计微架构是统计的拆分后的微码数量,那么总指令数不再等于各类指令数求和。这里 -O3 -ljemalloc 带来了些许的性能优势,不过指令数上并没有体现,它的性能提升主要是来自缓存局部性的改进。GCC 14 和 LLVM 22 在不同编译选项下各有千秋,大概看了一下生成的指令,其实实现方法都差不多,主要是地址计算、栈的使用和寄存器分配有一些区别。
值得注意的是,709.cactus_r 的缓存缺失率较高:GCC 14 -O3 下,L1 ICache 的 MPKI 达到 118.6B/1423.6B*1000=83.30,L1 DCache 也有 125.6B/1423.6B*1000=88.23 的 MPKI,在 SPEC FP 2026 Rate 和 SPEC INT 2026 Rate 中都是最高的。因此 L1 ICache 更大的核心更占优势,32KB 时遇到的 L1 ICache 瓶颈,换成 64KB 可能就消失了。开 -O3 -ljemalloc 后,L1 DCache 的 MPKI 降低到 111.7B/1423.6B*1000=78.46,在指令数与 -O3 持平的情况下获得了约 3% 的性能提升。
palm 是一个天气预报相关的程序,做的是 Navier Stokes 方程的求解,命令如下:
实测数据显示,运行时间为 174.0s,reftime 是 1320s,对应 7.59 分。不同编译器和编译选项对 722.palm_r 的优化情况:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) |
|---|---|---|---|
GCC 14 -O3
| 174.0 | 7.59 | 0 |
GCC 14 -O3 -march=native
| 157.8 | 8.34 | 10 |
GCC 14 -O3 -ffast-math
| 168.4 | 7.84 | 3 |
GCC 14 -O3 -ljemalloc
| 172.4 | 7.66 | 1 |
LLVM 22 -O3
| 144.0 | 9.17 | 21 |
LLVM 22 -O3 -march=native
| 118.6 | 11.13 | 47 |
趋势和 709.cactus_r 类似,-O3 -march=native 对性能提升巨大,LLVM 22 也明显比 GCC 14 快。
热点函数:
advec_s_ws_ij 来自 src/advec_ws.F90:9.80%,经典的 3 维上的 Stencil 计算,访存和计算的比例接近,基本是 load 一个点的数值然后就做对应的乘加,用 SSE 指令来做计算,有部分向量化计算,例如 addpd/subpd/mulpd 等,每条指令处理 2 个双精度浮点元素,不过也有一些循环没能成功向量化,退化到 addsd/subsd/mulsd 等浮点标量指令;advec_u_ws_ij 来自 src/advec_ws.F90:8.80%,同上;advec_v_ws_ij 来自 src/advec_ws.F90:8.54%,同上;advec_w_ws_ij 来自 src/advec_ws.F90:8.24%,同上;diffusion_e_ij 来自 src/turbulence_closure_mod.F90:5.14%,有一些比较复杂的浮点运算,比如 min/sqrt/div 等等,还有位运算,用 MERGE 来进行 ternary operator,无向量化,用 SSE 指令来做标量浮点计算。以下是 advec_s_ws_ij 中的 Stencil 计算代码,按 i,j,k 的顺序进行三层循环:
flux_r(k) = u_comp * ( & 37.0_wp * ( sk(k,j,i+1) + sk(k,j,i) ) & - 8.0_wp * ( sk(k,j,i+2) + sk(k,j,i-1) ) & + ( sk(k,j,i+3) + sk(k,j,i-2) ) ) * adv_sca_5 不同编译选项的情况对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) |
|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 174.0 | 3416.6 | 1267.4 | 271.1 | 155.6 | 779.0 | 318.5 |
GCC 14 -O3 -march=native
| 157.8 | 2710.0 | 1212.8 | 242.5 | 147.1 | 785.9 | 172.6 |
GCC 14 -O3 -ffast-math
| 168.4 | 3373.5 | 1204.7 | 278.0 | 134.0 | 612.8 | 363.1 |
GCC 14 -O3 -ljemalloc
| 172.4 | 3368.4 | 1259.7 | 260.7 | 141.6 | 779.0 | 318.5 |
LLVM 22 -O3
| 144.0 | 2640.4 | 835.5 | 216.3 | 90.4 | 179.5 | 609.7 |
LLVM 22 -O3 -march=native
| 118.6 | 1643.8 | 586.5 | 165.6 | 67.6 | 180.8 | 306.7 |
开 -O3 -march=native 后,能看到大量的 AVX2 向量化指令:vmulpd/vdivsd/vaddpd/vsubpd/vfmadd213sd/vfmsub132pd/vfmsub231pd/vmovupd 等等,每次处理 4 个双精度浮点元素,向量化程度很高,如果放在支持 AVX512 的处理器上,性能可能还会更高。相比 709.cactus_r 被 pow 等问题限制没能向量化,722.palm_r 的向量化收益要明显得多。LLVM 22 在 -O3 下比 GCC 14 更好,是因为它在热点函数如 advec_u/v/w_ws_ij 中成功进行了向量化,而 GCC 14 仍用标量,体现在数据上就是浮点向量指令数明显增多,浮点标量指令数明显减少。LLVM 22 下,上述热点函数被优化得较好后,flow_statistics(来自 src/flow_statistics.F90,时间占比 5.79%)成为了新的热点函数。它能向量化的部分比较少,因而时间占比提升。即使开了 -O3 -march=native,也还是用 AVX2+FMA 指令来做标量计算,时间区别不大。其他部分时间降低后,它的时间占比进一步提高到 6.95%,类似 Amdahl 定律。
709.cactus_r 和 722.palm_r 的计算模式其实都是 Stencil。物理相关的模拟经常做这类事情:在三维空间里求解微分方程,数值求解时需要对每个点的邻域进行反复计算,落到最后就是 Stencil。
astcenc 是一个针对 ASTC 有损压缩图片格式的编码器,运行三次,命令如下:
# 1. linear astcenc_r ref-inputs-linear.txt # 2. hdr astcenc_r ref-inputs-hdr.txt # 3. precision astcenc_r ref-inputs-precision.txt 实测运行时间为 49.9s、72.1s 和 53.8s,总时间 175.8s,reftime 是 840s,对应 4.78 分。不同编译器和编译选项的优化情况如下:
| 编译器 + 选项 | 总时间 (s) | 1. linear 时间 (s) | 2. hdr 时间 (s) | 3. precision 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) |
|---|---|---|---|---|---|---|
GCC 14 -O3
| 175.8 | 49.9 | 72.1 | 53.8 | 4.78 | 0 |
GCC 14 -O3 -march=native
| 157.3 | 44.0 | 63.2 | 50.0 | 5.34 | 12 |
GCC 14 -O3 -ffast-math
| 160.5 | 44.6 | 67.2 | 48.7 | 5.23 | 10 |
LLVM 22 -O3
| 134.0 | 38.5 | 56.1 | 39.3 | 6.27 | 31 |
LLVM 22 -O3 -march=native
| 117.2 | 34.4 | 48.6 | 34.1 | 7.17 | 50 |
又是 LLVM 22 相比 GCC 14 有明显优势的一个基准测试。其他对性能几乎没有影响的优化选项包括 -flto 和 -ljemalloc,这里就不具体列举了。731.astcenc_r 是 SPEC FP 2026 Rate 中 MPKI 最高的那一个,高达 5.0,相比其他大多数不到 1.0 的 MPKI 来说很高(第二高的是 737.gmsh_r,MPKI 达到了 3.33,第三高 767.nest_r 的 MPKI 只有 0.83),也比 SPEC INT 2026 Rate 的不少基准测试更高。下面分负载来进行分析。
主要热点函数:
compute_angular_endpoints_for_quant_levels 来自 src/astcenc_weight_align.cpp:18.93%,主要瓶颈是在中间的循环,在用 SSE 做一些单精度浮点的标量计算,中间还有一些对来自 libm 的 nearbyint 调用,进行 round 操作,从代码来看,开发者有意识地写一些适合编译器去向量化的代码,比如用 vfloat4 类型来做一些批量操作,还有 vmask4 类型保存 vfloat4 比较的结果(vmask4 保存了四个 int,用 0 代表 false,用 -1 代表 true),再用 select 函数来进行向量化的 ternary operator,可惜编译器并不领情,编译出来依然是标量 SSE;compute_avgs_and_dirs_3_comp_rgb 来自 src/astcenc_averages_and_directions.cpp:14.70%,模式和上面类似,在循环中做一些 vfloat4 和 vmask4 的计算,但 SSE 指令都是标量的;compute_quantized_weights_for_decimation 来自 src/astcenc_ideal_endpoints_and_weights.cpp:13.34%,在循环中做一些不过因为涉及到量化,有一些 vint 参与以及查表 vtable_lookup_32bit,这里 vfloat/vint 本来代表的是根据平台能提供的 SIMD 宽度进行一个自动的映射(定义在 src/astcenc_vecmathlib.h 中,比如 AVX 就是 8 个元素,vfloat 映射到 vfloat8;SSE 就是 4 个元素,vfloat 映射到 vfloat4),不过显然这些在 SPEC 里都被禁用了,fallback 到了 4 个元素的情况;compute_ideal_weights_for_decimation 来自 src/astcenc_ideal_endpoints_and_weights.cpp:9.57%,主要瓶颈是在一个 gather 操作 gatherf_byte_inds 里,不过因为 SSE 不支持 gather,所以是拆成四个元素分别进行 load 和标量计算的;bilinear_infill_vla 来自 src/astcenc_ideal_endpoints_and_weights.cpp:7.80%,瓶颈一样是 gather,即 gatherf_byte_inds 函数;compute_error_squared_rgb 来自 src/astcenc_averages_and_directions.cpp:6.39%,瓶颈一样是 gather,以及 gather 之后的一系列向量计算,但 GCC 14 都编译成了 SSE 标量计算。原生 SIMD 写法编译出来却是标量指令,反过来也说明,如果能正确向量化,性能还会有明显的提升空间。进一步,如果开了 -O3 -march=native,向量更宽来到 256 位,还多了 vblendvps 指令来实现上述 select 函数。前面提到过,LLVM 22 明显更快,下面看看不同编译器和编译选项的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 49.9 | 835.7 | 259.3 | 55.6 | 63.2 | 188.6 | 28.6 | 3136.0 | 3.75 |
GCC 14 -O3 -march=native
| 44.0 | 652.4 | 234.0 | 46.3 | 52.9 | 184.6 | 28.5 | 3148.2 | 4.83 |
GCC 14 -O3 -ffast-math
| 44.6 | 780.5 | 259.8 | 54.6 | 49.3 | 159.9 | 43.2 | 2139.0 | 2.74 |
LLVM 22 -O3
| 38.5 | 829.7 | 235.0 | 34.8 | 36.1 | 68.8 | 155.6 | 1095.5 | 1.32 |
LLVM 22 -O3 -march=native
| 34.4 | 620.9 | 179.5 | 17.7 | 19.6 | 42.1 | 125.7 | 823.4 | 1.33 |
从计数器可以看到,GCC 14 整体性能比 LLVM 22 差,是因为 LLVM 22 做了更多的向量化,它浮点向量指令明显比浮点标量要多,并且错误预测明显更少,MPKI 小很多。下面进行深入的分析。
首先看 GCC 14 是怎么实现 731.astcenc_r 的这类 SIMD 原生代码的。以上面分析的热点函数为例,一个常见的模式是用 vfloat4 的比较加 select 来实现向量化的最大值计算:
这段代码在 -O3 编译选项下会被 GCC 14 编译成这样的汇编:
vmax(vfloat4 a, vfloat4 b): # a 向量保存在 xmm0(a[0] 和 a[1])和 xmm1(a[2] 和 a[3])寄存器 # b 向量保存在 xmm2(b[0] 和 b[1])和 xmm3(b[2] 和 b[3])寄存器 # 虽然每个元素都是单精度,但每个 xmm 寄存器只保存了两个元素 movq %xmm1, %rax # rax = a3 | a2 movq %xmm3, %rcx # rcx = b3 | b2 movq %xmm0, %rsi # rsi = a1 | a0 movd %ecx, %xmm1 # xmm1 = b2 movd %eax, %xmm6 # xmm6 = a2 shrq $32, %rcx # rcx = b3 movdqa %xmm2, %xmm5 # xmm5 = b1 | b0 shrq $32, %rax # rax = a3 movdqa %xmm2, %xmm0 # xmm0 = b1 | b0 movd %ecx, %xmm4 # xmm4 = b3 shufps $85, %xmm5, %xmm5 # xmm5 = b1 | b1 | b1 | b1 movd %eax, %xmm2 # xmm2 = a3 movd %esi, %xmm7 # xmm7 = a0 shrq $32, %rsi # rsi = a1 movdqa %xmm5, %xmm3 # xmm3 = b1 | b1 | b1 | b1 comiss %xmm2, %xmm4 # 比较 a3 和 b3 movd %esi, %xmm5 # xmm5 = a1 seta %al # al = (b3 > a3) comiss %xmm6, %xmm1 # 比较 b2 和 a2 jbe .L14 # 如果 a2 >= b2 就跳转到 .L14 testb %al, %al jne .L15 # 如果 b3 > a3 就跳转到 .L15 # 此时 a2 < b2, a3 >= b3 maxss %xmm7, %xmm0 # xmm0 = max(a0, b0) maxss %xmm5, %xmm3 # xmm3 = max(a1, b1) unpcklps %xmm2, %xmm1 # xmm1 = a3 | b2 unpcklps %xmm3, %xmm0 # xmm0 = max(a1, b1) | max(a2, b2) ret .L14: # 处理 a2 >= b2 的情况 testb %al, %al jne .L16 # 如果 b3 > a3 就跳转到 .L16 #3 此时 a2 >= b2, a3 >= b3 movaps %xmm6, %xmm1 # xmm1 = a2 # 下略,就是分类讨论 a2 vs b2,a3 vs b3 的四种情况 .L17: maxss %xmm7, %xmm0 maxss %xmm5, %xmm3 unpcklps %xmm2, %xmm1 unpcklps %xmm3, %xmm0 ret .L16: movaps %xmm4, %xmm2 movaps %xmm6, %xmm1 jmp .L17 .L15: maxss %xmm7, %xmm0 maxss %xmm5, %xmm3 movaps %xmm4, %xmm2 unpcklps %xmm2, %xmm1 unpcklps %xmm3, %xmm0 ret 很奇怪的是,它首先用通用寄存器把输入的数值拆分出来,然后分别比较后两个元素 a2 vs b2,a3 vs b3,用分支来处理四种可能的情况,这四种情况是已知后两个元素最大值都来自哪里,结果针对前两个元素又用 maxss 来计算,为啥不一开始就对所有四个元素都用 maxss 呢?结果开 -O3 -ffast-math 后,它莫名其妙就学会了这一点:
vmax(vfloat4, vfloat4): movq %xmm0, %rsi movq %xmm1, %rcx movq %xmm2, %rdx movd %esi, %xmm1 movq %xmm3, %rax movdqa %xmm2, %xmm0 shrq $32, %rdx maxss %xmm1, %xmm0 shrq $32, %rsi movdqa %xmm3, %xmm1 shrq $32, %rax movd %ecx, %xmm3 shrq $32, %rcx movd %edx, %xmm2 movd %esi, %xmm4 maxss %xmm3, %xmm1 movd %ecx, %xmm5 movd %eax, %xmm3 maxss %xmm4, %xmm2 maxss %xmm5, %xmm3 unpcklps %xmm2, %xmm0 unpcklps %xmm3, %xmm1 ret 但依然是用 SSE 做标量,而 LLVM 22 就懂得如何用 maxps 指令向量化:
vmax(vfloat4, vfloat4): movlhps %xmm3, %xmm2 movlhps %xmm1, %xmm0 maxps %xmm2, %xmm0 movaps %xmm0, %xmm1 unpckhpd %xmm0, %xmm1 retq 剩余的指令只是为了解决调用约定的数据存放位置问题,实际在函数内部计算的时候,通常就一条 maxps 指令完成所有 4 个元素的 max 计算。从这个例子也可以看出,为啥 LLVM 22 比 GCC 14 要快得多:GCC 14 多了很多无用的分支来解决 select 里的比较,而且还不能向量化 max 操作。即使给 GCC 14 开 -march=native,它依然还在用 AVX 指令进行标量 max 运算,真是难绷。上述编译结果可见 Godbolt。GCC 14 的 MPKI 那么高,其实都是这么来的,也挺搞笑。我还测试了一下,发现相同的代码在 LoongArch 下也没有得到很好的向量化支持(见 Godbolt),因此提了一个 issue,仅考虑向量化 fmax 内核,用 vfcmp.slt.s + vbitsel.v 的优化实现大概是目前 LLVM 22 编译结果的 2.9 倍性能。这里有一个小冷知识,就是 x86 的 SSE/AVX max 指令都实现的都是 a > b ? a : b 的逻辑,而 LoongArch 的 fmax 指令实现的是 IEEE754 的 maxNum,二者在出现 NaN 时的行为不同:前者只要 a 或 b 出现一个 NaN,就都返回 b;后者只有一个 NaN 时,会返回另一个非 NaN 的数。
主要热点函数:
compute_angular_endpoints_for_quant_levels 来自 src/astcenc_weight_align.cpp:19.80%,描述见上;compute_avgs_and_dirs_3_comp_rgb 来自 src/astcenc_averages_and_directions.cpp:15.37%,描述见上;compute_quantized_weights_for_decimation 来自 src/astcenc_ideal_endpoints_and_weights.cpp:12.40%,描述见上;compute_error_squared_rgb 来自 src/astcenc_averages_and_directions.cpp:6.91%,描述见上;compute_ideal_weights_for_decimation 来自 src/astcenc_ideal_endpoints_and_weights.cpp:5.68%,描述见上。热点函数基本和 1. linear 一致,那么各方面基本也和它一样,GCC 14 生成大量分支和标量 SSE 指令,而 LLVM 22 能更好地向量化,避免一些无谓的分支。对比如下:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 72.1 | 1091.8 | 306.9 | 78.6 | 91.7 | 245.8 | 30.4 | 4928.9 | 4.51 |
GCC 14 -O3 -march=native
| 63.1 | 851.4 | 271.2 | 65.2 | 77.4 | 240.1 | 30.4 | 4890.6 | 5.74 |
GCC 14 -O3 -ffast-math
| 67.1 | 1036.6 | 311.0 | 85.5 | 73.7 | 200.8 | 54.3 | 4077.0 | 3.93 |
LLVM 22 -O3
| 55.9 | 1107.9 | 276.5 | 55.9 | 56.9 | 111.8 | 129.9 | 1943.2 | 1.75 |
LLVM 22 -O3 -march=native
| 48.6 | 825.2 | 209.3 | 30.7 | 34.1 | 85.2 | 139.7 | 1411.6 | 1.71 |
热点函数大多还是和 1. linear 以及 2.hdr 一样,就是多了一个 find_best_partition_candidates 函数,来自 src/astcenc_find_best_partitioning.cpp,主要瓶颈在 a / sqrt(length) 的计算上。这次 GCC 14 在 -O3 时倒是能够正确向量化这一步,通过一次标量的 sqrtss 加 shufps 把结果复制到所有 lane,再用 divps 进行批量的除法,不过其余的热点函数还是一如既往的编译出很慢的代码。下面给出性能计数器上的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 53.8 | 711.5 | 176.8 | 62.0 | 61.3 | 177.0 | 9.3 | 5119.2 | 7.19 |
GCC 14 -O3 -march=native
| 49.2 | 570.5 | 161.3 | 57.1 | 54.7 | 176.1 | 9.2 | 5113.1 | 8.96 |
GCC 14 -O3 -ffast-math
| 48.7 | 655.9 | 168.3 | 64.6 | 49.8 | 156.5 | 19.5 | 4227.6 | 6.56 |
LLVM 22 -O3
| 39.3 | 729.9 | 149.2 | 42.8 | 35.9 | 75.3 | 77.2 | 1906.7 | 2.61 |
LLVM 22 -O3 -march=native
| 34.1 | 544.9 | 112.5 | 28.0 | 23.2 | 52.0 | 87.1 | 1445.7 | 2.65 |
731.astcenc_r 用了 SIMD 原生的写法来编程:vfloat4、vint4 和 vmask4 等等,编写时就是奔着 SIMD 指令去的。只可惜 GCC 14 辜负了开发者的期望,不能正确识别代码意图并利用硬件指令,还莫名生成了一堆分支来实现 select 函数。相比之下,LLVM 22 就做得好很多,该向量化的地方就向量化。同时也能看到,像 LoongArch 这样稍微小众一些的指令集,在这些代码模式下的优化还比较欠缺,无论 GCC 还是 LLVM 都是如此。
ocio 是 OpenColorIO 的缩写,和 731.astcenc_r 类似,也是在图片上的处理,不过更侧重于图像处理,而非图像压缩。该基准测试包括如下四个负载:
# 1. lut1d ocioperf --spec-validation-offset 101 --spec-validation-stride 17 --spec-validation-pixels 131 --bitdepths ui16 ui16 --iter 100 --test -1 --transform ctf/lut1d_halfdom.ctf # 2. mntr ocioperf --spec-validation-offset 202 --spec-validation-stride 19 --spec-validation-pixels 132 --bitdepths ui16 f32 --iter 200 --8kres --test 0 --transform ctf/mntr_srgb_identity.ctf # 3. aces ocioperf --spec-validation-offset 303 --spec-validation-stride 23 --spec-validation-pixels 133 --bitdepths f32 f32 --iter 20 --8kres --test -1 --transform clf/aces_to_video_with_look.clf # 4. heavy ocioperf --spec-validation-offset 404 --spec-validation-stride 29 --spec-validation-pixels 134 --bitdepths f32 f32 --iter 25 --test -1 --transform clf/heavy_transform.clf reftime 是 875s,不同编译器和编译选项的运行情况如下:
| 编译器 + 选项 | 总时间 (s) | 1. lut1d 时间 (s) | 2. mntr 时间 (s) | 3. aces 时间 (s) | 4. heavy 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) |
|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 139.8 | 6.1 | 11.2 | 67.8 | 54.6 | 6.26 | 0 |
GCC 14 -O3 -march=native
| 105.0 | 4.2 | 10.2 | 49.6 | 40.1 | 8.33 | 33 |
GCC 14 -O3 -ffast-math
| 139.4 | 6.4 | 11.4 | 67.8 | 53.9 | 6.28 | 0.3 |
LLVM 22 -O3
| 128.9 | 6.8 | 11.3 | 61.7 | 49.0 | 6.79 | 8 |
LLVM 22 -O3 -march=native
| 105.3 | 5.4 | 9.6 | 49.3 | 40.9 | 8.31 | 33 |
可见又是一个 -O3 -march=native 带来明显提升的基准测试,且 LLVM 22 依然比 GCC 14 在 -O3 下有性能优势,在 -O3 -march=native 时基本打平。下面进行具体分析。
热点函数:
OpenColorIO_v2_2dev::BitDepthCast<BIT_DEPTH_F32, BIT_DEPTH_UINT16>::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/CPUProcessor.cpp:45.16%,主要做的计算是,在循环中对取值在零到一之间的单精度浮点元素,乘以 65535 从而放缩到 uint16_t 的范围,加 0.5 后 clamp 到 uint16_t 的范围,最后再 float 转换为 uint16_t,这个过程被编译为 SSE 的向量指令;OpenColorIO_v2_2dev::Lut1DRendererHalfCode<BIT_DEPTH_UINT16, BIT_DEPTH_F32>::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/lut1d/Lut1DOpCPU.cpp:33.70%,在循环中对输入的 uint16_t 进行查表,其实就是从预先计算好的数组里读取 uint16_t 对应的 float 值,瓶颈是 SSE 标量间接访存;__memmove_avx_unaligned_erms 来自 libc:13.28%,memmove 的 AVX 加速实现;__memset_avx2_unaligned_erms 来自 libc:3.55%,memset 的 AVX 加速实现。对于这类可以高度向量化的代码,-O3 -march=native 的提升是很明显的,在 OpenColorIO_v2_2dev::BitDepthCast<BIT_DEPTH_F32, BIT_DEPTH_UINT16>::apply 函数里,体现就是用上了 AVX2 的 256 位向量计算以及 FMA 指令,正好把放缩和加 0.5 这两步融合在了一起,后续则是继续用位运算来实现 clamp 操作,使得这个函数在 -O3 -march=native 下的时间占比降低到了 27.82%,那么依然在用 SSE 标量进行间接访存的 OpenColorIO_v2_2dev::Lut1DRendererHalfCode<BIT_DEPTH_UINT16, BIT_DEPTH_F32>::apply 就成为了主要的性能瓶颈,时间占比提升到 42.85%。
在该基准测试里,GCC 14 比 LLVM 22 更快一些。以下是二者在不同编译选项下的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) |
|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 6.1 | 106.2 | 23.3 | 11.7 | 4.2 | 2.6 | 5.0 | 2.6 |
GCC 14 -O3 -march=native
| 4.2 | 63.8 | 22.0 | 11.0 | 3.6 | 2.6 | 2.5 | 2.5 |
GCC 14 -O3 -ffast-math
| 6.4 | 104.8 | 23.2 | 11.7 | 4.2 | 2.5 | 5.0 | 2.6 |
LLVM 22 -O3
| 6.8 | 106.1 | 23.3 | 11.7 | 3.6 | 2.5 | 5.0 | 2.6 |
LLVM 22 -O3 -march=native
| 5.4 | 72.5 | 24.8 | 11.0 | 1.4 | 2.5 | 2.5 | 2.5 |
具体到汇编层面上,可以观察到,GCC 14 和 LLVM 22 在实现上有一些不同,开头都是乘法和加法,主要是 clamp 的部分用的指令不同,为了解决 16 位和 32 位的位宽转换的问题,GCC 14 主要用 punpcklwd 类指令,而 LLVM 22 更多使用 pshufd 类指令,详见 Godbolt。虽然总指令数很接近,但毕竟硬件执行这些指令需要的时间不同,所以体现在 IPC 上也有一定的差距。开 -O3 -march=native 之后也是类似的情况。
热点函数:
OpenColorIO_v2_2dev::BitDepthCast<BIT_DEPTH_UINT16, BIT_DEPTH_F32>::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/CPUProcessor.cpp:55.41%,这次转换的方向反过来了,是从 uint16_t 到 float,于是计算过程变成先从 uint16_t 转成 float,再乘以 1.0/65535.0,当然这次就没有 clamp 了,编译器依然能正确向量化,不过因为位宽从 16 变成 32 的问题,花了不少功夫;OpenColorIO_v2_2dev::ScaleRenderer::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/matrix/MatrixOpCPU.cpp:41.52%,代码逻辑就是很简单的对每个像素的四个分量分别乘以一个 scale(从 out[0] = in[0] * m_scale[0] 到 out[3] = in[3] * m_scale[3]),不同像素的 scale 来自同一个数组 m_scale,理应是比较好向量化的,但实际上并没有向量化成功,这是因为指针没有标记 restrict,编译器无法判断 out 和 m_scale 是否可能重合,只有在不重合的前提下,才能直接用 mulps 向量化,见 Godbolt。由于 AMD64 缺少对混合宽度计算的向量指令,其实很大开销是在向量之间搬运数据,而非进行实际的计算和访存,这方面,RISC-V Vector 的特殊设计还确实带来了更简洁的指令生成,见 Godbolt。不同编译器在不同编译选项下的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) |
|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 11.2 | 209.9 | 56.5 | 33.3 | 7.5 | 26.8 | 6.6 | 1.9 |
GCC 14 -O3 -march=native
| 10.2 | 159.6 | 54.8 | 29.9 | 7.1 | 26.8 | 3.3 | 1.8 |
GCC 14 -O3 -ffast-math
| 11.4 | 209.7 | 56.5 | 33.3 | 7.5 | 26.7 | 6.6 | 1.8 |
LLVM 22 -O3
| 11.3 | 194.5 | 56.5 | 33.3 | 8.6 | 26.5 | 6.7 | 1.9 |
LLVM 22 -O3 -march=native
| 9.6 | 149.4 | 58.2 | 29.9 | 2.8 | 26.5 | 3.4 | 2.0 |
热点函数:
OpenColorIO_v2_2dev::Lut3DTetrahedralRenderer::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/lut3d/Lut3DOpCPU.cpp:50.74%,做的操作还挺复杂,每个元素首先进行一次乘法,然后进行一次 clamp,floor 和 ceil 后分别转化为 int,再根据 int 去进行对一个表进行间接访存,查表的结果再经过一系列的加权平均完成计算,向量化程度不高;OpenColorIO_v2_2dev::MatrixRenderer::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/matrix/MatrixOpCPU.cpp:11.55%,进行矩阵的运算,把输入的四维向量和一个 4x4 矩阵进行乘法,得到输出的四维向量,向量化程度较高;__log2f_fma 来自 libm:10.02%,计算浮点 log2;OpenColorIO_v2_2dev::CameraLin2LogRenderer::apply 来自 src/ASWF-OpenCOlorIO/src/OpenColorIO/ops/log/LogOpCPU.cpp:9.76%,判断输入的范围,如果小于一个阈值 m_linb,就用线性的乘加计算结果,否则就会调用上述 log2 函数,结合一些乘加以及 max 操作来进行计算,向量化程度低。不同编译器和编译选项的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) |
|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 67.8 | 1258.9 | 299.3 | 86.3 | 100.5 | 260.6 | 28.0 | 146.6 |
GCC 14 -O3 -march=native
| 49.6 | 873.7 | 289.0 | 84.9 | 84.0 | 257.4 | 14.0 | 135.4 |
GCC 14 -O3 -ffast-math
| 67.8 | 1251.5 | 296.4 | 94.4 | 109.9 | 213.7 | 43.8 | 150.6 |
LLVM 22 -O3
| 61.7 | 1152.4 | 416.6 | 136.7 | 133.7 | 329.0 | 15.4 | 168.5 |
LLVM 22 -O3 -march=native
| 49.3 | 857.8 | 342.8 | 92.6 | 84.4 | 329.0 | 13.0 | 151.6 |
GCC 14 和 LLVM 22 在 -O3 下的性能差距主要来自于 floor 和 ceil 的处理:GCC 14 生成了一系列 SSE 指令来计算,由于没有 SSE4.1 的 roundps 指令,所以实现比较复杂,而 LLVM 22 转为采用 libm 的加速实现 __floorf_sse41,它的函数体就是一条 SSE4.1 的 roundps 指令加 return,虽然有函数调用的开销,不仅要 call/ret,还多了一些寄存器到栈的 Load 和 Store,但总体还是赚的。不过,如果处理器确实没有 SSE4.1 指令,那么 GCC 14 又该比 LLVM 22 更快了。这种取舍,在不开 -march=native 的时候确实无法实现,此时只能猜测,哪种情况发生的概率更高了,例如现在来看,有 SSE4.1 的 AMD64 处理器肯定是比没有 SSE4.1 的 AMD64 处理器要多。
开 -O3 -march=native 后,因为有了 vroundps 指令,原来的 ceil 和 floor 操作可以用向量指令代替,相比之前的向量化实现(GCC 14)或调用 libm 里的加速实现(LLVM 22),GCC 14 和 LLVM 22 都有不错的提升,来到了同一水平线上。同时 fma 也成功融合了不少浮点乘加计算。
热点函数:
__powf_fma 来自 libm:26.17%;OpenColorIO_v2_2dev::Lut3DRenderer::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/lut3d/Lut3DOpCPU.cpp:25.69%,模式和上面的 OpenColorIO_v2_2dev::Lut3DTetrahedralRenderer::apply 比较类似,也有 clamp/floor/ceil 和查表等动作,就是最后的计算部分不太一样,也都是标量的 SSE 指令;OpenColorIO_v2_2dev::Lut1DRenderer<BIT_DEPTH_F32, BIT_DEPTH_F32>::apply 来自 src/ASWF-OpenColorIO/src/OpenColorIO/ops/lut1d/Lut1DOpCPU.cpp:15.63%,模式和上述 OpenColorIO_v2_2dev::Lut3DRenderer::apply 类似,不过查表的部分更简单,因为只有一维,但也是全程标量;OpenColorIO_v2_2dev::CDLRendererFwd<true>::apply:10.88%,里面调用了 pow,导致 __powf_fma 占用了很多时间,其余部分做了浮点乘法、加减法以及 Clamp 操作,还是全程标量;OpenColorIO_v2_2dev::GammaMoncurveOpCPUFwd::apply:5.41%,同样调用了 pow,除了 pow 以外还有一些浮点运算以及比较。不同编译器和编译选项的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) |
|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 54.6 | 1013.5 | 209.4 | 57.0 | 80.8 | 253.7 | 5.8 | 32.0 |
GCC 14 -O3 -march=native
| 40.9 | 764.7 | 204.0 | 54.8 | 70.8 | 260.2 | 3.3 | 31.8 |
GCC 14 -O3 -ffast-math
| 53.9 | 971.0 | 202.1 | 50.5 | 80.6 | 252.3 | 6.6 | 29.1 |
LLVM 22 -O3
| 49.0 | 861.5 | 250.4 | 77.3 | 102.7 | 215.6 | 29.9 | 28.8 |
LLVM 22 -O3 -march=native
| 40.9 | 726.8 | 206.9 | 55.4 | 67.3 | 255.6 | 25.7 | 28.5 |
LLVM 22 相比 GCC 14 的主要性能区别和 3. aces 一样,就是 ceil/floor 的处理。此外,就是和 731.astcenc_r 类似的情况,在遇到向量化的 min/max 操作的时候,LLVM 22 会正确向量化为对应的 maxps/minps 指令,而 GCC 14 生成的代码就会比较冗长。
736.ocio_r 依然是一个比较适合向量化的应用,虽然它不像 731.astcenc_r 那样直接用 vfloat4 格式,但因为它是图像处理,每次循环处理一个像素,然后每个像素有四个通道,在很多情况下,这四个通道的计算过程是一样的,因此也非常适合向量化。而 LLVM 22 在 -O3 下做出了比 GCC 14 更好的指令生成,从 floor/ceil 到 libm 函数的映射,以及更好的向量化实现。当然,开 -O3 -march=native 后,GCC 14 和 LLVM 22 的性能差距非常小,说明在两方都开启足够的指令集扩展以后,基本会收敛到差不多的代码实现上,这也反过来说明,GCC 14 的 SSE 代码生成上有一些欠缺,可能的情况是,并非 GCC 14 不能向量化(因为开 -O3 -march=native 后就学会了),而是尝试向量化后,不知道怎么用 SSE 表达向量化后的代码,于是退回到了标量。
737.gmsh_r 是 3D 的 CAD 软件,包括七个负载:
# 1. choi gmsh_r -option gmsh.opts -nt 0 choi.geo # 2. mediterranean gmsh_r -option gmsh.opts -nt 0 mediterranean.geo # 3. projection gmsh_r -option gmsh.opts -nt 0 projection.geo # 4. gasdis gmsh_r -option gmsh.opts -nt 0 gasdis.geo # 5. Torus gmsh_r -option gmsh.opts -nt 0 Torus.geo # 6. spec gmsh_r -option gmsh.opts -nt 0 spec.geo -clscale 0.175 -algo del2d -algo hxt # 7. p19 gmsh_r -option gmsh.opts -nt 0 p19.geo 各负载运行时间为 17.1s、11.8s、11.2s、16.9s、9.2s、13.4s、12.8s,总时间 92.2s,reftime 是 459s,对应 4.98 分。-O3 -ffast-math 和 -O3 -march=native 收益都很小,LLVM 22 反而比 GCC 14 更慢,因此这里就不做具体比较了。
用 -O3 -march=native 编译的时候,发现如果 CC 只传了 gcc,而没有传 -std=c18,就会在 4. gasdis 这一个负载里死循环,一直报错:Info : Symbolic perturbation failed (2 superposed vertices ?)。经过对比,两者的区别在于是否进行乘加融合:-O3 -std=c18 -march=native 时,不会进行融合,而 -O3 -march=native 或 -O3 -std=gnu18 -march=native 时会进行融合,见 Godbolt。在其他程序里,融合对性能更优,但这里很不幸,融合了就会导致死循环。这和 -fp-contract 有关:
-ffp-contract=style -ffp-contract=off disables floating-point expression contraction. -ffp-contract=fast enables floating-point expression contraction such as forming of fused multiply-add operations if the target has native support for them. -ffp-contract=on enables floating-point expression contraction if allowed by the language standard. This is implemented for C and C++, where it enables contraction within one expression, but not across different statements. The default is -ffp-contract=off for C in a standards compliant mode (-std=c11 or similar), -ffp-contract=fast otherwise. 可见它只对 C 语言有效,对 C++ 无效,实际上就是只对 737.gmsh_r 有影响;虽然 709.cactus_r 也有 C 代码,但它的主要计算都在 C++ 语言的部分。
接下来针对各负载进行热点分析。
热点函数:
netgen::ADTree6::GetIntersecting 来自 src/gmsh/contrib/Netgen/libsrc/gprim/adtree.cpp:18.40%,实现了一个 6 维的 KD-Tree 的搜索算法,主要瓶颈在于中间的数据依赖的分支 if (node->pi != -1),预测错误率较高;__ieee754_atan2_fma 来自 libm:6.64%;reparamMeshVertexOnFace 来自 src/gmsh/src/geo/MVertex.cpp:6.03%,根据顶点的维度进入不同的 if-else 分支进行处理,错误预测也比较多。虽然用到了浮点,但计算模式并不适合向量化。毕竟是 KD-Tree 的搜索,MPKI 高是正常现象。执行了 204.7B 条指令,错误预测 744.3M 次,MPKI 等于 744.3M/204.7B*1000=3.64,是 SPEC FP 2026 Rate 中第二高的。第一高 731.astcenc_r 如上所述,其实是 GCC 的实现不够好,完全可以把 MPKI 优化到 LLVM 22 的 1.3 左右,那样的话 737.gmsh_r 就是第一了。
热点函数:
meshGEdgeProcessing 来自 src/gmsh/src/mesh/meshGEdge.cpp:36.55%,主要瓶颈在循环中的 gauss seidel 迭代,标量除法和比较耗费了比较多的时间;KDTreeSingleIndexAdaptor::searchLevel 来自 src/gmsh/src/numeric/nanoflann.hpp:33.50%,又一个经典的 KD-Tree 的搜索算法,根据输入的值递归到左子树或右子树;InterpolateCurve 来自 src/gmsh/src/geo/GeoInterpolation.cpp:6.53%,递归进行一些插值的计算。虽然用到了浮点,但计算模式依然不适合向量化,因为中间的计算结果还被用于 if 分支,分支内也有若干浮点计算。
热点函数:
laplaceSmoothing 来自 src/gmsh/src/mesh/meshGFaceOptimize.cpp:11.73%,主要瓶颈是 std::set 的操作,,而 std::set 是用 std::map 实现的,因此会调用下面的 std::map 的代码;std::map::_M_get_insert_unique_pos 来自 libstdc++:7.49%,std::map 的插入算法实现;__ieee754_atan2_fma 来自 libm:7.21%;reparamMeshVertexOnFace:6.66%,描述见上;std::map::_M_get_insert_unique 来自 libstdc++:6.09%,std::map 的插入实现;SetRotationMatrix 来自 src/gmsh/src/geo/Geo.cpp:5.01%,代码是多层循环,适合向量化,编译器也确实向量化了,不过时间占比并不高。可见,该负载主要还是 std::map 相关的操作为主要瓶颈。
热点函数:
MakeHybridHexTetMeshConformalThroughTriHedron 来自 src/gmsh/src/mesh/meshCombine3D.cpp:30.18%,主要瓶颈是在循环里对 std::map 进行搜索;parallelDelaunay3D 来自 src/gmsh/contrib/hxt/tetMesh/src/hxt_tetDelaunay.c:9.05%,实现了 Delaunay 三角剖分算法;hxtRefineTetrahedra 来自 src/gmsh/contrib/hxt/tetMesh/src/hxt_tetRefine.c:5.18%,主要是指循环中做一些浮点计算,包括加减法,乘除法和 sqrt。瓶颈主要还是在 std::map。
最后三个负载,其热点函数都与 4.gadis 相同,不再赘述。
各负载的情况:
| 负载 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|---|
| 1. choi | 17.0 | 204.7 | 59.3 | 25.6 | 39.4 | 22.1 | 0.3 | 744.3 | 3.64 |
| 2. mediterranean | 11.7 | 190.7 | 57.4 | 23.2 | 24.0 | 28.5 | 2.4 | 71.0 | 0.37 |
| 3. projection | 11.1 | 109.0 | 29.1 | 14.4 | 20.3 | 13.3 | 2.2 | 183.0 | 1.68 |
| 4. gasdis | 16.9 | 157.8 | 46.3 | 17.8 | 27.6 | 19.6 | 0.2 | 689.9 | 4.37 |
| 5. Torus | 9.2 | 77.3 | 21.9 | 8.2 | 13.4 | 9.4 | 0.5 | 380.4 | 4.92 |
| 6. spec | 13.3 | 101.4 | 30.2 | 10.8 | 18.1 | 10.9 | 0.2 | 546.1 | 5.39 |
| 7. p10 | 12.7 | 96.3 | 28.8 | 10.2 | 17.2 | 10.4 | 0.1 | 529.3 | 5.50 |
可见整体的 MPKI 还是偏高的,并且很大程度上归功于 KD-Tree 的查询以及 std::map 的查询或插入,只不过这些树的 key 都是单精度浮点数。并且根据上面的分析,确实相关的代码不适合向量化,浮点乘加融合还被禁用了,否则就可能不收敛。
flightdm 是一个飞行动力学模拟器,该基准测试包括如下八项负载:
# 1. weather JSBSim --nohighlight scripts/weather-balloon2.xml # 2. B747 JSBSim --nohighlight scripts/B747_script1.xml # 3. x153 JSBSim --nohighlight scripts/x153.xml # 4. c3104 JSBSim --nohighlight scripts/c3104.xml # 5. ah1s JSBSim --nohighlight scripts/ah1s_flight_test.xml # 6. orbit_torque JSBSim --nohighlight scripts/ball_orbit_g_torque.xml # 7. orbit_torque2 JSBSim --nohighlight scripts/ball_orbit_g_torque2.xml # 8. orbit JSBSim --nohighlight scripts/ball_orbit.xml 各负载的运行时间分别为 5.9s、14.7s、10.9s、11.3s、24.8s、8.0s、9.8s 和 8.4s,一共 93.9s,reftime 是 716s,对应 7.63 分。开 -O3 -march=native 仅对性能有 2% 的提升,-O3 -ljemalloc 反而能提升 4%,-O3 -flto 能提升 11%。LLVM 22 性能不如 GCC 14,这里就不赘述了。下面对各负载进行分析。
热点函数:
__sincos_fma 来自 libm:6.75%;__ieee754_atan2_fma 来自 libm:6.41%;__strncmp_avx2 来自 libc:5.04%;parse_path 来自 src/JSB-FlightSim/src/simgear/props/props.cxx:4.43%,路径字符串的解析,拆分成多个 component;__ieee754_pow_fma 来自 libm:4.05%。热点也挺神奇的,都是一些 libm/libc 的函数,flightdm 自己的代码耗时最多的居然是个路径解析。各种优化选项没啥效果,也不足为奇了。
热点函数:
SGPropertyNode::getDoubleValue 来自 src/JSB-FlightSim/src/simgear/props/props.cxx:5.65%,看起来是对配置文件的解析,然后从解析结果里提取浮点数;__ieee754_atan2_fma 来自 libm:5.42%;__sincos_fma 来自 libm:5.25%;依然没啥好分析的。
热点函数和 2. B747 相同,不再赘述。
热点函数:
SGPropertyNode::getDoubleValue 来自 src/JSB-FlightSim/src/simgear/props/props.cxx:8.45%,描述见上;JSBSim::aFunc::getValue 来自 src/JSB-FlightSim/src/math/FGFunction.cpp:7.20%,是一个带有 memo 能力的类似 std::function 的容器;__sincos_fma 来自 libm:6.04%;__ieee754_atan2_fma 来自 libm:5.35%;JSBSim::FGPropertyValue::getValue 来自 src/JSB-FlightSim/src/math/FGPropertyValue.cpp:5.11%,调用上面的 getDoubleValue 函数;给人的感觉就是,不是在调用 libm 计算一些超越函数,就是在做配置文件内容的提取。
热点函数:
__ieee754_atan2_fma 来自 libm:7.52%;__sincos_fma 来自 libm:6.82%;__strncmp_avx2 来自 libc:6.57%;parse_path 来自 src/JSB-FlightSim/src/simgear/props/props.cxx:6.12%,路径字符串的解析,拆分成多个 component;SGPropertyNode::getChild 来自 src/JSB-FlightSim/src/simgear/props/props.cxx:4.05%,遍历结点的子结点,通过字符串比较,找到匹配的子结点。热点函数与 6. orbit_torque 相同,不再赘述。
748.flightdm_r 是个没意思的基准测试,时间很多花在了 libm 和 libc 的函数上,自己的代码就是在配置文件里来回遍历,我愿称它为 libm 基准测试。除此之外,表现得更像一个 SPEC INT 2026 Rate 的负载:字符串操作,内存分配,很多小函数和 lambda,适合 -O3 -flto 优化。最后看一下 -O3 下各负载的情况:
| 负载 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 错误预测 (M) | MPKI |
|---|---|---|---|---|---|---|---|---|---|
| 1. weather | 5.9 | 106.1 | 30.8 | 15.4 | 19.5 | 12.9 | 0.6 | 11.6 | 0.11 |
| 2. B747 | 14.8 | 260.1 | 80.0 | 38.7 | 49.4 | 28.4 | 1.7 | 25.6 | 0.10 |
| 3. x153 | 10.8 | 193.3 | 59.1 | 28.7 | 37.3 | 20.0 | 1.0 | 20.9 | 0.11 |
| 4. c3104 | 11.4 | 194.6 | 58.9 | 29.1 | 35.7 | 23.9 | 1.3 | 18.2 | 0.09 |
| 5. ah1s | 24.7 | 407.3 | 130.0 | 61.3 | 77.9 | 46.4 | 1.6 | 49.3 | 0.12 |
| 6. orbit_torque | 7.9 | 152.8 | 41.9 | 22.7 | 28.3 | 16.3 | 1.1 | 24.2 | 0.16 |
| 7. orbit_torque2 | 9.9 | 191.4 | 52.5 | 28.4 | 35.3 | 21.0 | 1.2 | 17.1 | 0.09 |
| 8. orbit | 8.4 | 161.6 | 44.3 | 23.9 | 30.0 | 17.2 | 1.0 | 16.3 | 0.10 |
乏善可陈。
终于出现了一个 SPEC FP 2017 Rate 的老面孔,此前是 549.fotonik3d_r。fotonik3d 做的是 3D 空间里的麦克斯韦方程求解,又一个物理背景的基准测试,一般这种三维空间里的偏微分方程求解,必定会有 Stencil,下面看看这个猜测对不对。该基准测试只有一个负载:
reftime 是 1156s,在不同编译选项下,749.fotonik3d_r 的运行情况:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) | 指令数 (B) | Load 指令数 (B) | Store 指令数 (B) | 分支指令数 (B) | 浮点标量指令数 (B) | 浮点向量指令数 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 131.1 | 8.82 | 0 | 1408.5 | 375.1 | 120.7 | 30.9 | 5.4 | 527.2 |
GCC 14 -O3 -march=native
| 114.9 | 10.1 | 14 | 670.1 | 274.1 | 82.4 | 27.1 | 5.5 | 249.4 |
GCC 14 -O3 -ffast-math
| 116.7 | 9.91 | 12 | 1117.6 | 378.4 | 120.8 | 30.7 | 4.8 | 396.2 |
GCC 14 -O3 -ffast-math -march=native
| 108.5 | 10.65 | 21 | 599.5 | 276.3 | 82.3 | 26.9 | 4.8 | 204.8 |
LLVM 22 性能和 GCC 14 差不多,这里就不单列了。可见 -O3 -march=native 和 -O3 -ffast-math 都有不错的性能提升,下面进行热点分析:
power_dft 来自 src/power.F90:30.92%,进行的是离散傅里叶变化 DFT,主要瓶颈是在循环中进行双精度浮点乘加运算,GCC 14 把它编译成 SSE 的向量指令;UPML_updateE_simple 来自 src/UPML.F90:24.73%,主要时间在进行三维的 Stencil 计算,果然物理模拟都离不开 Stencil 计算,GCC 14 编译出 SSE 向量指令进行计算;UPML_updateH 来自 src/UPML.F90:23.26%,依然是 3D 的 Stencil 计算,采用 SSE 向量指令;mat_updateE 来自 src/material.F90:11.04%,同样是 Stencil 计算,采用 SSE 向量指令;updateH 来自 src/update.F90:9.78%,也是 Stencil 计算,采用 SSE 向量指令。由此可见,除了 power_dft 以外,大部分时间都在进行 Stencil 计算,这次 Stencil 计算的模式更加纯粹,因为 GCC 能够比较好地用 SSE 进行向量化。根据前面的经验,这类程序在 -O3 -march=native、-O3 -ffast-math 以及 -O3 -ffast-math -march=native 下都是有很大的提升的:
开启 -march=native 后,可以用更宽的 AVX2 向量,并行度更高,同时还能使用浮点乘加融合指令,例如 vfmaddsub231pd。
开启 -O3 -ffast-math 以后,power_dft 中的核心计算,实际上计算的是,复数乘以实数再加复数,如下面的 Fortran 代码所示:
subroutine update(Efreq1, Efreq2, expfuncE, Efield1, Efield2, n) implicit none integer, intent(in) :: n complex(8), intent(inout) :: Efreq1(n), Efreq2(n) complex(8), intent(in) :: expfuncE(n) real(8), intent(in) :: Efield1, Efield2 integer :: i do i = 1, n Efreq1(i) = Efreq1(i) + expfuncE(i) * Efield1 Efreq2(i) = Efreq2(i) + expfuncE(i) * Efield2 end do end subroutine update 在 -O3 时,GCC 14 会忠实地实现复数乘法,然而,实际上这里的 Efield1 和 Efield2 都是实数,转换过去的复数的虚部只能是零,因此通过 -O3 -ffast-math 的化简,直接把实部乘到 expfuncE 的实部和虚部即可,这样就可以简化指令。如果开 -O3 -ffast-math -march=native,将可以结合两个优化,直接用 AVX2 乘加融合指令 vfmadd213pd 完成这次运算,不需要像 -O3 -march=native 时用 vfmaddsub231pd 同时做加法和减法(原来的减,来自于复数乘法的定义,在这里减去的总是零,因为 Efield1/Efield2 的虚部是零),详见 Godbolt。
小结一下,749.fotonik3d_r 是经典的浮点应用,大量 Stencil 加浮点向量运算,并行度高,适合向量化,还能享受 -ffast-math 带来的浮点计算顺序优化。
又一个从 SPEC FP 2017 Rate 复活的基准测试,上一世是 554.roms_r,实现的是海洋模拟,不出意外依然是 Stencil,它只有一个负载:
reftime 是 1575s,不同编译器和编译选项下的运行情况:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) | 指令数 (B) | Load 指令数 (B) | Store 指令数 (B) | 分支指令数 (B) | 浮点标量指令数 (B) | 浮点向量指令数 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 169.8 | 9.28 | 0 | 2620.6 | 874.8 | 204.7 | 192.1 | 193.3 | 709.2 |
GCC 14 -O3 -march=native
| 149.5 | 10.5 | 14 | 1317.9 | 555.3 | 125.0 | 126.6 | 164.9 | 365.9 |
GCC 14 -O3 -ffast-math
| 162.8 | 9.67 | 4 | 2518.6 | 854.5 | 204.0 | 178.5 | 134.0 | 711.7 |
LLVM 22 -O3
| 165.6 | 9.51 | 3 | 2434.3 | 834.9 | 190.3 | 164.1 | 231.8 | 687.0 |
LLVM 22 -O3 -march=native
| 152.1 | 10.4 | 12 | 1423.4 | 551.4 | 131.2 | 140.1 | 259.8 | 350.0 |
从以上数据就可以看出,浮点计算很多,高度可向量化,因此 -O3 -march=native 的性能提升是很正常的。
热点函数:
step2d_tile,来自 src/step2d_LF_AM3.h:20.37%,主要瓶颈是 2D 的 Stencil 计算,向量化程度高;pre_step3d 来自 src/pre_step3d.F90:10.43%,主要瓶颈是在循环当中的浮点计算,向量化程度高;lmd_skpp 来自 src/lmd_skpp.F90:8.91%,主要瓶颈是循环中的复杂浮点计算,浮点标量计算为主;step3d_t_tile 来自 src/step3d_t.F90:7.04%,主要瓶颈是 3D 的 Stencil 计算,向量化程度高;rhs3d 来自 src/rhs3d.F90:6.04%,主要瓶颈是 2D 的 Stencil 计算,向量化程度高;t3dmix2 来自 src/t3dmix2_geo.h:5.86%,主要瓶颈是 3D Stencil 计算,向量化程度高;step3d_uv_tile 来自 src/step3d_uv.F90:5.85%,主要瓶颈是 3D Stencil 计算,向量化程度高;_ZGVbN2v_exp_sse4 来自 libmvec:4.66%,向量化版本的 exp。还是典型的 Stencil 计算,向量化程度高。开 -O3 -march=native 后,向量宽度增加,加上 FMA 的引入,自然带来了不错的性能提升。
femflow 是流体动力学求解器,求解 Navier-Stokes 方程。该基准测试只包括一个负载:
reftime 是 1467s,不同编译器和编译选项下的运行情况:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) | 指令数 (B) | Load 指令数 (B) | Store 指令数 (B) | 分支指令数 (B) | 浮点标量指令数 (B) | 浮点向量指令数 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 188.7 | 7.77 | 0 | 3862.4 | 1358.5 | 797.6 | 117.5 | 562.2 | 676.0 |
GCC 14 -O3 -march=native
| 95.1 | 15.4 | 98 | 1736.9 | 619.3 | 356.0 | 65.2 | 286.8 | 445.4 |
GCC 16 -O3
| 153.6 | 9.55 | 23 | 3178.6 | 1109.3 | 673.3 | 127.2 | 56.3 | 930.9 |
GCC 16 -O3 -march=native
| 83.5 | 17.57 | 126 | 1457.0 | 501.1 | 281.4 | 61.1 | 47.2 | 545.7 |
LLVM 22 -O3
| 124.7 | 11.8 | 51 | 2703.0 | 857.3 | 475.5 | 60.6 | 40.8 | 930.3 |
LLVM 22 -O3 -march=native
| 88.7 | 16.5 | 113 | 1392.9 | 495.7 | 269.4 | 42.9 | 41.8 | 471.1 |
可见,LLVM 22 相比 GCC 14 有显著的性能提升,同时 -O3 -march=native 带来了更加显著的性能提升,是整个 SPEC FP 2026 Rate 当中,-O3 -march=native 带来提升第二高的基准测试,第一高是后面会看到的 772.marian_r。GCC 16 相比 GCC 14 也有不错的性能提升,开 -O3 -march=native 后反超 LLVM 22。
热点函数还不少,很多函数都是个位数百分比的占用,大多是一些算子:
Laplace::LaplaceOperator::local_apply_quadratic_geo 来自 src/laplace_operator.h:5.49%,内部是大量的浮点向量计算,并行度高;operator *(const dealii::VectorizedArray &, const dealii::VectorizedArray &) 来自 src/dealii/include/deal.ll/base/vectorization.h:5.36%,两个向量的逐元素乘法。其他还有一些 dealii:Tensor 的计算,包括来自 src/dealii/include/deal.ll/matrix_free/tensor_product_kernels.h 的 dealii::internal::even_odd_apply,是 Tensor 双精度浮点乘法的实现,这里 even-odd 的意思是利用数据的对称性,把数据拆成 even 和 odd 两部分进行计算,可以节省计算次数,同时适合向量化。对于这类负载,-O3 -march=native 开启后,更快的向量长度带来了更好的浮点运算性能,同时还有 FMA 指令的加持。
LLVM 22 相比 GCC 14 的优势,主要来自于把更多代码进行了向量化,对比 GCC 14 和 LLVM 22 执行的指令数,可以看到 LLVM 22 执行的浮点标量指令数比 GCC 14 要少,而浮点向量指令又要多。GCC 16 也是类似的情况,向量化程度逼近 LLVM 22。
nest 是个脉冲神经网络的模拟器,忽然出现一个熟悉的面孔,也挺难得。该基准测试分为三个负载:
# 1. cuba nest_r cuba_stdp.sli # 2. structural nest_r structural_plasticity_benchmark # 3. Artificial nest_r ArtificialSynchrony 开 -O3 -march=native 只有 3% 的性能提升,LLVM 22 比 GCC 14 更慢,这里就不进行编译器和编译选项的对比了。三个负载在 GCC 14 -O3 下的对比:
| 负载 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) |
|---|---|---|---|---|---|---|---|
| 1. cuba | 14.1 | 176.3 | 54.5 | 21.6 | 22.4 | 29.2 | 0.0 |
| 2. structural | 24.6 | 413.3 | 136.3 | 42.8 | 52.5 | 93.2 | 0.0 |
| 3. Artificial | 48.6 | 1125.4 | 392.6 | 150.5 | 160.5 | 163.6 | 0.0 |
总时间 87.4s,reftime 是 793s,对应 9.07 分。下面进行负载的具体分析。
热点函数:
nest::iaf_psc_exp::handle 来自 src/nest-simulator/models/iaf_psc_exp.cpp:25.75%,处理该神经元接收到的脉冲,更新内部状态,主要瓶颈是间接访存,把脉冲的强度写入到对应的输入缓存区;__ieee754_pow_fma 来自 libm:11.96%,被后面的 nest::Connector::send 函数调用;spec::poisson_distribution::operator() 来自 src/specrand-distributions/spec_random_distributions.cpp:9.87%,生成随机数,以生成输入的脉冲;nest::Connector::send 来自 src/nest-simulator/nestkernel/connector_base.h:8.29%,负责脉冲在突触上的传播和 STDP,主要瓶颈是间接访存,以及内联了一些脉冲上的权重计算,还会调用 pow 和 exp;nest::iaf_psc_exp::update 来自 src/nest-simulator/models/iaf_psc_exp.cpp:6.91%,在每个时间步对神经元的状态进行更新,主要是标量的浮点运算。算是一个比较经典的带 STDP 的 SNN 模拟,主要瓶颈就是脉冲传播和 STDP 的突触权重更新,向量化程度很低,还有间接访存。
热点函数:
spec::poisson_distribution::operator() 来自 src/specrand-distributions/spec_random_distributions.cpp:24.26%,描述见上;nest::iaf_psc_alpha::update 来自 src/nest-simulator/models/iaf_psc_alpha.cpp:13.71%,做的事情和上面 nest::iaf_psc_exp::update 类似,就是换了个神经元模型;__ieee754_pow_fma 来自 libm:13.37%,描述见上;nest::GrowthCurveGaussian::update 来自 src/nest-simulator/nestkernel/growth_curve.cpp:6.60%,主要在用数值计算求解微分方程,频繁调用 exp 和 pow;nest::iaf_psc_alpha::handle 来自 src/nest-simulator/models/iaf_psc_alpha.cpp:25.75%,功能和上面 nest::iaf_psc_exp::handle 类似;nest::Connector::send 来自 src/nest-simulator/nestkernel/connector_base.h:6.60%,描述见上,这次没有 STDP,权重是静态的;exp 来自 libm:5.39%。和 1. cuba 相比,换了一个神经元模型,去掉了 STDP,结果主要的瓶颈跑到了泊松分布的随机生成,其余部分还是比较典型的 SNN 模拟。
热点函数:
nest::iaf_psc_alpha_ps::update 来自 src/nest-simulator/models/iaf_psc_alpha_ps.cpp:13.26%,神经元的状态更新函数;nest::iaf_psc_alpha::update 来自 src/iaf_psc_alpha.cpp:12.37%,描述见上;nest::Connector::send 来自 src/nest-simulator/nestkernel/connector_base.h:7.19%,描述见上,这次依然没有 STDP,权重是静态的;nest::SimulationManager::update_ 来自 src/nest-simulator/nestkernel/simulation_manager.cpp:5.66%,核心的 SNN 模拟循环,调用上面的各种函数。__ieee754_pow_fma 来自 libm:5.17%,描述见上。研究 SNN 的应该很熟悉,nest 是个很灵活的 SNN 模拟器,但单线程性能也确实不咋地,主要精力花在了多核/多线程上。不出所料,nest 的神经元更新部分没有向量化,所以挺慢的,而脉冲传播和 STDP 部分本来就很难优化。总之,这是个难以向量化的浮点应用,从上面的性能计数器来看,一条向量浮点指令都没有。
marian_r 是一个基于神经网络的翻译器,又是一个神经网络推理,意味着又是一个 -O3 -march=native 非常有优势的测例,如果像 706.stockfish_r 那样有直接可以用的硬件加速指令,性能将会比 -O3 快得多。该基准测试包括两个负载:
# 1. TildeMODEL marian-decoder --cpu-threads 1 -m model.alphas.npz -v vocab.spm vocab.spm --beam-size 1 --mini-batch 32 --maxi-batch 100 --maxi-batch-sort src -w 512 --skip-cost --gemm-type intgemm8 --intgemm-options precomputed-alpha standard-only --quiet --quiet-translation -i TildeMODEL-spec.en --log TildeMODEL-spec.log --log-level off -o TildeMODEL-spec.out # 2. EuroPat marian-decoder --cpu-threads 1 -m model.alphas.npz -v vocab.spm vocab.spm --beam-size 1 --mini-batch 32 --maxi-batch 100 --maxi-batch-sort src -w 512 --skip-cost --gemm-type intgemm8 --intgemm-options precomputed-alpha standard-only --quiet --quiet-translation -i EuroPat-spec.en --log EuroPat-spec.log --log-level off -o EuroPat-spec.out reftime 是 1579s,下面是不同编译器版本和编译选项的对比:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) | 1. TildeMODEL 时间 (s) | 2. EuroPat 时间 (s) |
|---|---|---|---|---|---|
GCC 14 -O3
| 235.2 | 6.71 | 0 | 88.8 | 146.4 |
GCC 14 -O3 -march=native
| 78.4 | 20.14 | 200 | 28.2 | 50.3 |
GCC 15 -O3
| 150.1 | 10.52 | 57 | 56.0 | 94.8 |
GCC 15 -O3 -march=native
| 77.5 | 20.37 | 203 | 27.8 | 49.7 |
可见 -O3 -march=native 带来的提升巨大,高达 200%,在 Apple M1 上有 47% 的提升,在 Apple M2 上更是提升了 92%,这种提升,之前只在 706.stockfish_r 上见到过。并且 GCC 15 也比 GCC 14 在 -O3 时有明显性能提升。下面分负载来讨论。
热点函数:
marian::cpu::integer::affineOrDotTyped 来自 src/marian/tensors/cpu/intgemm_interface.h:82.28%,主要时间在 tiled_gemm 函数里,做的是整数矩阵乘法,uint8_t 类型的 A 矩阵乘以 int8_t 类型的 B 矩阵,累加到 int32_t 类型,最后转换到 float 再加 float 的 C 矩阵;marian::cpu::ProdBatched 来自 src/marian/tensors/cpu/prod.cpp:10.30%,核心部分是 sgemm,这次确实是浮点的矩阵运算了,虽然被编译成了 SSE 的标量的浮点计算而不是向量,但考虑到时间占比,也无伤大雅了。可以看到,主要的热点部分,和 706.stockfish_r 的 nnue 的计算模式完全一样,因此开 -O3 -march=native 后,一样可以用 AVX-VNNI 的 vpdpbusd 指令优化,见 Godbolt。同理 GCC 15 因为更优的无符号扩展实现方式,性能比 GCC 14 要更好。具体的讨论,可以见之前 INT Rate 篇 中 706.stockfish_r 的部分。
不同编译器和编译选项下的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 128 位整数向量 (B) | 256 位整数向量 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 88.2 | 2038.9 | 217.8 | 57.8 | 53.2 | 58.7 | 2.1 | 514.6 | 0.0 |
GCC 14 -O3 -march=native
| 27.6 | 423.0 | 131.5 | 25.1 | 47.4 | 59.8 | 1.1 | 12.8 | 47.4 |
GCC 15 -O3
| 55.6 | 1353.5 | 173.9 | 22.1 | 53.2 | 58.7 | 2.1 | 184.7 | 0.0 |
GCC 15 -O3 -march=native
| 27.3 | 415.1 | 128.9 | 23.5 | 47.5 | 59.8 | 1.1 | 12.8 | 47.4 |
其中 128 位整数向量来自 int_vec_retired.128bit 计数器,256 位整数向量来自 int_vec_retired.256bit 计数器。
热点函数:
marian::cpu::integer::affineOrDotTyped:78.96%,描述见上;marian::cpu::ProdBatched:14.25%,描述见上。热点函数和 1. TileMODEL 完全相同,其余的分析对 2. EuroPat 也是成立的,这里直接给出性能计数器的对比:
不同编译器和编译选项下的对比:
| 编译器 + 选项 | 时间 (s) | 指令 (B) | Load (B) | Store (B) | 分支 (B) | 浮点标量 (B) | 浮点向量 (B) | 128 位整数向量 (B) | 256 位整数向量 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 145.6 | 3352.7 | 370.4 | 89.7 | 98.8 | 123.8 | 3.6 | 815.0 | 0.0 |
GCC 14 -O3 -march=native
| 49.7 | 777.2 | 228.7 | 36.6 | 88.3 | 123.9 | 1.7 | 19.9 | 72.6 |
GCC 15 -O3
| 94.2 | 2268.5 | 301.7 | 33.1 | 98.8 | 123.8 | 3.6 | 293.6 | 0.0 |
GCC 15 -O3 -march=native
| 49.0 | 765.3 | 225.2 | 34.3 | 88.3 | 123.9 | 1.7 | 19.9 | 72.6 |
772.marian_r 鉴定为 706.stockfish_r 的 NNUE 翻版,热点就是 int8_t 乘 uint8_t 累加到 int32_t 的矩阵乘运算,整数向量指令比浮点指令还多,建议开除 SPEC FP 2026 Rate 籍。
lbm 是 lattice boltzmann method 的缩写,又是一个流体动力学的应用,依然是 Stencil。该基准测试只有一个负载:
reftime 是 573s,不同编译选项下的性能对比:
| 编译器 + 选项 | 时间 (s) | 分数 | 相比 GCC 14 -O3 性能提升 (%) | 指令数 (B) | Load 指令数 (B) | Store 指令数 (B) | 分支指令数 (B) | 浮点标量指令数 (B) | 浮点向量指令数 (B) |
|---|---|---|---|---|---|---|---|---|---|
GCC 14 -O3
| 105.8 | 5.42 | 0 | 2232.2 | 473.3 | 242.4 | 14.5 | 1108.2 | 0.0 |
GCC 14 -O3 -ffast-math
| 95.8 | 5.98 | 10 | 1892.4 | 419.2 | 192.8 | 14.5 | 1009.5 | 0.0 |
GCC 14 -O3 -march=native
| 131.0 | 4.37 | -19 | 1669.6 | 550.3 | 309.8 | 14.5 | 1228.8 | 0.0 |
GCC 15 -O3
| 105.2 | 5.45 | 0.6 | 2218.9 | 468.9 | 242.4 | 14.5 | 1108.2 | 0.0 |
GCC 15 -O3 -march=native
| 111.0 | 5.16 | -5 | 1777.3 | 509.8 | 282.9 | 14.5 | 1108.2 | 0.0 |
GCC 16 -O3
| 105.4 | 5.44 | 0.4 | 2218.9 | 468.9 | 242.4 | 14.5 | 1108.2 | 0.0 |
GCC 16 -O3 -march=native
| 110.6 | 5.18 | -4 | 1777.3 | 509.8 | 282.9 | 14.5 | 1108.2 | 0.0 |
热点函数只有一个,就是 LBM_performStreamCollideTRT 函数来自 src/lbm.c,占了 99.35% 的时间。其结构是从当前轮次 Grid 读取、大量浮点计算、写入下一轮次 Grid,中间还有分支判断,访存为跨步(strided)模式,难以向量化,生成的都是 SSE 标量指令。对于这种标量计算密集的情况,-O3 -ffast-math 通常能通过调整计算顺序、复用中间结果来节省一些计算。
开启 -O3 -march=native 后性能反而下降,GCC 14 倒退最多(-19%),GCC 15/16 稍好但也不如 -O3。分析汇编,推测是因为对栈的访存指令变多,抵消了 FMA 乘加融合减少指令数的优势,详见 Godbolt。注意 FMA 指令在上述表格的浮点标量指令数一栏会计数两次,在总指令数一栏只会计数一次。
综合来看,编译选项对 SPEC FP 2026 Rate 的性能影响同样不小:
-march=native 对很多基准测试有不错的性能提升。毕竟 AVX2 相比 SSE 不仅在宽度上拓宽,还增加了很多好用的指令,可以减少指令数,还有 AVX-VNNI 这种对 772.marian_r 特攻的;-ffast-math 也有不错的提升,尤其 SPEC FP 2026 Rate 有不少浮点运算,完全按照源码的编写方式去计算,往往不如调整运算顺序后来得快。但也要注意,-ffast-math 可能会导致计算结果不符合 IEEE 754 标准。-flto 和 -ljemalloc 对 SPEC FP 2026 Rate 的多数基准测试效果不大,但对 748.flightdm_r 有些许提升。还有一些常用的编译参数,比如 -static、-fomit-frame-pointer 等等,目前没有做太多测试,以后说不定会加上。
SPEC FP 2026 Rate 中 MPKI 特别高的只有 731.astcenc_r 和 737.gmsh_r,其他最高也就是 767.nest_r 的 0.87。731.astcenc_r 如此的高,完全是 GCC 14 编译的锅,换成 LLVM 22 立马就正常了,希望后续 GCC 能修一修。
本文深入分析了 SPEC CPU 2026 中 FP Rate 的负载,供编译器和处理器的设计者参考。从编译器的角度来说,可以集 GCC 和 LLVM 之长,进一步提升性能;从处理器的角度来说,针对程序的瓶颈进行优化,也能进一步提高分数。
2026-05-22 08:00:00
最近用 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
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 分,分数提升显著。下面逐一分析这三个负载的性能特性。
通过 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 本身和前后保存和恢复寄存器的开销。
后两个负载的引擎从 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 性能计数器),优化效果明显。
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,并不算高。
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) 函数当中。性能区别有两点:
if (neighbors[sq]&enemy) 条件成立的情况下,需要执行复杂函数体,需要 callee-saved 寄存器时才会进行 push/pop,否则就直接 ret,因为检查条件的时候并没有用到 callee-saved 寄存器,避免了保存和恢复。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 的普及程度就要大得多了。
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 甚至带来了负优化。下面逐一分析这三个负载的性能特性。
通过 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。
通过 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,处于很低的水平。
通过 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 贡献。
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 分。
首先分析第一个负载的热点函数:
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 |
用 perf 观察,其余 9 个 queuenet 负载的瓶颈主要集中在这些函数:
__strcmp_avx2)__do_dyncast 和 __dynamic_cast)__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。虽然还是同一个框架,但是负载行为还是出现了明显的变化。
前面才提到过解释器,这就到 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 有负优化。下面具体分析三个负载的负载特性。
还是用 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 次。
统计出热点函数,发现前四依然是上面四个,且时间占比差不多。可能是因为,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 次。
统计热点函数:
_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。
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。
三次运行的性能计数器如下:
2.2B/470.2B*1000=4.68
0.91B/243.4B*1000=3.74
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 更快。
随着 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 编译的运行更快,不过优势并不大。下面针对这两个负载进行具体的分析。
使用 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 也够呛。
使用 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 第三高的项目。
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 分。
下面对这三个负载进行深入的分析。
热点函数如下:
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,不算高。
热点函数类似:
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,也不算高。
热点如下:
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,说明大部分字符串匹配的结果都是很好预测的,比如比较到第一个字节就对不上了。
之前第一次看到 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% 以内,属于是油盐不进,各种优化都难以生效。下面进行具体热点分析。
主要的热点函数:
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 的几处变量取值的判断逻辑,都是依赖数据的分支,难以预测。
主要的热点函数:
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 来说低了很多。
热点函数依然是 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,非常高。
热点函数依然是 sat solver,整体特性一致。运行 490.0B 条指令,Load 指令有 143.9B,Store 指令有 54.4B,分支有 76.9B,错误预测 2092.8M 次,MPKI 等于 2092.8M/490B*1000=4.27,还是很高。
热点函数终于有了新面孔:
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,不低。
再次出现了新的热点函数:
__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。
接下来就到了 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% 的提升。
下面进行具体分析。
因为这两个负载都是做的布局(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 还会更高。
到了布线,热点函数出现了一些不同:
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,处于中游偏高的水平。
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%。仅对第一个负载有加速效果。看到这个性能提升的幅度,结合前面的经验,已经可以预估一下后面会见到的瓶颈大概是什么类型了。
第一个负载是用 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,比较低。
第二个负载则是把 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,比较低。
热点函数:
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,明显变高。
相比 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 负载贡献。
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 和 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%。下面来进行具体的分析。
热点分析:
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 红黑树的插入算法。
第二个负载测的又是不一样的代码了,这次的热点函数:
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 红黑树的插入和删除算法。
第三个负载测的又是不一样的代码了,这次的热点函数:
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 红黑树的插入和删除算法。
第四个负载测的又是不一样的代码了,这次的热点函数:
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 红黑树的插入和删除算法。
热点函数就不列举了,基本还是内存分配,外加 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。
热点函数除了内存分配,多了 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。
作为 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 较高的有:
作为对比,SPEC INT 2017 Rate 的情况:
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 之长,进一步提升性能;从处理器的角度来说,针对程序的瓶颈进行优化,也能进一步提高分数。
2026-05-21 08:00:00
SPEC CPU 2026 官方只附带了 aarch64/ppc64le/riscv64/x86_64 指令集的预编译 tools,如果要在其他指令集上使用,就需要首先编译 tools,过程如下:
cd /mnt && tar xvf install_archives/tools-src.tar wget -O config.guess 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.guess;hb=HEAD' wget -O config.sub 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.sub;hb=HEAD' cp config.* /mnt/tools/src/make-4.2.1/config/ # build tools mkdir -p /mnt/config cd /mnt && echo 'y' | SKIPTOOLSINTRO=1 FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS=-j16 ./tools/src/buildtools mkdir -p /mnt/config cd /mnt && . ./shrc && packagetools linux-loong64 例如下面是在 LoongArch 上编译 SPEC CPU 2026 的 Dockerfile,假设 SPEC CPU 2026 已经解压到 /mnt:
RUN cd /mnt && tar xvf install_archives/tools-src.tar RUN wget -O config.guess 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.guess;hb=HEAD' RUN wget -O config.sub 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.sub;hb=HEAD' RUN cp config.* /mnt/tools/src/make-4.2.1/config/ # build tools RUN mkdir -p /mnt/config RUN cd /mnt && echo 'y' | SKIPTOOLSINTRO=1 FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS=-j16 ./tools/src/buildtools RUN mkdir -p /mnt/config RUN cd /mnt && . ./shrc && packagetools linux-loong64 RUN /mnt/install.sh -f 2026-05-07 08:00:00
每次没有 UPS 或 UPS 容量不够用的倒闸对于运维来说都是一次鸡飞狗跳。这次很不幸,鸡飞狗跳终于轮到了我,还好花了一个半小时还是解决了。在这里做个简单的复盘。
首先介绍一下现象:停电恢复之后,服务器开起来,但是无法从互联网连接内网的网关(即主网关)。还好,之前考虑到网关的重要性,做了备份,走内网的备用网关顺利进入了内网,然后发现主网关即使 IP 地址是对的,也连不上互联网。虽然通过 keepalived 的配置,主网关和备用网关会通过虚拟 IP 给内网机器提供一个高可用的默认网关,但由于 keepalived 只是检测了机器是否开机,并没有判断能否正常访问互联网,所以 keepalived 总会选择优先级更高的主网关,导致虚拟 IP 指向主网关,因而内网的机器都连不上互联网,还得继续尝试修复主网关。
主网关运行在 ESXi 的虚拟机里,于是进入 ESXi 管理网页,看看它的网络情况。这个虚拟机的网络用的并不是 ESXi 的普通网络,而是通过 vCSA 配置的基于 DS 的 LACP。看了几个不同的 ESXi,发现问题都集中在 LACP 上。而 ESXi 是没法配置 DS 的,所以就先去看了 vCSA。连上 vCSA 以后一看,所有的 ESXi 都掉线了。原来,之前为了方便配置,vCSA 都是通过域名连接 ESXi 的,而域名就需要有 DNS 服务器解析,然而主网关连不上互联网,也就连不上配置好的互联网的 DNS 服务器,于是 vCSA 无法配置 ESXi,然后 ESXi 的部分虚拟机就会断网,正好主网关又在被断网的虚拟机里面。这就形成了一个循环依赖。
既然找到了问题,那就需要打破循环依赖:把主网关在 keepalived 里的优先级调低,让备用网关上位。结果这时候发现一个小的问题:备用网关的 NAT 忽然不工作了。排查了一下,发现是因为 net.ipv4.ip_forward = 1 写在了 /etc/sysctl.conf 文件里,而 Debian 升级 Trixie 以后,这个文件已经不会被应用了,而要把内容写到 /etc/sysctl.d/*.conf 里面去,通过 /usr/lib/systemd/systemd-sysctl --cat-config 来确认是否持久化成功。由于主网关一直工作得很好,备用网关很久都没有做 NAT 了,导致这个问题一直没有被发现。
修好以后,vCSA 就能找回 ESXi 了,然后通过 vCSA 再重新配置一下 ESXi 的 DS LACP 网卡,然后一切就恢复了。
虽然事故解决了,但这个过程中暴露了很多问题:
2026-04-12 08:00:00
前几天参加了系里的关于 AI 时代的 CS 教育的研究生论坛,在论坛上我分享了一些小的思考,也在论坛上得到了许多不同的想法,于是把一些想法记录下来,过一段时间再回来看看,到底 CS 教育应该怎么办。
本文仅代表本人观点,不代表本系或本校的观点,请勿扩大解读!请不要让我上 AI 三大顶会(机、量、新),谢谢!但欢迎大家参与到这个讨论当中,因为目前谁也不知道未来应该怎么做。
为了让读者了解背景,首先要知道前 AI 时代的 CS 教育大概是怎样的:本科的时候先上编程课,教大家各种编程语言,然后逐渐深入到各个领域,课上讲授知识点,课下通过工程训练来夯实,由于计算机是工科,这里面通过不断的工程实践来获取经验,是很重要的一个部分。这一部分学习过程很辛苦,但是确实很有效果,可以说几乎每一位系友都是这么锻炼过来的。
下面这一段,如果你还在读本科,请不要点开,点开了也请忘掉,按照老师的要求去做:
但是,现在 AI 时代来临,很多事情都发生了变化。首先,AI 编程能力很强,大一同学辛辛苦苦学完一年,然后发现自己写的代码还不如 AI 写得好写得快,内心的挫败感和对这种古法编程的学习方法的质疑是无与伦比的。这对课程的教学产生了很大的冲击,因为人很难克制自己的懒惰,面对巨大的诱惑,其实很难静下心去学习这些已经由 AI 掌握的基础课程。论坛上有同学做了个比喻,计算器被发明了以后,人类没有失去心算的能力,因为你为了去用好计算器,还是要知道这些基础知识,从小学起,然后到某一个年级告诉你可以用计算器,然后各种考试还可以出计算器没法解决的题目。但是,AI 的能力边界太大了,它能解决从简单到困难的各种问题,只是有一定的概率解决出来是错的。其次,即使是前几年我们还会觉得,专业核心课的大作业还很难由 AI 完成,似乎还能通过大作业的难度来倒逼大家学习,但在今年也纷纷沦陷,对于学生来说,只要愿意,完全可以自己不写一行代码,纯让 AI 写一个能通过所有测试的作业,自己完全不了解内部是怎么实现的,用很短的时间完成作业。而且还不好去举证,说这一定是 AI 写的。这一点在这次论坛上,不同课程的助教都做了类似的实验,证明了这一点。虽然发这篇博客可能会让一些本科同学看到,然后不好好写大作业,但还是希望更多教育工作者可以看到并参与讨论。如果你是正在上课的同学,就自觉忘记吧。
那么,应该怎么办呢?在这里阐述一些我的观点。一个大的前提是,肯定不能完全禁止 AI,也不能完全依赖 AI,需要辩证地把 AI 引入到 CS 教育当中。
首先是关于 CS 教育要培养出什么样的人才。之前,我们要培养的一方面是工程师,在长时间的工程实践当中积累经验,通过自己的经验,可以打造出一个很完备的系统,功能完善,可靠安全。但其实细分看来,在系统的搭建当中,其实有偏向于顶层设计的架构师,也有偏向于具体实现的工程师。目前 AI 已经可以很快地针对一个给定的 Plan 去做实现,并且实现得还不错,但是从需求到 Plan 的这一步,其实还需要人类的专家知识,因为实际的需求往往很复杂,会有许多大模型没有学过的假设与背景,这需要架构师脑子里把架构想清楚,知道哪里应该怎么做,然后把一部分的工程实现外包给 AI,自己再保证它的实现质量,确保它忠实地实现了所设计的架构,并且实现的系统是可靠安全的。用 AI 写代码很容易,但是写出来复杂可靠的软硬件系统,依然不是容易的事情。另一方面是科学家,在科研方面,科研的品味(Taste)变得更加重要,因为许多科研,其工程量本来就很小,完全可以由 AI 代劳,那么谁能够找到正确的路径,谁才能更好地与 AI 协作,完成科研。换句话说,以后的每个科研工作者,可能自己都是通讯作者,手下是一堆 AI 博士生在做实验,自己提出研究的思路,由 AI 实现和写作,然后自己来保证整个过程的正确性和学术伦理。无论是哪个方向,重点都从以前的知道某个东西“是什么”,变成了“为什么”,进而能够判断“对不对”。论坛上有同学总结得好,人类会更多地变成一个鉴别器(Discriminator)。
那么,具体到课程上,应该怎么做呢?其实我也没有想太明白,需要在未来几年里通过实践来不断修正。目前的一些初步的想法主要有下面这几点:
首先,作业已经不再能区分同学,不能代表同学对知识的掌握情况,只能代表 AI 对知识的掌握情况。所以作业已经完全沦为 AI 的课后送分小练习,在目前这个卷绩点的氛围下,让大家都开开心心地拿作业满分,也是越来越普遍了。如果真的想要通过作业来督促同学进行学习,那就必须回归作为人类的基本功,就是通过更多的线下的口语、展示和对话,以最“人味”的方式对抗 AI 的“机味”。事实上,在目前这个时代,其实如何扩大自己的影响力,也是很重要的技能,真的是酒香也怕巷子深,如何能够让大家看到你,抓住大家的注意力(Attention),很多时候会比你做出来的东西有多好更重要。这些能力,其实是值得通过作业的设计来培养的。我在本科的时候,尝试选了一次演讲的课程,当时看到作业要求,人直接麻了:需要每个人在班级所有人面前做演讲,这对于当时还比较社恐的我,由于太过害怕直接退课了。现在想想,其实都是小意思,当你迈出那一步以后,会发现懂得大大方方展示自己,真的是很重要的能力,是 AI 暂时还无法取代的能力。
既然作业沦陷了,那么,怎么打分呢?难道让每个人都能拿到满绩点?几年前,我在和大一新生聊天,他们就对这个打分的事情感到困惑,因为在目前这个绩点膨胀的时代里,好像很多课程拿满绩点都是天经地义的事情,如果你这个课不给我满绩点,我就要给你打教评低分。但是,又有不少东西和绩点挂钩,奖学金,保研等等。老师当然可以撒手不管,让所有人满绩点,但这只是让竞争延后、转化为其他领域了而已,不比绩点,那就比谁更能在本科的时候做科研,打比赛等等。另一方面,打分也是一个很重要的督促学习的手段,还是一样的前提,人类是很难抵抗自己的懒惰的,如果不是为了毕业以后有更好的发展,可能会有很多人放弃毕业、放弃学习。以前,为了能够顺利毕业,还会咬咬牙做一些比较困难的学习,甚至可能是自己不喜欢的;现在可以用 AI 糊弄了,那就糊弄过去,反正分数不错,能给父母交差,大环境也不好,然后就陷入了虚无主义,一泻千里。所以,似乎考试成为了最后的防线,还能在一定程度上督促学习。
但其实考试也受到了巨大的冲击。第一个问题就是,考试是否允许使用 AI 呢?许多 CS 课程,未来都会或多或少地引入一些 AI,那么学生对 AI 的掌握程度,也是一个需要考核的能力。但目前不同厂商的 AI 的可用性与性能差距过大,“AI 平权”会成为一个新的问题,我们希望比的是谁更会用 AI,而不是谁能用上更好的 AI。就像高考作文要考虑贫富差距一样,本科课程的考试也会面临类似的问题。一种可能性是在考试的时候提供统一的 AI 访问,但目前 AI 生态还是比较混乱,指定一个 AI 让大家用,其实也很容易出现与学生平时使用工具或生态不兼容的问题,而且学校自己部署一个同时几百上千人同时用的 AI 服务,也不是一件容易的事情,希望未来有云厂商可以提供类似的服务,并且能够控制住成本,其实就是一个持续两小时的上百 QPS 的专属推理服务。如果要类比的话,其他一些学科允许使用计算器,出题的时候可以规避,但 AI 能做的事情太杂了,其实很难针对。
另一方面,如果禁止 AI 的话,也有很多问题。首先是没法考察学生的 AI 使用能力,这个在未来会更加重要。其次,学生自己会比较难接受,先给了 AI 这么方便的工具,结果期末考试又要古法做一遍,最后结果可能就是学期中都在用 AI,只有考试前一周突击一下,考完就忘了,当然,好像现在很多人也是这样呢。而且课程很容易被贴上“不与时俱进”的标签,就如那些用十几年前课件的课程一样。现在这个过渡时期,大家都知道会变,但是怎么变并没有达成共识,所以一定会有一个阵痛期。如果你是刚上本科,或者马上要上本科的高中同学,那就要做好成为小白鼠的准备了。此外,随着本地模型的发展,如果让学生带电脑,即使不给联网,有更好的独立显卡的同学,事实上可以通过电脑配置的优势转化为分数,这也会带来新的不公平性。
当然,也不是毫无希望,比如前面说的,加一些有“人味”的考核,唯一的缺点是人力需求较大,难以扩展;或者允许使用 AI,但是必须提交完整的 AI 使用记录,这一点很多地方已经在实践;出题的时候,可能也要想办法去考察学生的思路,一些可以由 AI 完成的作业,不如就直接让学生用 AI 做,变成考察 AI 使用能力的题目。
以上基本是我在论坛上所展示的内容,下面也分享一些我在论坛上了解到的一些情况,以及所引发的思考。
首先,这次论坛不仅有大量的研究生助教参与,也有许多一线的教学老师参与了讨论。其实老师们感受到的冲击也很直接,因为可能就是从 2025-2026 开始,就有一批学生可以完全不接触古法编程,直接上手写代码,用一种完全不同的学习方法来学习各种课程内容。有的人可以很好地利用 AI 加速自己的学习,比如之前需要花费很多时间做的工程实践,现在可以在相同时间内用 AI 做更多的实践,一样可以获得很多甚至更多的实践经验。有的人就完全依赖 AI,可以糙快猛地完成很多事情,但对内部工作一概不知,能做的事情完全取决于 AI 的能力边界,同时自己又缺乏很多基础知识,可以说上知天文下知地理,但是四体不勤五谷不分。现在大家心里没底的就是,AI 的能力是否可以无限扩展,自己只需要站在 AI 的肩膀上,坐等 AI 发火箭上月球就行;还是需要脚踏实地,踩着地月天梯去月球。
咱也不知道答案,就在实践中前行吧。
也顺带聊聊 CS 以外的教育吧,其实它们受到的冲击并不比 CS 少。但从某种意义来说,对于很多学科而言,AI 给每个人都带来了特别强大的工具,而且由于本来也不是学 CS 的,用 AI 能写出以前自己写不出来的代码,一下就把能力范围拓宽了。即便受到 AI 能力的限制,但反正自己也不是干这行的,本来也达不到那个上限,自然也就无所谓了。所以其实在 AI 时代,CS 以外的学科,都很值得学会怎么用 AI,给自己的学科赋能。比如论坛上有同学举了个例子,像写网站这种事,几天之内就能由来自不同学科、可能完全没有基础的同学,各自写出不同的校内交友相亲网站,而且还能让大模型帮忙做运维。好的想法、合适的商机、宣传和包装,这些才是更重要的,不用担心自己做不出来。
2026-03-26 08:00:00
最近在和 @CircuitCoder 交流 SDRAM(通常简写为 DRAM,或更进一步简写为 DDR)的各种性能指标,于是想到利用现有的 DRAMSim3 和 Ramulator2 做一些模拟测试,看看各种访存模式下可以实现峰值带宽的多少比例,再结合时序验证理论与模拟结果是否吻合。实验相关代码已开源至 jiegec/dram-bench。
首先简单回顾 SDRAM 的背景,我的知识库中有更详细的介绍,这里仅提炼几个便于理解后续内容的要点,完整的 SDRAM 介绍请移步知识库:
[33:18] 位,共 65536 个 Row[17:17] 位,共 2 个 Rank[16:15] 位,每个 Bank Group 内有 4 个 Bank[14:13] 位,共 4 个 Bank Group[12:6] 位,共 1024 个 Column,每 8 个 Column 为一个 Burst首先考虑最经典的顺序访存,从地址 0 开始,以 64 字节为跨步访问。直觉上顺序访存似乎能实现最大带宽,但实际未必如此。例如以下测试结果中,DDR3 确实接近峰值,而 DDR4 则相差甚远:
先分析 DDR3-1866 的模拟结果。实验中发出 50000 次 Read,其中 49772 次命中了已激活的 Row,无需额外 Activate 或 Precharge;此外还有 53 次 Refresh,228 次 Activate 和 222 次 Precharge。由于 DDR3-1866 的时序参数中,tCCD(两次 Read 之间的最小间隔)仅为 4 个周期,而一次 Burst 为 8 拍,因为 DDR 在时钟上下边沿都传输数据,所以一次 Read 正好占用数据总线 4 个周期,因此如果所有命令都是 Read,理论上可以完美衔接,不浪费任何带宽。既然实测只有 95% 左右,必定是其他命令引入了空泡:
尝试理论计算:每 \(x\) 次 Read,对应 \(x/256\) 次因 Row 结束带来的 Activate/Precharge,每轮 Activate/Precharge 带来 \(\mathrm{tRTP}+\mathrm{tRP}+\mathrm{tRCD}\) 的开销;此外在大约 \(4x\) 个周期内,每个 Rank 还需进行 \(4x/\mathrm{tREFI}\) 次 Refresh,每次 Refresh 带来约 \(\mathrm{tRFC}\) 的开销。将这些开销汇总,代入时序参数计算得到约 \(0.30x\) 的额外周期数。但实际上,Activate/Precharge 的部分开销可以通过 Bank 级交错来隐藏,比如在访问一个 Bank 的同时,提前对下一个 Bank 执行 Activate/Precharge,因此主要开销来自 Refresh。即使只考虑一个 Rank 内的 Refresh 开销,也有约 \(0.17x\) 的额外周期数,此时带宽约为峰值的 \(4x/(4x+0.17x)=0.959\) 倍,与实际测得的 95.6% 高度吻合。
但 DDR4 的带宽比例显著下降,显然出现了新瓶颈。DDR4 相比 DDR3 一个重大改动是,原本一个 Rank 内只有 Bank,现在一个 Rank 包含多个 Bank Group,每个 Bank Group 内又有多个 Bank。这种分层是因为 Bank Group 内部的 tCCD 无法像 DDR3 那样保持在 4 个周期,只能退化为 5-8 个周期,这个新时序参数称为 tCCD_L(L 代表 Long);而 Bank Group 之间的 tCCD 仍能保持在 4 个周期。这意味着在 DDR4 下,只有交替对不同 Bank Group 发送 Read 命令,才能逼近峰值带宽;一旦局限在某个 Bank Group 内部,每次 Read 需间隔 tCCD_L 个周期,而每次 Read 仅提供 4 个周期的数据,导致巨大的带宽浪费。特别是在 DDR4-3200 速率下,tCCD_L 长达 8 个周期,数据总线有一半时间处于空闲。
为验证这一点,额外做了一个测试:不再单纯顺序访存,而是固定一个 Bank Group,交错读取不同 Bank,每个 Bank 内顺序访问 Row 和 Column,最终测得的带宽仅为峰值的 47.5%,这大致是考虑 Refresh 后数据带宽减半的结果。按前述 DDR3 的分析方法,计算此时 Refresh 的开销:每 \(x\) 次 Read,对应 \(8x\times\mathrm{tRFC}/\mathrm{tREFI}\) 的周期开销,代入时序参数约为 \(0.36x\),性能可达峰值的 \(4x/(8x+0.36x)=0.478\) 倍,与实际测试的 47.5% 高度吻合。
再回到顺序访存,为何能实现 66.4% 的峰值带宽?注意刚才假设访存总是映射到同一个 Bank Group,而 66.4% 突破了 47.5% 的极限,意味着必然访问了多个 Bank Group。此时需要深入分析地址映射方式,它采用的 RoChRaBaBgCo 映射方法,意味着从地址高位到低位依次是 Row、Channel、Rank、Bank、Bank Group 和 Column。因此随着地址每次增加 64,当 Column 溢出时就会访问下一个 Bank Group,两个 Bank Group 的 Read 命令可以交错执行,填补流水线空档。如果改变映射顺序,会得到不同结果:
可见,Bank Group 地址越向高位移动,带宽越低,说明 Bank Group 交错的频率降低,性能随之下降;除了 Bank Group,Rank 之间也可以交错来掩盖部分延迟,但效果不如 Bank Group 交错显著;若两者都置于最高位,则退化为前述 47.5% 的带宽,即数据总线一半时间为空泡,再加上 Refresh 开销。
再回头看 DDR3 的分析:若只考虑 Refresh 带来的性能损耗,理论上限为 95.9% 带宽,实际达到 95.6%;若将 Activate/Precharge 的损耗也计入,理论上限仅为 \(4x/(4x+0.30x)=0.930\) 倍峰值,低于 95.6%,这说明在顺序访存模式下,通过地址映射在 Bank 或 Rank 层面实现了交错,从而隐藏了一部分延迟。为此再进行一组实验:仅访问一个 Bank 内的连续 Row 和 Column,测得带宽为峰值的 92.7%,与分析基本吻合。
即使是简单的顺序访存,由于地址映射的存在,地址的连续变化会映射到不同的 SDRAM 层次,从而产生不同的性能表现。例如,在 DDR3 上,通过 Bank 和 Rank 的交错,可以隐藏一部分 Activate/Precharge 开销,仅剩 Refresh 开销无法避免;在 DDR4 上,根据地址映射的不同,若能在 Bank Group 层面实现细粒度的交错,就能充分利用更短的 tCCD_S 填满数据总线;否则会产生大量空泡,最坏情况下带宽降至 \(4/\mathrm{tCCD_L}\) 的比例。
与顺序访存相对的另一个极端是随机访存:访问地址随机分布在各种 Bank 和 Row 上,此时 Row 命中率很低,几乎每次 Read 之前都需要 Precharge 和 Activate。在这种场景下,只能依靠 Bank 等层次上的交错来尽量掩盖开销。
从 DDR3-1866 实验数据可以明显看出随机访存与顺序访存的差异:同样是 50000 次 Read,顺序访存仅有 228 次 Activate 和 222 次 Precharge,而随机访存则达到了 50086 次 Activate 和 50078 次 Precharge。接下来尝试理论分析该场景下的性能。首先,在每个 Bank 内,循环执行 Activate-Read-Precharge,这一组操作至少耗时 \(\mathrm{tRAS}+\mathrm{tRP}\);其次,若共有 8 个 Bank(为简化,固定只用一个 Rank),则这 8 个 Bank 可以交错执行 Activate-Read-Precharge 循环,理想情况下在 \(\mathrm{tRAS}+\mathrm{tRP}\) 时间内,8 个 Bank 各可完成一次 Read。代入时序参数,推测带宽为峰值的 \(4\times8/45=0.71\) 倍,但实际仅测到 46.0%,说明还存在其他瓶颈。事实上,这里需要考虑另一个时序参数 tFAW,其含义是在连续的 tFAW 时间内,最多只能有 4 次 Activate,且该限制跨 Bank 生效。因此即使有 8 个 Bank,实际也只能达到 \(4\times4/\mathrm{tFAW}=0.485\) 倍的峰值性能,与模拟值已较为接近,还需考虑 Refresh 开销。在另一组 DDR3-1866 时序参数下,tFAW 为 26 个周期,理论值为 \(4\times4/26=0.615\) 倍峰值,模拟结果为 57.7%,同样比较接近。
DDR4-3200 的情况类似。当 tFAW 为 34 个周期时,理论值为 \(4\times4/34=0.471\) 倍峰值,模拟结果为 44.5%。尽管 DDR4-3200 有 4 个 Bank Group,每个 Bank Group 内含 4 个 Bank,总共 16 个 Bank,但在频繁 Activate 的场景下,依然受限于 tFAW。
因此,即使是随机访存,只要能将请求分散到不同 Bank 上,性能依然可以接受。当然,随机访存的困境还体现在其他方面:缓存命中率低,且每个缓存行可能只用到少量数据就被丢弃。
前面分析提到,Bank 交错可以在一定程度上掩盖 Activate-Precharge 的开销,但如果连这种掩盖也失效了,会发生什么?下面进行一组模拟,固定在某 Bank Group 内的一个 Bank 中,对其内部随机 Row 进行访问。
仍以 DDR3-1866 时序参数为例进行理论分析:每 \(\mathrm{tRAS}+\mathrm{tRP}\) 时间只能完成一次 Read 操作,因此带宽仅为峰值的 \(4/(\mathrm{tRAS}+\mathrm{tRP})\) 倍。代入实际时序参数得 \(4/(32+13)=0.089\) 倍,模拟结果为 8.5%,与理论分析吻合。
DDR4-3200 同样如此,代入时序参数得 \(4/(52+22)=0.054\) 倍,实际模拟结果为 5.2%,基本吻合。
因此,若对同一 Bank 频繁进行随机访存,性能将显著下降。不过,由于地址映射机制的存在,Row 通常位于地址的高位,在实际应用中,绕过 Bank 与 Bank Group 对应的地址位、直接在 Row 地址位上进行随机访问的概率相对较低;然而一旦发生,对性能的影响将是毁灭性的。为此,研究人员提出了一些更为复杂的地址映射模式,例如在选取地址特定位的基础上引入异或运算,或进一步采用 Row Indirection Table,实现从逻辑 Row 到物理 Row 的映射,甚至动态交换特定 Row 中的数据。
上述测试均针对 DDR3 和 DDR4 的读请求展开,那么这些结论对写请求,或者对新一代的 DDR5 会产生怎样的影响呢?
首先,如果将读操作替换为写操作,上述分析基本依然成立:无论是读还是写,占用数据总线的时间相同,虽然时序上略有差异,但瓶颈主要在于 Activate、Precharge、Refresh 等操作,这些方面读和写并无本质区别。模拟结果也证实了这一点,读与写的带宽相差不大。
另一方面,DDR5 相较于 DDR4,主要有两点不同。其一,为支持更高频率,DDR5 将预取(Prefetch)位数从 8n 提升至 16n,即一次突发传输(Burst)包含 16 次传输,对应 8 个时钟周期。同时,为保证每次传输仍为 64 字节,原有的 64 位宽的 Channel 被拆分为两个 32 位宽的 SubChannel。因此,本质上 DDR5 将 Channel 数量翻倍,每个 Channel 内部的数据位宽减为 32 位,突发长度翻倍。这使得一次读写操作将占用数据总线 8 个周期,而不再是先前的 4 个周期,因此上述分析中的相关数值需作相应调整。其二,DDR5 进一步增加了 Bank Group 数量,由 4 组提升至 8 组,从而更容易触发 tCCD_S 而非 tCCD_L。
简单总结上述分析,根据访存模式的不同:
如果读者感兴趣,也可以在代码基础上添加其他访存模式,进一步探索性能表现。