把指令塞满、把缓存喂饱:一次围绕 LLVM 的端到端代码优化实战
“把循环换成并行库就能提速十倍”——很多新人刚入职的时候都会抱着这种误解。现实中,真正能让 CPU 跑到 90% 以上指令吞吐的往往不是一句 magic macro,而是一连串纵横交错的细节:源代码写法、编译器优化开关、IR 级别的指令重排、SIMD 向量化、再到 L1/L2 Cache 的访存模式。本文结合我负责的一个视频推理框架核心模块(帧内 8×8 DCT)的优化过程,从“写源代码”一直讲到“看 LLVM IR”,再到“用 perf 抓热点”,并分享几条针对内存访问模式和 SIMD 指令集的可复制心得。全文约 3 400 余字,附 C++、LLVM IR 以及 perf 报告片段,以期给正在做底层性能调优的朋友一些实战参考。
一、问题定位:为什么 8×8 DCT 还跑不过 MATLAB
背景:项目需要在英特尔 Ice Lake Xeon 上做 4K@60fps 的实时 DCT 变换。初版 C++ 实现如下,仅开 -O2:
void dct8x8_naive(const float* IN, float* OUT) {
const float C = sqrt(2.f / 8);
for (int u = 0; u < 8; ++u)
for (int v = 0; v < 8; ++v) {
float sum = 0.f;
for (int x = 0; x < 8; ++x)
for (int y = 0; y < 8; ++y)
sum += IN[x * 8 + y] *
cosf((2 * x + 1) * u * PI / 16) *
cosf((2 * y + 1) * v * PI / 16);
OUT[u * 8 + v] = C * ((u == 0) ? 1 / sqrt(2.f) : 1) *
((v == 0) ? 1 / sqrt(2.f) : 1) * sum;
}
}
在 Ice Lake (AVX-512) 上跑 1 000 帧耗时 640 ms,远低于 16 ms×1 000=16 000 ms 的目标,但与 MATLAB 的 dct2 相比只快了 1.3 倍,且 CPU 利用率一直低于 35%。perf 采样显示最热的函数就是上面这段,ICache Miss 与 L1D Miss 同时偏高,说明既“算不动”又“喂不饱”。
二、第一刀:编译器优化策略
-
统一编译旗标
- 早期同事手动写了
-march=native -O2,但项目里混杂着-fno-math-errno、-ffast-math等凌乱开关。 - 重构 Makefile:
COMMON_FLAGS := -O3 -pipe -march=icelake-server \ -fno-exceptions -fno-rtti \ -ffast-math -fno-trapping-math \ -fmerge-all-constants -flto=auto - 开启 LTO 和 PGO:用 50 000 帧样例生成
default.profdata再重编译。
- 早期同事手动写了
-
Pass Pipeline 定制
-O3默认顺序:EarlyCSE → InstCombine → GVN → LoopVectorize …
但项目对向量化极度敏感,决定手动把 Loop Interleaving Pass 换到 Vectorizer 前,以减少寄存器压力:-mllvm -pass-remarks=loop-vectorize \ -mllvm -passes='default<O3>,loop-interleaving,loop-vectorize'
结果:纯靠重新编译即降到 460 ms。但仍未触及 SIMD 指令吞吐。
三、第二刀:LLVM IR 分析与手动铺路
用 clang -S -emit-llvm -O3 生成 dct.ll,截取核心循环片段:
; %for.cond2.i
%idx = phi i64 [ 0, %entry ], [ %inc, %for.inc8 ]
%0 = getelementptr inbounds float, ptr %IN, i64 %idx
%1 = load float, ptr %0, align 4
; …
%sum = fadd fast float %sum.prev, %prd
%inc = add nuw nsw i64 %idx, 1
br i1 %cmp, label %for.body4, label %for.inc8
观察
getelementptr步幅为 1,说明内层循环抓的是连续地址,符合 cache line;- 但
%sum每步依赖%sum.prev,形成链状 data hazard,阻碍向量化。
Vectorizer 未生效的原因:
- 循环迭代次数固定 8,低于默认阈值;
cosf被视为外部 call,不满足“无副作用”要求。
解决
(1) 提前把 DCT 余弦常数展开成 8×8 LUT;
(2) 用 #pragma clang loop vectorize(enable) 强制;
(3) 改为内联汇编 or SVML intrinsics。
更新后 IR 出现 <16 x float> 类型,表明已走向量化路径。
四、第三刀:SIMD 指令集——AVX-512 Intrinsics 接管
虽然 LLVM 已做自动向量化,但出于可控性仍决定手写关键 8×8 转置与乘加段。以下是精简的 AVX-512 变体(只演示行变换):
#include <immintrin.h>
inline void dct8_avx512_row(const float* in, float* out) {
__m512 x0 = _mm512_loadu_ps(in); // 16 × float
__m512 c0 = _mm512_set_ps(C7, C6, C5, … , C0);
__m512 mul= _mm512_mul_ps(x0, c0);
// 水平加和
__m256 hi = _mm512_extractf32x8_ps(mul, 1);
__m256 lo = _mm512_castps512_ps256(mul);
__m256 sum= _mm256_add_ps(hi, lo);
sum = _mm256_hadd_ps(sum, sum);
_mm256_storeu_ps(out, sum);
}
讨论
- 为什么不用 FMA?由于 C 系数为常量,
mul+add比fmadd性能相当; - 为什么不用
permutevar?转置用shuffle_epi32足够,指令更短; - AVX-512 对 32B 对齐要求严格,输入来自解码器并非天然对齐,因此选择
_loadu并结合_mm_prefetch(in, _MM_HINT_T0)。
优化后耗时降到 180 ms。
表 1 手写 Intrinsics vs Auto-Vectorization
┌──────────────┬────────┬─────────┐
│ 指标 │ Auto │ Hand │
├──────────────┼────────┼─────────┤
│ IR Vector宽度 │ 8 │ 16 │
│ FMA 使用率 │ 52 % │ 0 % (拆分)│
│ 执行时间 │ 460 ms │ 180 ms │
└──────────────┴────────┴─────────┘
五、第四刀:内存访问模式优化
-
行列交替导致的 Cache Thrashing
- 初版算法“先行后列”两次 dct8,第二次访存步幅为 8,正好跨一个 cache line16B,导致每行都 miss;
- 解决:先一次性读取 8×8 到寄存器,完成行 DCT 后做就地转置并行写回,避免第二遍扫内存。
-
NUMA 绑核
- 采用
numactl --membind=0 --physcpubind=0-15,保证解码线程与 DCT 线程共用一块 L3。
- 采用
-
写分配 (Write Allocate)
Ice Lake 的 L1D 为 64KiB,写回策略为 Write-Allocate。如果我们已知输出只会被下一层编码立即读取,可用非临时存储:_mm512_stream_ps(out, sum);省掉一条 write allocate 读。
表 2 内存优化前后 Cache 指标
┌────────────┬──────────┬──────────┐
│ │ 优化前 │ 优化后 │
├────────────┼──────────┼──────────┤
│ L1D Miss │ 3.8 % │ 1.2 % │
│ LLC Miss │ 0.9 % │ 0.3 % │
│ Prefetch │ 22 M/s │ 58 M/s │
└────────────┴──────────┴──────────┘
六、性能验证与回归脚本
perf 监控
perf stat -e cycles,instructions,cache-misses,\
branches,branch-misses,fp_arith_inst_retired.512b \
./dct_bench
输出摘要
cycles 2 980 000 000
instructions 9 680 000 000 # 3.25 IPC
cache-misses 4 200 000 # 0.14%
fp_512b 84 000 000
IPC 从 1.1 → 3.25,cache-miss 降 70%。最终单帧耗时 0.18 ms,完全满足 4K@60fps。
七、可迁移的经验清单
- 编译器优化 ≠
-O3,利用 Pass Pipeline 定制往往有惊喜。 - LLVM IR 是“第二语言”,任何瓶颈先观察 IR 再谈汇编。
- SIMD 自动向量化能跑 70 分,关键路径还是应手写 Intrinsics。
- 内存模式先画图:哪个阶段顺序访问、哪个阶段随机访问,再决定是 Prefetch、Streaming Store 还是 Software Pipelining。
- perf 不要只看 cycles/instructions,结合
fp_arith_inst_retired.128b/256b/512b判断 SIMD 饱和度。
八、尾声:优化没有终点,但要留“换挡空间”
把循环拆到寄存器、把指令排成 pipeline,只是性能调优的起点。随着 AVX-512 VNNI、AMX 甚至 GPU Offload 的普及,未来同一套 DCT 可能迁往异构计算平台。记住:
- Abstract First:始终保持一份数学上“干净”的参考实现;
- Profile First:永远用数据说话,别被“感觉快”迷惑;
- Leave Space:给下代硬件留 ROOM FOR IMPROVEMENT,不要把一切逻辑写死在某条指令。
写到这里,IDE 的风扇突然安静了许多,进程监控上的 CPU Usage 却稳定在 95% 以上——这才是代码优化工程师最想看到的画面。
- 点赞
- 收藏
- 关注作者
评论(0)