Skip to content

延迟、吞吐量、cycles:你到底在测什么

前三篇咱们拆掉了编译器、噪声、分支预测器这几层骗子。哪怕它们都对付好了,你测出来的数字仍然可能答非所问——因为你可能根本没意识到,自己问错了问题。"这个函数有多快"这句话里,藏着两个完全不同的维度:延迟和吞吐量。把它们搅在一起,是微基准测试里最隐蔽、也最常见的概念性错误。

先把两个词钉死

延迟(latency)说的是:一次操作,从进去到出来,花多长时间。单位是纳秒每次,或者周期每次。它问的是"单次多快"。

吞吐量(throughput)说的是:在一段时间窗口里,你总共能完成多少次操作。单位是每秒操作数,或者每秒千兆字节。它问的是"一秒能干多少活"。

听起来像是一回事——既然一次 2 纳秒,那一秒不就是 5 亿次嘛,延迟取个倒数不就是吞吐量?但现代 CPU 偏偏不按这个简单的倒数来。原因在于一个反直觉的事实。

CPU 根本不会等你

笔者把这个认识放在最前面,因为它是这一整篇的命门:CPU 是乱序执行的,它有很深的流水线,它在你的循环里会拼命往前冲,不会因为一次迭代"逻辑上结束了"就停下来等。

想象你写了一个循环,每次迭代调用某个函数 f,然后把结果存起来。在你看来,这是严格的串行:第 1 次 f 跑完,才轮到第 2 次。但在 CPU 眼里,只要第 1 次 f 产生的结果不被第 2 次立即需要,它完全可以提前开始算第 2 次、第 3 次甚至第 10 次,把多条迭代的不同部分塞进不同的执行端口里并行跑。这就是指令级并行。

这件事的直接后果是:吞吐量可以远高于"延迟的倒数"。单次 f 的延迟可能是 5 个周期,但 CPU 通过乱序和流水线,可能做到每个周期完成一次 f——吞吐量是延迟的五倍。反过来说,如果你只测延迟,你会觉得这个函数"挺慢的";但它真实的生产表现可能快得多,因为生产环境是连续调用,CPU 能把多次调用并行化。

所以,根据你想问的问题不同,你的 benchmark 代码结构必须不一样。测延迟有一种写法,测吞吐量是另一种写法。写错了,测出来的就不是你想的那个量。

同一个函数,两种测法,差四倍

咱们拿一个轻量的位混合函数当靶子。它纯粹是一串位运算和乘法,没有任何内存访问,适合把注意力放在 CPU 流水线上:

cpp
static inline std::uint64_t bit_mix(std::uint64_t x) {
    x ^= x >> 33;
    x *= 0xff51afd7ed558ccdULL;
    x ^= x >> 33;
    x *= 0xc4ceb9fe1a85ec53ULL;
    x ^= x >> 33;
    return x;
}

先看延迟测量的写法。思路是:每次单独调用、单独计时,跑很多次取中位数,尽量反映"一次调用本身要多久":

cpp
// 用 rdtsc 读 TSC (硬件周期计数器), 精度比 chrono 高
static inline std::uint64_t rdtsc() {
    std::uint32_t hi, lo;
    __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi));
    return (static_cast<std::uint64_t>(hi) << 32) | lo;
}

constexpr int M = 20000;
std::uint64_t samples[M];
std::uint64_t x = 42;
for (int i = 0; i < M; ++i) {
    std::uint64_t a = rdtsc();
    std::uint64_t r = bit_mix(x + i);
    std::uint64_t b = rdtsc();
    samples[i] = b - a;
}
// 排序取中位数

再看吞吐量测量的写法。注意它跟延迟测量长得完全不一样:不是每次单独计时,而是一个大循环里连续跑五亿次,只在开始和结束各读一次 TSC。关键是循环体里制造了一条数据依赖链——每次迭代的结果喂给下一次:

cpp
constexpr int N = 500'000'000;
std::uint64_t acc = 12345;
auto c0 = rdtsc();
for (int i = 0; i < N; ++i) acc = bit_mix(acc);  // 每次都依赖上次结果
auto c1 = rdtsc();
double per_op = (double)(c1 - c0) / N;

本机(GCC 16.1.1, -O2)跑出来的结果:

text
吞吐量测量 (带数据依赖): 9.30 cycles/op   (sink=7075159340691660003)
延迟测量   (单次计时): 37.00 cycles/op (中位数, 含 rdtsc 开销)
最小观测: 1.00 cycles/op (接近 rdtsc 两次调用本身的开销)

同一个 bit_mix, 吞吐量 9.30 vs 延迟测量 37.00 —— 数值差异来自测量方式

同一个函数,两种测法,差了四倍。这四倍不是噪声,也不是编译器捣鬼,纯粹是你问的问题不一样。

为什么差这么多,以及一个反直觉的结论

先解释那个 9.30。bit_mix 里有三次乘法(x *= ...),每次乘法的延迟大约 3 个周期,而三次乘法是串行依赖的(后一次用到前一次的结果),构成一条关键路径。所以"算完一次 bit_mix"的最小周期数,大约就是 3 × 3 = 9 个周期。吞吐量测出来的 9.30 cycles/op,正好对应这条关键路径的长度。

为什么吞吐量测量能这么准?因为那条数据依赖链(acc = bit_mix(acc))强制 CPU 没法跨迭代并行:第 i+1 次的输入就是第 i 次的输出,CPU 必须等第 i 次算完才能开始第 i+1 次。流水线里的并行被这条依赖链堵死了,每次迭代只能老老实实走完关键路径。所以测出来的,是真实的一次延迟——单次操作到底要花多少周期。

这就引出一个反直觉的结论:在这套带数据依赖的写法里,"吞吐量测量"反而测出了真实的单次延迟。 名字叫吞吐量,但因为依赖链的存在,它实际反映的是延迟。

那 37.00 又是怎么回事?为什么"单次计时"的延迟测量反而比真实延迟高那么多?因为单次计时法有两个污染源。第一,rdtsc 这条指令本身不是零开销,读两次 TSC 中间夹一次 bit_mix,你测到的 b - a 里至少包含了两次 TSC 读取的开销——那个"最小观测 1.00 cycles/op"就是两次 TSC 之间什么都不做时的下限,真实开销更高。第二,单次调用时流水线是冷的,函数的指令还没进 L1 指令缓存,前端要现取现解码,这又加了几拍。这两个污染源叠起来,把一个真实延迟约 9 周期的函数,测成了 37 周期。

所以你看,这不是"吞吐量和延迟天然差四倍",而是"单次计时法在这个场景下测不准"。如果你据此宣布"bit_mix 的延迟是 37 周期",你就被自己的测量方法骗了。

那怎么测才对

取决于你想测什么。

想测延迟(单次操作本身要多久),最可靠的办法不是单次计时,而是带数据依赖的循环测量:用 acc = f(acc) 这种依赖链强制串行,跑很多次取平均,得到的是关键路径长度,也就是真实延迟。这是前面 9.30 那个数的来历。

想测吞吐量(一秒能干多少次),就得反过来:主动打破依赖链,让 CPU 能把多次迭代并行起来。把每次的输入从独立的来源取(比如从一个预生成的数组里读),不要让这次输出变成下次输入:

cpp
// 吞吐量测量: 输入独立, CPU 可以跨迭代并行
auto c0 = rdtsc();
for (int i = 0; i < N; ++i) {
    do_not_optimize(bit_mix(inputs[i]));  // inputs[i] 之间互不依赖
}
auto c1 = rdtsc();

这种写法下,CPU 会尽情地把不同迭代的 bit_mix 塞进不同的执行端口并行执行,测出来的是吞吐量(每周期完成的次数),它会显著高于"延迟的倒数"。

别犯一个典型错误:把延迟测量和吞吐量测量混在一个 benchmark 里。比如写一个循环,既不造依赖链也不刻意打破依赖,输入半随机半相关——这时候你测到的数字介于两者之间,既不是延迟也不是吞吐量,是一个说不清楚的混合量。很多人就是这么得出"我这个函数大约 X 纳秒"这种没意义结论的。

从墙上时钟到 cycles

前面咱们一直在用周期数(cycles)而不是纳秒来描述性能,这不是偶然。墙上时钟时间(纳秒)里混进了太多不属于你代码的东西:操作系统调度、中断、缓存状态。而 cycles 是 CPU 真正花在执行指令上的心跳,它才是你代码"实际干了多少活"的更忠实的刻度。

读 cycles 用 TSC(就是前面那段 rdtsc 内联汇编)。它有几个要注意的点:

  • TSC 在多核之间不一定完全同步。跨核迁移会让读数出现偏差,所以测之前最好绑核(见第二篇)。
  • TSC 的频率不一定等于 CPU 标称频率。现代系统有"invariant TSC",它以固定速率走,不受 CPU 变频影响——这其实是好事,意味着你不用锁频也能拿到稳定的周期数。但你要是想把 cycles 换算回纳秒,得知道 TSC 的实际频率,不能直接拿 CPU 主频除。
  • TSC 给的是总周期,不是"你的代码用的周期"。如果测量窗口里发生了中断或调度,那段时间的周期照样算进去。所以 TSC 比墙上时钟好,但没好到能完全屏蔽噪声,该跑多轮取中位数还得跑。

拿到 cycles 之后,怎么往下挖

假设你测准了,某段代码每次调用大约 12 个周期。然后呢?光有这个数字,你还不知道它是"快"还是"慢",更不知道该往哪个方向优化。Kris 在演讲里反复强调一个观点:拿到数字只是开始,你得能解释这个数字为什么是它,测量才有价值。

往下挖有两个方向,本机条件有限,这里只做概念性介绍,不展开实测。

第一个方向是算 IPC(Instructions Per Cycle,每周期指令数)。用执行的指令数除以周期数就得到了。IPC 高,说明 CPU 大部分时间在干活;IPC 低,说明它在等待——等内存、等分支解析、等执行端口。当代 x86 的分发宽度大概在 4-6 之间,所以 IPC 理论上限也就是 4-6,实际能跑到 2 以上就算不错了,低于 1 通常意味着卡在内存上。本机没有硬件计数器读取工具(没装 perf),精确测 IPC 需要它,这里点到为止。

第二个方向是静态流水线分析。LLVM 带一个工具叫 MCA(Machine Code Analyzer),你给它一段汇编,它根据 CPU 的调度模型告诉你每条指令的延迟、用了哪个执行端口、整个循环跑一轮要多少周期。它的价值在于:不需要真的运行代码,就能预测性能。本机没装 llvm-mca,没法实测展示,但思路值得知道——当你测出来的 cycles 和 MCA 预测的对得上,说明你的测量流程是可信的;对不上,要么是你测偏了,要么是 MCA 的调度模型对你的 CPU 不准,无论哪种,都是一条有价值的信息。

一条更重要的提醒

别陷入"数汇编指令"的陷阱。指令少不等于快——一条 imul 和一条 add 都算一条指令,但代价差好几倍。真正决定快慢的是指令之间的依赖关系、占用的执行端口、以及流水线气泡。这需要像 MCA 那样的资源压力分析,而不是数行数。下一篇讲相关性的时候,咱们会看到一个活生生的例子:-O2 下编译器把除法优化成了乘法,光看指令条数你根本发现不了。

这一层的边界

把延迟和吞吐量分清,用对测量方法,你拿到的数字总算既有意义又可信了。但还有最后一道坎,也是 Kris 整场演讲最想传达的那个认知:就算你的微基准做得无可挑剔、数字真实可信,它和你整个程序的真实性能之间,仍然可能毫无关系。那是一个比"测不准"更深的问题——你测的东西,和你想优化的东西,是不是同一件事。

下一篇:微基准变快,不代表程序变快 →

v0.10.0-6-gbcee94e · bcee94e · 2026-08-20