用 perf 找到热点以后,下一步是认出它是哪种瓶颈、照什么办法改。这一页把常见的瓶颈各写成一张卡片:信号是在 perf 里或源码里看到什么,原因是为什么慢,修法是怎么改,验证是怎么确认改对了,原理链到站内讲这个机制的文章。
这些内容属于 Intel 开源的 performance-patterns(intel/intel-performance-skills,提交 3e33aec,MIT 许可)。卡片里的阈值和加速倍数都是原文写的,没有在自己的机器上复测;串行累加除外,perf 专题 4.6 实测过。
1. 从信号找瓶颈:查找表
perf 给出一个现象,先在这张表里找它对应哪张卡片。左边是在 perf 的输出或源码里看到的东西,右边链到卡片。
| 看到什么 | 对应的瓶颈 |
|---|---|
annotate 上一条 vaddss/vfmadd(或它的下一条)占大头;IPC ≪ 1,缓存缺失却很低 | B1 串行累加 |
| 热循环是标量浮点;或支持 AVX2 的机器上用 xmm,支持 AVX-512 的机器上用 ymm | B2 向量太窄 |
| AVX 函数之后第一条 SSE 指令的周期数极高;AVX 转 SSE 的辅助事件非零 | B3 漏 vzeroupper |
| 循环前有一段指针相减、比较、跳转的重叠检查;同一个循环编出向量和标量两个版本 | B4 漏 restrict |
annotate 上 lock cmpxchg 在紧循环里扎堆;线程越多吞吐越低 | B5 TAS 自旋锁 |
| c2c 某行 Tot Hitm > 5%,同一行里不同偏移被不同线程写 | B6 伪共享 |
热路径上有 lock add/inc/xadd、没有重试循环;c2c 上同一偏移被很多 CPU 写 | B7 共享统计计数器 |
| 热函数里有只在罕见分支上调用的函数:报错、日志、断言、一次性初始化 | B8 冷路径没标注 |
futex_wake、try_to_wake_up 热;上下文切换率随每次广播叫醒的线程数涨 | B9 条件变量惊群 |
mutex 护着读多写少的数据(读超过约 75%);perf lock stat 里等待时间远大于持有时间 | B10 该用读写锁的地方用了 mutex |
perf report 里热符号在系统 .so 里,而且在版本表里 | B11 系统库版本旧 |
annotate 上 crc32b/l/q 占大头;只有一个 _mm_crc32_u64 累加器,或逐字节查表 | B12 CRC32C 写得慢 |
对 float、double 或整数数组调 std::sort、nth_element、partial_sort、qsort | B13 数值数组用 std::sort |
| 函数名是 cosine、hamming、jaccard 一类 | B14 已知算法 |
2. 瓶颈卡片:信号、原因、修法、验证
每张卡片的标题是编号加瓶颈名,锚点是它的英文 id。编号按收录先后发,发出后不再改,新收的取下一个号,排在最后;讲到某种瓶颈原理的文章,末尾也嵌着同一张卡片。
B1 串行累加
- 信号:annotate 上一条
vaddss/vfmadd占大头(样本常记在它的下一条指令上,要往前看一条);IPC ≪ 1,缓存缺失却很低,这个组合是关键。- 原因:每次
+=都要等上一次的结果,速度卡在浮点加的延迟(4–5 周期)上,而不是吞吐(约 0.5 周期),差 8–10 倍。- 修法:拆成多个独立累加器。纯 C 拆 4 个约快 4 倍;SSE2 向量累加快 4–8 倍;AVX2 向量加 4 个累加器快 8–15 倍。
- 累加器个数:$\text{延迟} \div \text{吞吐} = 4 \div 0.5 = 8$。一般 4 个够,紧循环用 8 个。
- 尾巴:不满一组的尾巴也要用多累加器,否则小输入又退回串行。
- 前提:运算满足结合律(加、乘、min、max)。结果会差一个舍入误差;要求逐位一致就不能用。
- 验证:输出在舍入误差内一致,IPC 至少翻倍。
- 原理:#9 循环优化 3.3;累加器个数的公式见 #9 3.4;编译器替不替你拆,见内存延迟与带宽 1.5.1;实测见 perf 专题 4.6。
B2 向量太窄
- 信号:热循环是标量浮点;或支持 AVX2 的机器上用 xmm;或支持 AVX-512 的机器上用 ymm(annotate 扫描第 1、2 条)。
- 原因:向量单元每周期发一条指令,跟寄存器宽度无关。宽度每翻一倍,吞吐约翻倍;标量到 zmm 最多快 8 倍。
- 先排除三种情况:
- 已经卡在内存带宽上的循环,加宽的收益远小于 2 倍。IPC 接近或超过 1、缺失少,才算计算瓶颈。
- 有串行累加的,先改多累加器(B1 串行累加),单靠加宽没用。
- ymm 到 zmm 这一步:很多 Intel CPU 跑 zmm 会降频,抵掉一部分甚至全部收益。前后用 turbostat 核持续频率;频率掉得多、负载又不是重计算的,拿 AVX2 版本比一比。
- 修法(已有 intrinsics 或汇编时)是一套十步的“拉链”改写:
- 复制一份原循环,留作窄宽度的回退;
- 原循环展开一次,后半段换用另一组寄存器;
- 把后半段的指令逐条挪到前半段对应指令的正下方;
- 相邻的两条整数加法合成一条,成对的窄向量操作合成一条宽操作,相邻的乘和加可以融合成 FMA;
- 处理循环不变量和累加器,最后改 clobber 列表和 CPUID 注释;
- 任何一步做不下去(交换不合法、寄存器合并不了),整个放弃。
- 尾部:AVX-512 可以用掩码寄存器做一次带掩码的操作,去掉标量尾巴。
- 没有手写 intrinsics、只是编译器只出了 xmm:用
target_clones让编译器出多个版本(见自动选指令集)。- 验证:
- 浮点结果允许差 1 ULP(unit in the last place,末位上的一个单位;重排和 FMA 都改变舍入),逐位比对的参考值要用新代码重新生成;
- 整数循环要核有无符号语义;
- CPUID 守卫覆盖新用到的指令集;
- annotate 里出现宽寄存器。
- 原理:寄存器有多宽、不指定指令集为什么只有 128 位,见 #15 第 1 节;挡住向量化的几种情况见 #15 2.2。
B3 漏 vzeroupper
- 信号:
other_assists.avx_to_sse非零;AVX 函数之后第一条 SSE 指令的周期数极高;冷启动、还没跑过 AVX 代码时没有这个开销。- 原因:写过 ymm 或 zmm0–15,寄存器上半部就是“脏”的。之后第一条老式 SSE 指令要保存、恢复整个寄存器状态,有的微架构要几百周期。
vzeroupper约 1 周期就能清掉。- 修法:
- intrinsics 代码在每个可能走到 SSE 的出口前加
_mm256_zeroupper():return 前、调 SSE 函数前、回调前;- 内联汇编末尾加
vzeroupper,并把 ymm0–15 全部加进 clobber 列表,因为它会清掉所有寄存器的上半部。- 不需要的情况:只用 zmm16–31,或只用 xmm。用
target("avx2")、target_clones的函数由编译器自动插入。- 验证:
other_assists.avx_to_sse归零。没归零就objdump -d搜 ymm/zmm,找别的漏点。- 原理:#15 3.3 对齐、指令集检测、vzeroupper。
B4 漏 restrict
- 适用:只适用于 C。
- 信号:循环前有一段指针相减、比较、跳转的重叠检查;同一个循环编出向量和标量两个版本;或者一个没有依赖、没有分支的简单循环完全没向量化。
- 原因:编译器不敢假设输出指针和输入不重叠,只能加运行时检查(10–20 条指令,外加两份循环),或者干脆放弃向量化。
- 修法:只读输入写成
const float * restrict,输出写成float * restrict。C++ 没有标准的 restrict,只有 GCC/Clang 的__restrict__。- 坑:restrict 是对编译器的承诺。调用方真传了重叠指针就是未定义行为,不只是变慢。故意原地处理的参数不能加。
- 验证:
gcc -O2 -S前后对比,检查段消失。加了以后测试挂了,说明调用方在传重叠指针,要去掉 restrict、改调用方。- 原理:#10 第 1 节 __restrict:两个指针为什么可能指向同一块内存,见 1.1;编译器在运行时自己检查一遍,见 1.2。
B5 TAS 自旋锁
- 信号:annotate 上
lock cmpxchg在紧循环里扎堆;perf report 里 spin_lock、cas 一类函数显眼;线程越多吞吐越低,不只是不涨。- 原因:TAS(test-and-set)锁每次都直接读改写。
cmpxchg失败也要独占缓存行,所有等待者和持锁者抢同一行,竞争时总线流量是 $O(N^2)$。和锁放在同一行的数据也跟着被抢。- 修法:先普通读,读到锁空闲再 CAS(TTAS,test-and-test-and-set)。等待时缓存行处于共享态,流量降到 $O(N)$。
- 生产写法:先试一次 CAS,失败再进读循环,没有竞争时不多花一次读。
- 锁和数据同行:把锁垫到单独一行。
- 验证:
lock cmpxchg的样本密度下降;1/2/4/8 线程的吞吐曲线不再往下走;c2c 上锁所在行的 HITM 下降。- 原理:等的核每试一次都要把行抢过来,见 #16 1.4;先读后抢见 #16 1.5 TTAS;最简单的 CAS 循环见 #16 1.3。
B6 伪共享
- 信号:c2c 某行 Tot Hitm > 5%,同一行里不同偏移被不同线程写;1 核时不显眼,多核才冒出来;以同一 socket 内的 HITM 为主。
- 原因:缓存行(通常 64 字节)是一致性的最小单位。A 线程写字段 X,B 线程那份整行也失效,要重新从 L3 或内存读。
- 修法分三步:
- 先多垫 padding,把打架的字段推到不同的行。这一步只为证明诊断对,先不管省空间;
- 用 c2c 复测,该行 HITM 降到 1% 以下、吞吐上来才算对。降不下来说明诊断错了:要么是真共享,要么还有别的字段卷进来;
- 再按访问模式重排:每个线程常写的字段各占一行,多线程只读的字段可以共用一行,很少碰的字段填空位。
- 更彻底的做法:把线程私有的字段挪进每线程一份的结构体。
- 验证:c2c 前后对比;用
_Static_assert守住结构体大小。- 原理:#2 第 2 节 伪共享;两个核各写自己的变量时,这条行的状态怎么来回变,见 MESI 专题第 6 节;让对象独占整条行的
alignas(64)见 #6 1.3。
B7 共享统计计数器
- 信号:热路径上有
lock add/inc/xadd,没有 cmpxchg 重试循环;c2c 显示同一偏移被很多 CPU 写(真共享);1 核不显眼,多核才冒出来。- 原因:每次原子加都要独占缓存行,这一行在核之间来回传,每次接近内存延迟(约 100–300 ns)。改用 relaxed 内存序也没用,硬件照样要独占。
- 修法按优先级三选一:
- 自增在一个函数的循环里、热点不超过 2 处:本地变量攒一批,到批量或函数退出时用 relaxed 原子加一次。纯计算循环直接在末尾加一次。
- 自增散在多处:按 CPU 号分桶。64 字节对齐的统计结构体数组,下标用
sched_getcpu() & 63;所有计数器放进同一个结构体,只调一次sched_getcpu;读的时候把各桶加起来。- 共享库不方便持有全局数组:用
__thread线程局部变量,线程退出或定期刷到全局。- 已经分桶了还在抢:查是不是刷新太频繁,比如每个请求刷一次,拉长间隔。
- 验证:总数在任意时刻只是近似值,要确认业务能接受;
lock指令从热路径消失;c2c 上该行 HITM 下降。- 原理:一次写的价钱从哪来,见 MESI 专题第 5 节;计数器按线程分片、事后合并,见 MESI 专题第 6 节;两个核都写的那个计数器,见 #4 1.2。
B8 冷路径没标注
- 信号:热函数里有只在罕见分支上调用的函数,比如报错和日志函数、断言、一次性初始化,或者写在
if (ret < 0)、switch的default里的调用。- 冷的标准:调用处的分支概率低于约 0.1%。
- 原因:冷代码按源码顺序夹在热代码中间,占指令缓存;编译器也没法生成“几乎不跳”的分支布局。
- 修法:在被调函数的定义上加
[[gnu::cold]](老标准和 C 用__attribute__((cold))),只加在声明上没用。冷函数里再调用的报错函数也要标,不会自动传下去。- 受益的是调用方:冷函数本身不变快,热函数的指令变紧凑。
- 验证:
- 先确认分支真的冷:Intel 的
branchprob.py用 PMU 事件量每个分支的实际跳转概率;没有负载可跑时,gccbranchprob.py解析 GCC 的编译期静态估计(-fdump-tree-profile_estimate-lineno);- 再用
-O2 -S看冷函数被挪到热函数的ret之后。- 原理:#7 3.2 冷热分离。
B9 条件变量惊群
- 信号:
futex_wake、try_to_wake_up热;上下文切换率随每次广播叫醒的线程数涨;CPU 很忙但 IPC 崩;perf trace -e futex里大量FUTEX_WAKE val=INT_MAX。- 和锁竞争的区别:
perf lock stat的等待时间正常;热符号是唤醒路径,不是native_queued_spin_lock_slowpath。- 原因:
notify_all叫醒 N 个线程,只有一个能拿到 mutex,其余白跑一趟 futex(唤醒、调度、拿锁失败、再睡)。160 个线程时一轮约 $N \times 5\ \mu\text{s} \approx 800\ \mu\text{s}$;160 核只有 40 个活时,约 75% 的唤醒是白费的。- 修法按唤醒结构选:
- 派活的线程知道有几个活:只叫醒 $\min(\text{线程数}, \text{活数})$ 个,活被领完就停;
- 必须叫醒全部:树形唤醒,先叫醒 $K \approx \sqrt{N}$ 个帮手,大家并行往下叫,耗时从 $O(N)$ 降到约 $O(\sqrt{N})$;
- 一次只有一个能干活:
notify_one接力,在释放 mutex 之后再通知;- 条件只是“还剩几个单位”:换成信号量,省掉每次唤醒后的抢锁。
- 不适用:所有线程都要往下走的屏障同步,改用
std::barrier、std::latch。- 验证:压测确认不丢唤醒、没有线程永远睡着;futex WAKE 次数和上下文切换下降。文档引的案例:精确唤醒快 49–84%(OpenCV),树形唤醒快 3 倍(RocksDB)。
- 原理:线程睡下、再被叫醒一次要付什么,见 #16 1.1 futex。
B10 该用读写锁的地方用了 mutex
- 信号:mutex 护着查找、读状态,写只在罕见分支,读占比超过约 75%;内核 mutex 的
osq_lock热;pthread mutex 的内核态占比随核数涨;perf lock stat里等待时间远大于持有时间。- 原因:mutex 让只读的获取也独占缓存行,N 个读者完全串行。
- 修法:读路径用读锁,写路径用写锁。读到一半发现要插入时,先放读锁再拿写锁,拿到后必须再查一次,因为中间可能有别的线程已经插入了。
- 内核里:
rwlock_t是自旋锁,持锁时不能睡;可能睡眠的临界区用rw_semaphore。- 不适用:写 > 50%;临界区只有几条指令;写对时间敏感(有的实现会让读者一直饿着写者,可以设写优先)。
- 原文附带的实测(单 socket 32 核,纯读):
- mutex 从 1 线程的 1.64 Mops/s 降到 32 线程的 1.06;
- 读写锁 16 线程到 18.2 Mops/s(16.8 倍),32 线程回落到 15.47,因为读者计数本身在抢一个缓存行;
- 32 线程时有 1% 写还快 2.7 倍,5% 写快 1.7 倍;
- mutex 每秒约 104 万次上下文切换,读写锁 3 秒里一共 225 次;
- perf record 自身开销:1 线程 1.1%,32 线程 5.5%。
- 原理:拿锁是一次读改写,要独占缓存行,见 MESI 专题第 5 节;抢不到的线程进内核睡,见 #16 1.1 futex。
B11 系统库版本旧
- 信号:perf report 里热符号的 DSO(动态共享对象)是系统
.so,而且这个符号在 Intel 的版本表(references/library-versions.md)里。- 修法:查装的版本,低于表里的最低版本就报差距。不改代码,怎么升级由用户定。
- 查版本:多数库
readlink -f一下真实文件名就有完整版本号;OpenSSL 的文件名只到大版本,用openssl version。- 版本表里有什么:目前只有 OpenSSL 的 AES-GCM、AES-XTS 几个符号,最低 3.3,因为 3.3 起 AVX-512 实现大幅优化。在新 CPU 上看到非 AVX-512 的
aesni_xts_encrypt热,说明连 AVX-512 版本都没有。- 版本已经够新还热:瓶颈在函数内部,回到 perf 接着看(annotate 扫描)。
B12 CRC32C 写得慢
- 信号:annotate 上
crc32b/l/q占大头;只有一个_mm_crc32_u64累加器;逐字节查表的实现。函数名里带 crc32c 就值得查。- 原因:
_mm_crc32_u64延迟约 3 周期,一次只算 8 字节,单累加器上限约 2.5 GB/s/GHz。- 修法:用 corsix fast-crc32 的 fusion 算法。向量路径(VPCLMULQDQ)和标量 crc32 指令走不同的执行端口,两条流同时跑;向量路径再用多个独立累加器。
- 现成三版,运行时按 CPU 自动选:
- AVX-512 + VPCLMULQDQ(Ice Lake、AMD Zen 4 以后),64–97 GB/s;
- SSE4.2 + PCLMULQDQ,15–25 GB/s;
- 纯 C,1–3 GB/s。
- 单核对比(文档里的表):Sapphire Rapids 上朴素标量约 23 GB/s,Chromium 的 AVX-512 版 65 GB/s,fusion 97 GB/s。
- 写新代码:要用 CRC32C 就直接用现成的,不要自己写。
- 验证:空缓冲区、种子 0 结果是 0;
crc32c(0, "123456789", 9) == 0xE3069283。- 原理:单累加器的上限由指令延迟决定,拆成几条独立的链才能把执行单元用满,见 #9 3.4 累加器该拆几个。
B13 数值数组用 std::sort
- 适用:float、double、int32/uint32/int64/uint64 数组上的
std::sort、nth_element、partial_sort、数值比较的qsort、手写快排。字符串、指针、没有数值键的对象不适用。- 原因:introsort 一次比较一个元素,每次比较都是一个分支,没有数据并行。
- 修法:换 x86-simd-sort(NumPy、PyTorch、OpenJDK 在用)。快排的划分用 AVX-512 压缩指令向量化,小子数组用 SIMD 排序网络。AVX-512 上快 3–8 倍,AVX2 上 2–4 倍,运行时自动选。
- 坑:
- 不稳定,
stable_sort没有替代;- 浮点数组可能有 NaN 时必须传
hasnan=true,否则行为未定义;qsort会把所有 NaN 统一成 quiet NaN;argsort遇到 NaN 静默退回std::sort,没有加速;- 要求 C++17。
- 验证:拷一份用
std::sort排,逐个比对。
B14 已知算法
- 信号:函数名是 cosine、hamming、jaccard 一类就触发,按指令集给出向量化做法。
- 余弦相似度:一次遍历同时算 $a \cdot b$、$a \cdot a$、$b \cdot b$ 三路,每路 4 个独立累加器(AVX2 下共 12 个 ymm)。输入已归一化就退化成点积;分母小于 $\varepsilon$ 返回 0。
- Hamming 距离:XOR 后数 1。POPCNT 基线 8 字节一块、4 个累加器,约 4–6 GB/s;AVX2 用 Harley-Seal 位切片计数,约 20–30 GB/s;AVX-512 VPOPCNTDQ 约 40–60 GB/s。不要手写 popcount。
- Jaccard 距离:同一遍里同时算 AND 和 OR 的 popcount。两边各自的 popcount 已知时,用 $|A \cup B| = |A| + |B| - |A \cap B|$ 省掉 OR,工作量减半。两个全零向量按约定返回相似度 1。
- 共同点:三种都要处理不满一组的尾巴;结尾的除法和开方占比可以忽略,不用优化。
- 原理:点积怎么用向量指令和累加器写,见 #15 3.2 AVX2 + FMA 点积。
3. 写新代码:一开始就避开这些坑
从零写新代码时,照下面的清单写,第 2 节那些瓶颈一开始就避开。每条后面是对应的卡片。
- 指针:互不重叠的指针参数加
restrict(C),只读输入加const(B4 漏 restrict)。 - 求和这类浮点归约:热循环里不用单个累加器;在函数说明里写明,求和顺序变了,结果会差一个舍入误差(B1 串行累加)。
- SIMD:
- 用目标机器支持的最宽寄存器(B2 向量太窄);
- 用过 ymm 或 zmm0–15 的函数,出口加
vzeroupper(B3 漏 vzeroupper); - 处理长度不是向量宽度整数倍的尾巴;
- 内联汇编标注
/* CPUID: <特性> */,改了标志位要把"cc"加进 clobber。
- 几个 SIMD 版本:同一个函数写了几个版本,在运行时按 CPU 选(第 4 节),不要用编译期的
#ifdef写死。 - 多线程:
- 每线程的数据按 64 字节对齐垫开(B6 伪共享);
- 计数器每 CPU 或每线程一份,不用全局原子量(B7 共享统计计数器);
- 自旋锁先读再 CAS(B5 TAS 自旋锁)。
- 罕见路径:报错和罕见路径的函数标
[[gnu::cold]](B8 冷路径没标注)。 - 有现成的不重写:CRC32C 用现成实现(B12 CRC32C 写得慢);已知算法照表写(B14 已知算法);数值排序用 x86-simd-sort(B13 数值数组用 std::sort)。
4. 让程序在不同 CPU 上自动选指令集
同一个函数准备几个版本,比如 SSE2、AVX2、AVX-512 各一个,程序运行时查 CPU 支持哪些指令,挑最快的那个。这样同一个二进制在老机器上能跑,在新机器上也能用上宽向量。有两种做法:
- 让编译器生成(
target_clones):适合编译器能自动向量化的普通循环。在函数上加这个属性,列出要生成的几档(比如 AVX-512、AVX2),编译器生成几个版本,程序加载时按 CPU 挑一个。- 最后一档写
"default"兜底;各档写特性名,GCC 在这个属性里不认x86-64-v3这类级别名; - 函数必须非 static,还要加
noinline:处处被内联掉,就不会生成挑版本的代码,自动选择悄悄失效; - 它不会把手写的
_mm256_*升级成_mm512_*。
- 最后一档写
- 自己写选择逻辑(
__builtin_cpu_supports):适合手写的 intrinsics 和汇编。拿不准就用这个,逻辑写在明面上,能审。- 第一次调用时按
x86-64-v4、v3、v2从高到低查,把选中的版本存进一个 static 函数指针;之后每次调用只多一次间接跳转。几个线程同时第一次调用也没事,写进去的是同一个值; - 每个版本的定义上加
__attribute__((target("avx2,fma")))这类属性:整个文件按较低的-march编译时,靠它编译器才会给这个函数生成高一档的指令。
- 第一次调用时按
- 检查条件要覆盖全部指令集:少写一个,就会在通过检查却缺这个特性的 CPU 上报非法指令。每次加宽、改写之后都要重新核。
- 别的平台:
- Clang 两种做法都一样能用;
- MSVC 用
IsProcessorFeaturePresent()查特性; - ARM/AArch64 用
getauxval(AT_HWCAP)查特性,没有target_clones的对应做法; - 共享库优先自己写选择逻辑,编译器生成的那种在有的链接器上有初始化顺序问题。
站内的例子见 #15 3.3:用 __attribute__((target("avx2,fma"))) 只给一个函数开 AVX2,启动时检测一次 CPU,支持才走这条路。