速查

性能瓶颈:信号、原因、修法

常见的性能瓶颈各一张卡片:在 perf 里看到什么、为什么慢、怎么改、怎么确认改对了,再链到站内讲这个机制的文章。后两节是写新代码时一开始就避开的坑,以及让程序在不同 CPU 上自动选指令集。

本页目录

用 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 的机器上用 ymmB2 向量太窄
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、qsortB13 数值数组用 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 写死。
  • 多线程:
  • 罕见路径:报错和罕见路径的函数标 [[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,支持才走这条路。