性能基准测试:使用Google Benchmark编写微基准测试,统计显著性

性能优化这件事,最怕的就是「我感觉快了」。我见过太多团队,凭直觉改代码,结果上线后反而更慢。所以,基准测试不是可选项,而是必修课。

今天我们来聊聊微基准测试——用 Google Benchmark 这个库,把代码性能「称」出来。我会结合我自己的踩坑经验,告诉你怎样写测试才靠谱,怎样判断结果是不是真的「显著」。

为什么需要微基准测试?

你想想看,一个函数跑一次,可能只要几微秒。但系统里成千上万次调用,累积起来就是毫秒甚至秒级的差异。微基准测试就是把这些微小的差异放大,让你看得清清楚楚。

我在项目中遇到过一件事:一个字符串处理函数,肉眼看起来差不多,但用基准测试一跑,发现新版本比旧版本慢了 3 倍。原因?多了一次不必要的内存分配。这种问题,靠 code review 很难发现。

Google Benchmark 快速上手

Google Benchmark 是 Google 开源的 C++ 微基准测试库。它帮你处理了热身、迭代次数、统计计算这些琐事。你只需要写测试函数,剩下的交给它。

先看一个最简单的例子:

#include <benchmark/benchmark.h>

// 被测函数
int sum(int n) {
    int s = 0;
    for (int i = 0; i < n; ++i) s += i;
    return s;
}

// 基准测试
static void BM_Sum(benchmark::State& state) {
    int n = state.range(0);
    for (auto _ : state) {
        benchmark::DoNotOptimize(sum(n));
    }
}

BENCHMARK(BM_Sum)->Arg(100)->Arg(1000)->Arg(10000);

BENCHMARK_MAIN();

编译运行后,你会看到类似这样的输出:

----------------------------------------------------------
Benchmark                Time             CPU   Iterations
----------------------------------------------------------
BM_Sum/100             0.123 us        0.123 us      5680000
BM_Sum/1000            1.23 us         1.23 us        569000
BM_Sum/10000           12.3 us         12.3 us         56900

嗯,这里要注意:benchmark::DoNotOptimize 是防止编译器把计算结果优化掉。我刚开始写基准测试时,就吃过这个亏——编译器发现结果没用,直接把循环优化没了,测出来的时间几乎为零。

统计显著性:别被噪音骗了

跑一次基准测试,得到的时间是 1.23 us。再跑一次,变成 1.31 us。哪个是真的?

其实,两个都不一定是真的。因为系统有噪音——CPU 频率波动、缓存命中率、后台进程干扰,都会影响结果。所以我们需要统计显著性。

Google Benchmark 默认会跑多次迭代,计算均值和标准差。你看到输出里的 Iterations 列,就是它自动调整的迭代次数。它保证结果在统计上足够稳定。

我个人习惯,在对比两个版本的性能时,会看三点:

  • 均值差异:新版比旧版快了多少?
  • 标准差:如果标准差很大,说明结果不稳定,需要增加迭代次数。
  • 置信区间:Google Benchmark 可以输出置信区间,帮你判断差异是否显著。

我曾经有一次优化,均值看起来快了 5%,但标准差也大了 4%。说白了,这个「优化」可能只是噪音。后来我增加了迭代次数,差异就消失了。

如何编写可靠的微基准测试

写基准测试,有几个坑你一定要避开。我列出来,你对照着检查:

常见陷阱:
  • 编译器优化:被测代码的结果必须被使用,否则编译器可能直接删掉它。
  • 热身不足:第一次运行代码时,缓存是冷的,分支预测器还没学习。需要跑几轮热身。
  • 测试时间太短:如果一次测试只有几微秒,系统噪音的影响会很大。建议每次测试至少跑 1 毫秒。
  • 环境不一致:不要在笔记本上一边充电一边跑测试,CPU 频率会变。最好固定 CPU 频率。

Google Benchmark 已经帮你处理了热身和迭代次数。但编译器优化这个坑,你得自己注意。看这个例子:

// 错误写法:结果被优化掉
static void BM_Bad(benchmark::State& state) {
    int x = 0;
    for (auto _ : state) {
        x += 1;  // 编译器可能直接优化掉
    }
}

// 正确写法:使用 DoNotOptimize
static void BM_Good(benchmark::State& state) {
    int x = 0;
    for (auto _ : state) {
        benchmark::DoNotOptimize(x += 1);
    }
}

你想想看,如果编译器发现 x 从来没被用过,它会把整个循环删掉。测出来的时间就是 0。嗯,这个坑我踩过不止一次。

对比测试:A/B 比较

很多时候,我们不是想知道一个函数跑多快,而是想知道「新版本比旧版本快多少」。Google Benchmark 提供了 Compare 功能:

static void BM_VersionA(benchmark::State& state) {
    for (auto _ : state) {
        // 旧版本代码
    }
}

static void BM_VersionB(benchmark::State& state) {
    for (auto _ : state) {
        // 新版本代码
    }
}

BENCHMARK(BM_VersionA);
BENCHMARK(BM_VersionB);

// 在 main 中比较
int main(int argc, char** argv) {
    ::benchmark::Initialize(&argc, argv);
    ::benchmark::RunSpecifiedBenchmarks();
    ::benchmark::RegisterBenchmark("BM_VersionA", BM_VersionA);
    ::benchmark::RegisterBenchmark("BM_VersionB", BM_VersionB);
    ::benchmark::Compare("BM_VersionA", "BM_VersionB");
}

输出会直接告诉你:版本 B 比版本 A 快了多少百分比,以及这个差异是否统计显著。

我的建议:对比测试时,把两个版本的代码放在同一个二进制里跑。这样可以保证环境完全一致。我曾经把两个版本分开编译,结果一个用了 -O2,一个用了 -O3,对比结果完全没意义。

参数化测试:覆盖不同输入规模

一个函数在不同输入规模下,性能表现可能完全不同。比如排序算法,小数据量时插入排序可能更快,大数据量时快速排序更优。

Google Benchmark 支持参数化测试:

static void BM_Sort(benchmark::State& state) {
    int n = state.range(0);
    std::vector<int> data(n);
    for (auto _ : state) {
        std::sort(data.begin(), data.end());
        benchmark::DoNotOptimize(data);
    }
}

BENCHMARK(BM_Sort)->RangeMultiplier(10)->Range(10, 100000);

这个测试会依次用 10、100、1000、10000、100000 个元素来跑。你可以看到性能随规模的变化曲线。

我个人习惯,在写基准测试时,至少覆盖三个数量级的输入规模。这样能发现一些隐藏的问题——比如某个算法在数据量小时很快,但数据量一大就崩了。

知识体系:微基准测试的核心逻辑

下面这张图,是我对微基准测试核心逻辑的总结。你可以把它当作一个检查清单:

微基准测试核心逻辑 编写基准测试 控制变量 统计显著性 参数化测试 固定CPU频率 / 关闭Turbo Boost 避免编译器优化(DoNotOptimize) 同一环境对比(同一二进制) 多次迭代 / 计算均值和标准差 置信区间 / 差异是否显著 热身轮次 / 消除冷启动影响 覆盖多个数量级的输入规模 观察性能随规模的变化趋势 发现隐藏的瓶颈(如内存分配)

实战:一个完整的例子

最后,我给你一个完整的实战例子。假设我们要比较两种字符串拼接方式:

#include <benchmark/benchmark.h>
#include <string>
#include <sstream>

// 方式A:使用 + 运算符
std::string concat_plus(const std::string& a, const std::string& b) {
    return a + b;
}

// 方式B:使用 stringstream
std::string concat_stream(const std::string& a, const std::string& b) {
    std::ostringstream oss;
    oss << a << b;
    return oss.str();
}

static void BM_ConcatPlus(benchmark::State& state) {
    std::string a = "hello";
    std::string b = "world";
    for (auto _ : state) {
        benchmark::DoNotOptimize(concat_plus(a, b));
    }
}

static void BM_ConcatStream(benchmark::State& state) {
    std::string a = "hello";
    std::string b = "world";
    for (auto _ : state) {
        benchmark::DoNotOptimize(concat_stream(a, b));
    }
}

BENCHMARK(BM_ConcatPlus);
BENCHMARK(BM_ConcatStream);

BENCHMARK_MAIN();

跑完之后,你大概率会发现 + 运算符比 stringstream 快很多。为什么?因为 stringstream 内部有格式化开销,而 + 直接操作内存。

核心要点:
  • 微基准测试帮你量化性能,而不是凭感觉。
  • Google Benchmark 自动处理热身、迭代、统计,你只需关注被测代码。
  • 统计显著性比单次测量值更重要。看均值、标准差、置信区间。
  • 参数化测试覆盖不同输入规模,避免「小数据快,大数据慢」的陷阱。
  • 对比测试时,确保环境一致,避免编译器优化干扰。

好了,关于微基准测试,我就讲到这里。记住,性能优化不是玄学,而是科学。用数据说话,用基准测试验证。下次你改代码之前,先写个基准测试,跑一遍。改完之后再跑一遍。如果差异不显著,就别急着说「优化了」。


公众号:蓝海资料掘金营,微信deep3321