跳到正文

瓶颈定位与优化次序:先判是哪一类(IO / CPU / 锁 / 数据布局),再决定动谁

环境说明:本节不产生新数字。所有引用的测量都来自本专栏已发布的各节,出处随文标注(栏目 + 节号),每个数值都能在对应文章里找到原始输出。这么写的理由是:瓶颈定位的价值在于判断顺序,而判断顺序必须建立在真测过的量级差上;重复测一遍只会引入第二次噪声。凡是需要现场量的,本节只给”该跑哪一条命令/哪一类探针”,并明确写”未在本节实测”。

一、先分类,再量化:三类瓶颈的量级差长什么样

同一台机器(Windows 11 x64 / 6C12T / NTFS)上已经测到的、按”最大可回收倍数”排序的证据链:

类别已实测的量级差出处
访问方式(缓冲 vs 直读)随机 4 KB:75.64 µs vs 7.19 µs = 10.5 倍OS 专栏第 7 节
事务边界(逐条 vs 包事务)逐条 autocommit 比单事务慢 717–1053 倍数据库专栏第 1 节
落盘策略(每行 flush vs 大缓冲)每行 fflush 8195 ns/行 = 1MB 缓冲的 31 倍调试与性能第 2 节
等待的交接成本(睡眠 vs 自旋)条件变量唤醒 11.35 µs = 原子自旋交接 61 ns 的 186 倍并发专栏第 1 节
锁的排队位置(全局锁 vs 每线程私有)811.6 → 95.6 ns/行 = 8.5 倍调试与性能第 2 节
算法与计划(索引方向)加两个索引后一条 LEFT JOIN 9.653 → 57.783 ms(慢 6.0 倍),换覆盖索引才到 3.288 ms数据库专栏第 2 节
依赖图(增量 vs 全量)201 文件全量 34.1 s,只改一个文件 2.4 s = 14.1 倍编译与构建第 3 节
并行度(-j8)同一批 34.1 → 13.0 s = 2.63 倍(6 核、还有别人抢)编译与构建第 3 节
数据布局(独占缓存行)见本专栏第 8 节《缓存友好与数据布局》调试与性能第 8 节

把这张表读成一句话:“改变事情发生的方式”普遍比”把同一件事做得更快”贵一个数量级。逐条提交 vs 包事务(1000 倍)、每行刷盘 vs 缓冲(31 倍)、睡眠等待 vs 自旋交接(186 倍)都是”方式”问题;-j8(2.63 倍)是”强度”问题。先动方式,再动强度。

二、判据:五分钟之内把问题归到某一类

不需要 profiler(本机也没有 perf/ETW,见第 6 节),三条可观察量就够分类:

  1. 它是 CPU 时间还是墙钟? 用 GetProcessTimes(要等线程退出才结算)或 GetThreadTimes(几毫秒的短线程会返回 0,工作单位必须放大到 ~100ms,编译与构建第 3 节、并发专栏都踩过)。墙钟远大于 CPU 时间 ⇒ 它在等,不在算。
    • ⚠️ 别用 clock():本机实测 Sleep(200) 期间它前进 209 ms,它跟的是墙钟(第 9 节《基准测试方法论》原文)。
  2. 它等的是谁? 若 CPU 时间几乎不涨而耗时线性增长,是外部等待(磁盘、网络、锁、定时器)。Windows 上有一条免费的量:等待精度受 ~15.6ms 进程默认 tick 支配——Sleep(1) 实测睡 14.9–15.2ms、wait_for(10µs) 实测 15.1ms(并发专栏第 1、2 节)。如果你的”慢”刚好是 15ms 的整数倍,那不是负载问题,是 tick 问题(宿主里有活动高精度定时器时会整体抬到 1ms,Qt/timeBeginPeriod(1) 都改过它)。
  3. 它的成本随规模怎么长? 只有两种值得先分辨:
    • 进程/调用次数主导:本机实测 100 次 gcc -fsyntax-only = 16505 ms(165 ms/次),第 9 节。任何”每次都要起进程/建连接/开事务”的实现,规模一大必然被常数吃掉。
    • 随机访问次数主导:页缓存/TLB 那一侧的量级差(OS 专栏第 5、7 节,第 8 节)。

这三条测完,剩下的就是查表:是方式问题还是强度问题。

三、次序:从最便宜、最可逆的开始,把”改数据结构”排在”上并行”之前

按前面各节的实测代价排出来的执行顺序,每一步都写明它在本专栏里的证据:

  1. 先删掉没用的工作(几乎免费,收益最大)。
    • 编译期:-O2 会把”算了但没人用”的整段代码删掉——实测结果丢弃版函数体只剩一条 ret(第 9 节)。你的热点循环可能根本不该存在。
    • 运行期:把级别判断放到格式化之前(第 2 节实测 0.2ns vs 181.2ns,900 倍);日志布局选 csv 还是 json 的差别只有 52%(78.4 vs 119.0 ns),体积才是那条账(31 vs 61 字节/行)。
    • 事务/批量:把逐条提交改成包事务(717–1053 倍,数据库第 1 节)——这是”删掉没用的 fsync”,而不是”让 fsync 更快”。
  2. 改访问方式(改一两个调用,可逆)。
    • 直读随机小 IO 换成分块缓冲读:10.5 倍(OS 第 7 节)。
    • 每行刷盘换成缓冲 + 关键锚点显式 flush(第 2 节:31 倍差价,且”崩溃丢多少行”变成可预测的:落盘字节 = ⌊已写 ÷ 缓冲大小⌋ × 缓冲大小)。
    • 深分页的代价其实不在”跳过行数”而在”排序”:同一份 10 万行、OFFSET 50000 在主键序上是 0.389 ms,在无索引列上是 52.770 ms;给排序列建索引后从 52.77 → 0.393 ms(134 倍),因为消掉了 USE TEMP B-TREE FOR ORDER BY(数据库专栏第 2 节)。“分页慢”要先问是哪一列在排序。
  3. 改数据结构 / 算法(改动中等,但收益是数量级的)。
    • 索引不是加速器:本专栏实测到”同一动作可以是反向的”——加两个索引让一条 LEFT JOIN 慢 6.0 倍(回表 10 万次随机取页),补一个覆盖索引才反超 2.9 倍(数据库第 2 节);同一批索引在 2% 命中时快 1.6 倍、20% 时慢 2.6 倍、全表时慢 6.4 倍(数据库第 1 节)。判据是命中行数,不是”有没有索引”。
    • 范式 vs 反范式要算三笔账:+1.83 倍字节、+1.25 倍写入、改名多改 100 倍行,只为买 1.5–2.6 倍读(数据库第 1 节)。
  4. 改并发结构,而不是加并发度(改动较大)。
    • 锁的排队放进临界区里 vs 放出去:8.5 倍(第 2 节,全局锁 811.6 → 每线程私有 95.6 ns/行)。
    • 睡眠等待 vs 原子自旋交接:186 倍(并发第 1 节)——但反过来,超额订阅时自旋会翻成 3.6 倍劣势(并发第 4 节)。所以”等待放锁外”必须先于”要不要无锁”。
  5. 最后才调强度(并行度、缓存参数、语言/库替换)。
    • -j8 在 6 核上只值 2.63 倍(编译第 3 节);把同一逻辑处理器塞 2 个线程,吞吐只涨 1%(并发第 1 节)。
    • 队列深度从 1 到 16 值 5 倍(68.40→13.16 µs),但再加到 64 中位回落到 18.14 µs(OS 第 7 节)——并行度是有拐点的,不是单调的。

一句话版:删工作 > 换方式 > 换结构 > 换并发形态 > 加并发度。前两条在本专栏的实测里都是 10 倍以上,最后一条不到 3 倍。

四、“先量化再优化”里最容易被跳过的三步

  1. 证明被测代码还在(第 9 节实测:-O2 下”结果丢弃”的百万次循环编成一条 ret,测出 0.00 ns/次)。任何优化前先 objdump -d 数指令,否则你在优化一个不存在的循环。
  2. 证明你的尺子够细:本机 QPC 分辨率 100 ns、两次连续调用差 0 个 tick(第 9 节)。低于百纳秒的量必须万级累计。
  3. 一次运行不下结论:真实样本里”什么都不改”的空跑 max/min = 1.32,纯 CPU 依赖链的散布约 1.04(第 9 节)。预期收益 25% 的优化会被散布整个吃掉——所以要么给区间,要么别声称有效。数据库第 2 节把 B12/B13 的”变化全在样本区间内”明确写成噪声而非结论,就是这个规矩的执行。

五、优化的可读性代价:什么时候不该优化

本节不引数字、只列判据,因为”值不值”取决于代码寿命而不是微基准:

  • 不可读的 2 倍,通常输给可读的 1.5 倍。 上面那条”覆盖索引”就是反例式的经验:正确的做法(INDEX (customer_id, amount))比错误做法(单列索引)既快又短,收益和可读性不冲突时才有理由改。
  • 只有当”方式”已经对的时候,才允许牺牲可读性。 逐条提交改成包事务(1000 倍)不需要任何可读性代价;而”为了 5% 把循环拆成手工 SIMD”要付的是以后没人敢动它。
  • 给优化留一个能进 CI 的断言。本专栏里可复算的那几条都是这个形态:入队数 == 落盘数(第 2 节,写错时表现成”80 万行只剩 4956 行”的静默损坏)、行长上限 自检(第 2 节,snprintf 截断而返回值照报应有长度)、记账值 == 实走链表长度(并发第 9 节)、抓到的字节数 == 应用侧字节数(网络第 5 节)。能被断言的优化才敢留在代码里。

六、本节未实测(以及为什么)

  • 本节没有新数字。 所有倍数、区间都指向已发布各节的原始输出;跨节引用只写”某一节实测过 X”,不重测,以免引入第二次噪声。
  • 端到端”优化前后对比”没有做过同一次实验:真实项目里一次改动往往同时动了好几层(既换缓冲又改并发度),本节给的是各层单独的可回收倍数,不是某个具体项目的组合效果。要判断组合,得在目标系统上做消融实验——本机没有这样的被测系统。
  • 采样式 profiler 的分层能力:本机无 perf、无 ETW 抓取工具(第 2 节明确记录过”没实测到 WriteFile 的真实次数”),所以”用火焰图直接指出热点”这条路径未验证,本节给的是手动分类判据。
  • 功耗/温度对判据的影响:本专栏多处出现”睿频干扰导致绑核吞吐不可复现”(并发第 1 节),但没做”降频后各层倍数如何变形”的测量。
  • 内存带宽受限型瓶颈:第 8 节《缓存友好与数据布局》处理这一类,本节不重复它的数字。

七、小结

  • 三类瓶颈在本专栏的量级差是现成的:访问方式(直读 vs 缓冲 10.5 倍)、事务/刷盘边界(逐条 vs 包事务 717–1053 倍、每行 flush vs 大缓冲 31 倍)、等待形态(唤醒 11.35 µs vs 自旋 61 ns,186 倍)、锁的排队位置(8.5 倍)、算法与计划(索引方向反了 慢 6.0 倍)、依赖图(增量 14.1 倍)、并行度(-j8 2.63 倍)。前四类是”方式”,后三类是”强度”。
  • 五分钟分类法:CPU 时间还是墙钟(别用 clock(),它跟墙钟)→ 等的是谁(15.6ms tick 的整数倍不是负载问题)→ 成本随什么长(进程次数 165 ms/次,还是随机访问次数)。
  • 执行顺序:删没用的工作(连”被编译器删掉的循环”都算,-O2 实测只剩一条 ret)→ 改访问方式 → 改数据结构/算法 → 改并发形态 → 最后才加并发度(且有拐点:队列 1→16 值 5 倍,16→64 反而回落)。
  • 量化三步不许跳:证明代码还在、证明尺子够细(100 ns 分辨率、连续两次调用差 0 tick)、一次运行不下结论(空跑散布就有 1.32 倍)。
  • 优化要留断言:入队数==落盘数、记账==实走长度、抓到字节==应用侧字节——能被断言的优化才敢留在代码里;不可读的 2 倍通常输给可读的 1.5 倍。