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::DoNotOptimize 和 benchmark::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 中一旦性能退化,构建就会失败。
避坑指南:我踩过的那些坑
我曾经在一个项目中,基准测试跑出来结果很漂亮,但上线后性能一塌糊涂。后来排查发现,问题出在以下几个方面:
- 编译器优化过度:被测代码被完全优化掉了,测了个寂寞。一定要用
DoNotOptimize保护输出。 - 内存分配干扰:在循环内部频繁分配内存,导致测试结果包含了malloc的开销。建议在循环外预分配。
- CPU频率缩放:笔记本上跑基准测试,CPU频率忽高忽低。建议用
cpupower锁定频率,或者在服务器上跑。 - 预热不足:Google Benchmark默认会做预热,但如果你设置了
Iterations(1),预热就失效了。
Ranges 覆盖多个数据规模,才能看到性能的全貌。
知识体系总览
下面这张图总结了微基准测试的核心流程和与单元测试的关系:
实战:一个完整的例子
最后,我们来看一个完整的实战案例。假设我们要优化一个字符串拼接函数:
// 原始版本
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/ 子目录,和单元测试放在一起。这样开发者跑测试时,顺手就把性能测试也跑了。久而久之,团队里每个人都会养成关注性能的习惯。