Skip to content

噪声压得下去,偏差才是噩梦

上一篇咱们把编译器这层骗子拆了,知道了为什么循环会被删、怎么用 DoNotOptimize 把它钉回来。但钉回来只是起步——你保住了循环,跑出来的纳秒数照样可能在撒谎,因为跑它的那台机器本身就不安分。这一篇讲两种性质完全不同的运行时干扰:噪声和偏差。前者好对付,后者才是真正让人睡不着觉的那种。

先跑两百次看看数字长什么样

咱们把上一篇的累加循环改一下,连续跑 200 次,每次都重新计时,看看每次的耗时分布。循环本身没变,环境也没变(本机 GCC 16.1.1,没绑核、没锁频的 WSL2):

cpp
constexpr int N = 1'000'000, R = 200;
double samples[R];
volatile long sink = 0;
for (int r = 0; r < R; ++r) {
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < N; ++i) sink += i;
    auto t1 = std::chrono::steady_clock::now();
    samples[r] = std::chrono::duration<double, std::micro>(t1 - t0).count();
}

把 200 个采样画成直方图(去 5 个最大最小极端值,横轴是耗时、单位微秒):

text
200 次采样 (去极端值后 198..259 us), 分布形状:
 197.8 | ######################################## (152)
 200.5 |  (1)
 203.2 | # (4)
 205.8 | # (5)
 208.5 | ### (12)
 211.2 | # (5)
 213.9 |
 216.6 |  (1)
 219.3 |  (1)
  ...  |  (零星几个采样散布在这一带)
 235.3 |  (3)
 251.4 |  (1)
 259.5 | # (5)

min=197.7  median=199.4  mean=204.8  max=313.8 us
mean > median, 说明分布右偏 (长尾在慢的那侧)

咱们盯着这张图看几秒。第一件事:200 次里有 152 次挤在最快的那一格(197.8us 附近),这是"正常情况下这段循环就是这么快"。但右边拖着一条长尾,一路散到 259us,极端值甚至到 313us——差不多是最快值的 1.6 倍。

第二件事:mean=204.8median=199.4 大。如果数据是对称的钟形分布,平均值和中位数应该几乎相等。这里平均值被右侧的长尾拉高了,说明分布是右偏的。

第三件事,也是最要命的一件:如果你只跑了一次,拿到的是分布里任何一个点,你根本不知道自己拿到的是那个 152 次里挤着的"典型值",还是长尾里某个 250us 的"倒霉值"。一次测量什么也说明不了。

噪声从哪来

这些跳动叫噪声(noise),它的特征是随机:方向不定,时大时小,你跑很多次它会互相抵消。来源一抓一大把:

  • 操作系统调度。你的进程不会独占 CPU,内核随时可能把它切出去跑别的任务,再切回来。被切出去那段时间,计时器还在走。
  • 中断。硬件中断、定时器中断、网卡收包中断,都会打断你的循环。
  • CPU 变频。现代 CPU 根据负载动态调频,你的循环跑到一半频率降了,同样的指令就慢了。这台 WSL2 机器没锁频,所以噪声特别明显。
  • 缓存状态。你的数据这次在 L1 里,下次被别的进程挤掉了,就得从更慢的层级取。
  • 超线程争用。同一个物理核上的另一个逻辑线程如果忙起来,会抢执行资源。

噪声是随机的,所以对付它的办法也直接:多跑,取统计量。跑 30 次取中位数,跑 200 次看分布,长尾的影响就被压下去了。把上面的 200 次采样算一下:

text
30 次采样 (us): min=200.2  median=228.3  mean=242.1  max=458.2  stddev=52.1
max/min = 2.29x   stddev/mean = 21.5%

注意那个 stddev/mean = 21.5%(变异系数)。它的意思是:在这台没调过的机器上,同一段代码的测量值波动幅度大约是平均值的五分之一。如果你的优化只带来 10% 的提升,你根本分不清那是真的变快了,还是噪声抖了一下。

把噪声压下去的标准动作有这么几条,按性价比排序:

  1. 多跑取中位数,别取平均值。前面那张直方图已经说明了,分布是偏的,平均值会被长尾拉偏。中位数更抗异常值。要更稳,去掉最高最低各几个再平均。
  2. 绑核。Linux 上 taskset -c 2 ./bench 把进程钉在某个核心,避免跨核迁移带来缓存重建。
  3. 锁频cpupower frequency-set -g performance 把调频策略设成性能模式,不让 CPU 偷懒降频。
  4. 预热。正式测量前先跑几轮,让分支预测器、缓存、分配器都进入稳态。第一轮永远偏慢。

噪声压到什么程度算够

一个经验阈值:变异系数(stddev/mean)压到 1% 以下,数据才算稳得住,可以用来做几个百分点级别的对比。压不到,说明环境还没调好,先别急着下结论,跑出来的排名大概率每次都不一样。本机这台没调的 WSL2 是 21.5%,差着一个数量级——所以它只适合讲"噪声长什么样",不适合做精确对比。

时钟选错,从一开始就输了

压噪声之前还有个更底层的问题:你用什么时钟计时?这一步选错,后面全是白费。咱们先做一个静态检查,看看本机 libstdc++ 上几个时钟到底是谁:

cpp
#include <chrono>
#include <type_traits>
#include <cstdio>
int main() {
    if constexpr (std::is_same_v<std::chrono::high_resolution_clock,
                                 std::chrono::steady_clock>)
        std::printf("high_resolution_clock == steady_clock (单调, 安全)\n");
    else if constexpr (std::is_same_v<std::chrono::high_resolution_clock,
                                      std::chrono::system_clock>)
        std::printf("high_resolution_clock == system_clock (日历时钟, NTP 调时会跳!)\n");
    // ...
}

本机跑出来的结果是:

text
high_resolution_clock == system_clock (日历时钟, NTP 调时会跳!)
steady_clock 单调: 是
steady_clock tick 分辨率: 1.000 ns (1/1000000000 秒)

这就是 Kris 在演讲里反复提醒的那个坑。std::chrono::high_resolution_clock 这个名字听起来最专业,网上教程也最爱用它,但标准对它的定义极其模糊——它只是一个别名,在不同实现上可能指向不同的东西。在 libstdc++(也就是 GCC 默认用的实现)上,它就是 system_clock

system_clock 是日历时钟,它反映的是系统墙上时间。问题在于:系统时间会被 NTP 服务往前调或往后调。如果你的测量窗口里恰好赶上一次 NTP 同步,你算出来的 t1 - t0 可能是个负数,或者大得离谱。想象一下,你测一个函数耗时,结果打印出来是 -342 纳秒,那种感觉能把人逼疯。

正确选择是 std::chrono::steady_clock。它是单调递增的,保证后一次读数永远不小于前一次,不受系统时间调整影响。本机它的分辨率是 1ns,对绝大多数微基准绰绰有余。所以记住一条:steady_clock,别碰 high_resolution_clock

要是你真的在乎到要读硬件级别的周期数,那就直接读 TSC(Time Stamp Counter),它是 CPU 上电后每个时钟周期自增的硬件寄存器。本机 <x86intrin.h> 没装,但用一段内联汇编就能读:

cpp
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;
}

TSC 给的是周期数,不是纳秒,但它直接来自硬件,精度最高,也不受系统时间影响。下一篇讲延迟和吞吐量时会用到它。

偏差:跑一万次也错的那个

噪声再大,多跑几次总能压下去,因为它是随机的。但偏差(bias)不一样——它是系统性的,始终把你的结果往同一个方向拉,你跑一万遍它照样错,只是错得很稳定、很"自信"。

举一个让笔者印象很深的例子。性能测量领域有一篇被反复引用的研究,讲的是 Linux 环境变量如何影响进程的栈对齐方式。实验发现,仅仅是环境变量的配置不同,就能让同一个程序出现 30% 到 300% 的性能差异。百分之三百——同一个二进制、同一段代码,换一组环境变量,慢三倍。

为什么?因为环境变量的长度会影响程序启动时栈的初始对齐,而对齐又影响后续所有内存访问的缓存行为。这是一条从"看起来人畜无害的环境配置"一路连到"指令缓存命中率"的隐蔽链路。你坐在终端里敲 ./bench 跑出一个数 X,把同样的代码扔进 systemd 跑出另一个数 Y,X 和 Y 可能差几十个百分点。那你那个 X 到底测的是什么?它和你生产环境的真实表现,有没有关系?

偏差的来源都有一个共同特征:不是随机的,而是由环境的某个固定配置决定的。常见的有这么几类:

  • 栈对齐和代码布局。前面那个环境变量的例子就是栈对齐。代码布局也一样:链接器把函数排到二进制里的不同位置,会影响指令缓存命中率和分支预测器的行为。
  • CPU 初始频率状态。节能策略下 CPU 一开始是低频,前几轮迭代偏慢,这种偏慢是系统性的,不是随机的。
  • 分支预测器的初始状态。预测器会"记住"之前的分支模式,第一轮跑过的模式会影响后续几轮的表现。
  • 页面是否驻留。第一次访问某块内存触发缺页中断,后续就快了。这个"第一次慢"也是系统性的。

噪声和偏差的处理方式完全不同

噪声靠"多跑"解决,偏差靠"控制变量"解决——这是两种完全不同的应对策略。看到测量值跳动就无脑加迭代次数,只能压噪声,对偏差毫无作用。偏差必须找到那个拉偏你的固定因素(绑核、对齐、预热、初始状态),把它固定下来或者主动打乱它做对照。分不清这两者,你就会陷在"明明跑了十万次还是很偏"的困惑里出不来。

统计这件事,别偷懒

最后说一个统计上的坑,它跟噪声和偏差都有关。很多人跑完 benchmark,习惯性地算个平均值、看个标准差,就完事了。这个习惯的前提是:数据服从正态分布。但前面那张 200 次采样的直方图已经摆在那儿了——微基准的真实数据几乎从来不是正态分布,它通常是偏态的,甚至会出现双峰(一半采样命中缓存、一半没命中,就是两个峰)。

在偏态分布上算平均值,你得到的是一个被长尾拉扯出来的中间值,它既不代表"快的情况",也不代表"慢的情况"。标准差也失去了它在正态分布下那种漂亮的概率含义。

所以养成一个习惯:拿到一组测量数据,先看分布,再看统计量。画个直方图(像咱们上面那样,ASCII 都够用),看看是单峰还是双峰、对称还是偏斜。如果偏斜,报告中位数而不是平均值;如果要比较两个方案谁快,看完整的分布或者经验累积分布函数(eCDF),而不是只比对平均值。

如果你一定要用平均值做对比,至少要带上置信区间或误差线。一张没有误差线的柱状图,你根本不知道它背后测了多少次、波动多大——它可能只测了一次,也可能测了一百次但藏着巨大的方差。误差线不是装饰品,是命脉。

这一层的边界

到这儿,咱们把运行时的两类骗子拆完了。噪声靠多跑压下去,偏差靠控制变量找出来。但还有一类骗子比它们都隐蔽:你的代码本身没变、环境也调好了、统计也做对了,可数字还是在骗你——因为 CPU 内部的分支预测器和缓存层级在联手给你表演一个"完美运行"。那是下一篇的主角。

下一篇:分支预测器在帮你作弊 →

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