18、性能测试:使用Google Benchmark编写微基准测试,与单元测试结合

性能测试这件事,说实话,很多团队都是到了线上出问题了才想起来做。我见过太多项目——单元测试覆盖率90%以上,但一上生产就卡成PPT。为什么?因为单元测试只管“对不对”,不管“快不快”。

今天我们就来聊聊怎么用Google Benchmark写微基准测试,并且把它和单元测试体系结合起来。这不是什么花哨的技巧,而是每个C++工程师都应该掌握的硬功夫。

为什么需要微基准测试?

你想想看,一个函数跑100次平均耗时1毫秒,和跑100次平均耗时10毫秒,在单次调用时根本感觉不出来。但如果是高频调用的热点路径,这个差距就是天壤之别。

微基准测试,说白了就是精确测量一小段代码的执行时间。它和单元测试的区别在于:

  • 单元测试:验证功能正确性,断言返回值是否符合预期
  • 微基准测试:验证性能指标,断言执行时间是否在可接受范围内

两者相辅相成,缺一不可。

Google Benchmark 快速上手

Google Benchmark是Google开源的微基准测试库,用起来非常顺手。先看一个最简单的例子:

#include <benchmark/benchmark.h>

// 待测试的函数
int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n-1) + fibonacci(n-2);
}

// 基准测试用例
static void BM_Fibonacci(benchmark::State& state) {
    for (auto _ : state) {
        fibonacci(state.range(0));
    }
}
BENCHMARK(BM_Fibonacci)->Arg(10)->Arg(20)->Arg(30);

BENCHMARK_MAIN();

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

----------------------------------------------------------
Benchmark                Time             CPU   Iterations
----------------------------------------------------------
BM_Fibonacci/10       0.002 us        0.002 us  375000000
BM_Fibonacci/20       0.256 us        0.256 us    2734375
BM_Fibonacci/30       31.5 us         31.5 us       22189

嗯,这里要注意:state.range(0) 就是传入的参数值。Google Benchmark会自动运行多次,计算平均值,还会帮你做预热——避免冷启动带来的误差。

核心API详解

我个人习惯把Google Benchmark的API分成三类,这样记起来比较清晰:

类别 API 用途
参数控制 Arg(n) 传入单个参数
Args({a,b}) 传入多个参数
Range(start, end) 自动生成参数范围
迭代控制 Iterations(n) 指定迭代次数
MinTime(seconds) 最小运行时间
输出控制 Unit(kMicrosecond) 设置时间单位
Label("desc") 添加描述标签

举个例子,如果你想测试一个排序算法在不同数据规模下的表现:

static void BM_Sort(benchmark::State& state) {
    std::vector<int> data(state.range(0));
    std::generate(data.begin(), data.end(), std::rand);
    
    for (auto _ : state) {
        std::vector<int> copy = data;
        std::sort(copy.begin(), copy.end());
    }
}
BENCHMARK(BM_Sort)->Range(8, 8<<10)->Unit(benchmark::kMicrosecond);

Range(8, 8<<10) 会自动生成 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 这些参数值。省得你一个个写 Arg

与单元测试结合:性能断言

光跑出数据还不够,我们要把性能指标变成可执行的断言。Google Benchmark 提供了 benchmark::DoNotOptimizebenchmark::ClobberMemory 来防止编译器优化掉你的测试代码。

但真正的“性能断言”需要我们自己封装。我在项目中是这样做的:

#include <benchmark/benchmark.h>
#include <gtest/gtest.h>

// 定义一个性能阈值检查函数
void AssertPerformanceUnder(const char* name, double max_time_us) {
    // 运行基准测试并获取结果
    benchmark::RunSpecifiedBenchmarks();
    
    // 从结果中查找对应的benchmark
    auto results = benchmark::GetBenchmarkResults();
    for (const auto& result : results) {
        if (result.name.find(name) != std::string::npos) {
            double real_time = result.real_time;  // 单位:纳秒
            EXPECT_LT(real_time / 1000.0, max_time_us) 
                << "性能测试失败: " << name 
                << " 耗时 " << real_time / 1000.0 << " us, 阈值 " << max_time_us << " us";
        }
    }
}

TEST(PerformanceTest, SortShouldBeFast) {
    AssertPerformanceUnder("BM_Sort/1024", 500.0);  // 排序1024个元素不能超过500微秒
}

这样做的好处是:性能测试和单元测试跑在同一个流水线里。CI/CD 中一旦性能退化,构建就会失败。

注意:性能断言不能太死板。不同机器、不同编译器优化级别下,执行时间差异很大。我建议在CI环境中固定硬件配置,并且给阈值留出20%-30%的余量。

避坑指南:我踩过的那些坑

我曾经在一个项目中,基准测试跑出来结果很漂亮,但上线后性能一塌糊涂。后来排查发现,问题出在以下几个方面:

  • 编译器优化过度:被测代码被完全优化掉了,测了个寂寞。一定要用 DoNotOptimize 保护输出。
  • 内存分配干扰:在循环内部频繁分配内存,导致测试结果包含了malloc的开销。建议在循环外预分配。
  • CPU频率缩放:笔记本上跑基准测试,CPU频率忽高忽低。建议用 cpupower 锁定频率,或者在服务器上跑。
  • 预热不足:Google Benchmark默认会做预热,但如果你设置了 Iterations(1),预热就失效了。
小技巧:写基准测试时,尽量让被测代码的输入具有代表性。不要只测最好情况,也不要只测最坏情况。用 Ranges 覆盖多个数据规模,才能看到性能的全貌。

知识体系总览

下面这张图总结了微基准测试的核心流程和与单元测试的关系:

微基准测试与单元测试结合体系 单元测试 验证功能正确性 微基准测试 验证性能指标 互补 Google Benchmark 核心 State 循环控制 DoNotOptimize 防优化 Range/Arg 参数化 性能断言 + CI 集成

实战:一个完整的例子

最后,我们来看一个完整的实战案例。假设我们要优化一个字符串拼接函数:

// 原始版本
std::string ConcatRaw(const std::vector<std::string>& parts) {
    std::string result;
    for (const auto& p : parts) {
        result += p;
    }
    return result;
}

// 优化版本:预分配内存
std::string ConcatReserve(const std::vector<std::string>& parts) {
    size_t total = 0;
    for (const auto& p : parts) total += p.size();
    
    std::string result;
    result.reserve(total);
    for (const auto& p : parts) {
        result += p;
    }
    return result;
}

// 基准测试
static void BM_ConcatRaw(benchmark::State& state) {
    std::vector<std::string> parts(state.range(0), "hello");
    for (auto _ : state) {
        auto result = ConcatRaw(parts);
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(BM_ConcatRaw)->Range(8, 8<<10);

static void BM_ConcatReserve(benchmark::State& state) {
    std::vector<std::string> parts(state.range(0), "hello");
    for (auto _ : state) {
        auto result = ConcatReserve(parts);
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(BM_ConcatReserve)->Range(8, 8<<10);

// 性能断言
TEST(ConcatPerformance, ReserveShouldBeFaster) {
    AssertPerformanceUnder("BM_ConcatRaw/8192", 5000.0);
    AssertPerformanceUnder("BM_ConcatReserve/8192", 500.0);
}

运行结果会告诉你:预分配版本比原始版本快了一个数量级。而且这个断言会一直守护着你的代码——如果有人不小心改坏了性能,CI会立刻报警。

核心要点回顾:

  • 微基准测试测量的是“快不快”,单元测试验证的是“对不对”
  • Google Benchmark 通过 State 循环、DoNotOptimize、Range 三个核心机制保证测量准确性
  • 性能断言把基准测试结果变成可执行的测试用例,集成到CI流水线中
  • 注意编译器优化、内存分配、CPU频率等干扰因素

说白了,性能测试不是锦上添花,而是雪中送炭。你想想看,一个系统上线后才发现性能瓶颈,那代价有多大?不如从一开始就把性能指标写进测试里,让每次提交都经过性能的检验。

我个人习惯在项目的 test/ 目录下建一个 benchmark/ 子目录,和单元测试放在一起。这样开发者跑测试时,顺手就把性能测试也跑了。久而久之,团队里每个人都会养成关注性能的习惯。

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