把指令塞满、把缓存喂饱:一次围绕 LLVM 的端到端代码优化实战

举报
i-WIFI 发表于 2026/01/24 13:32:09 2026/01/24
【摘要】 “把循环换成并行库就能提速十倍”——很多新人刚入职的时候都会抱着这种误解。现实中,真正能让 CPU 跑到 90% 以上指令吞吐的往往不是一句 magic macro,而是一连串纵横交错的细节:源代码写法、编译器优化开关、IR 级别的指令重排、SIMD 向量化、再到 L1/L2 Cache 的访存模式。本文结合我负责的一个视频推理框架核心模块(帧内 8×8 DCT)的优化过程,从“写源代码”一...

“把循环换成并行库就能提速十倍”——很多新人刚入职的时候都会抱着这种误解。现实中,真正能让 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 同时偏高,说明既“算不动”又“喂不饱”。

二、第一刀:编译器优化策略

  1. 统一编译旗标

    • 早期同事手动写了 -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 再重编译。
  2. 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

观察

  1. getelementptr 步幅为 1,说明内层循环抓的是连续地址,符合 cache line;
  2. %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+addfmadd 性能相当;
  • 为什么不用 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 │
└──────────────┴────────┴─────────┘

五、第四刀:内存访问模式优化

  1. 行列交替导致的 Cache Thrashing

    • 初版算法“先行后列”两次 dct8,第二次访存步幅为 8,正好跨一个 cache line16B,导致每行都 miss;
    • 解决:先一次性读取 8×8 到寄存器,完成行 DCT 后做就地转置并行写回,避免第二遍扫内存。
  2. NUMA 绑核

    • 采用 numactl --membind=0 --physcpubind=0-15,保证解码线程与 DCT 线程共用一块 L3。
  3. 写分配 (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。

七、可迁移的经验清单

  1. 编译器优化 ≠ -O3,利用 Pass Pipeline 定制往往有惊喜。
  2. LLVM IR 是“第二语言”,任何瓶颈先观察 IR 再谈汇编。
  3. SIMD 自动向量化能跑 70 分,关键路径还是应手写 Intrinsics。
  4. 内存模式先画图:哪个阶段顺序访问、哪个阶段随机访问,再决定是 Prefetch、Streaming Store 还是 Software Pipelining。
  5. 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% 以上——这才是代码优化工程师最想看到的画面。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。