第29章 库的性能基准测试:基准测试方法、微基准测试、吞吐量测试、延迟测试
性能基准测试,说白了就是给库“称体重、测心率”。
我见过太多开发者,库写得挺漂亮,接口设计也优雅,结果一上生产环境就崩了——不是内存暴涨,就是响应慢得像蜗牛。为什么会这样?因为没做性能测试。
这一章,我们就来聊聊怎么给C/C++库做性能基准测试。我会从方法论讲起,再深入到微基准测试、吞吐量测试和延迟测试。嗯,这些都是我踩过坑之后才真正理解的东西。
29.1 基准测试方法论
先说说基准测试的基本原则。我个人习惯把性能测试分成三类:
- 微基准测试:测试单个函数或操作的性能,比如一个排序算法、一次内存拷贝
- 吞吐量测试:测试系统在单位时间内能处理多少请求,比如每秒处理多少条消息
- 延迟测试:测试单个请求从发出到收到响应的时间,比如P99延迟是多少
这三者缺一不可。你想想看,微基准测试告诉你“这个函数很快”,但吞吐量测试可能告诉你“并发一高就完蛋”。
核心原则:测试环境必须与生产环境尽可能一致。包括CPU型号、内存大小、操作系统版本、编译器优化级别。我在项目中遇到过,开发机上测试结果漂亮,部署到服务器上直接腰斩——因为服务器开了CPU节能模式。
还有一个容易被忽略的点:预热。现代CPU有缓存、有分支预测、有动态频率调整。如果你不预热就直接测,结果会非常不稳定。我一般会先跑几千次“热身”,再开始正式采集数据。
小技巧:每次测试至少跑5轮,取中位数或平均值。不要只看最好的一次,那叫“表演赛”,不是“实战”。
29.2 微基准测试
微基准测试,就是盯着一个函数使劲测。比如你写了一个字符串哈希函数,想知道它到底快不快。
先看一个简单的例子:
#include <benchmark/benchmark.h>
#include <string>
// 被测函数:计算字符串哈希
static void BM_StringHash(benchmark::State& state) {
std::string input = "hello_world_benchmark_test";
for (auto _ : state) {
// 这里放被测代码
size_t hash = std::hash<std::string>{}(input);
// 防止编译器优化掉结果
benchmark::DoNotOptimize(hash);
}
}
BENCHMARK(BM_StringHash);
BENCHMARK_MAIN();
这段代码用了Google Benchmark库。注意看benchmark::DoNotOptimize——这个很重要。编译器很聪明,如果它发现计算结果没被使用,可能会直接把整个循环优化掉。那你的测试就变成测“空循环”了。
我曾经犯过这个错误。测一个内存拷贝函数,结果耗时几乎为零。我还以为发现了性能黑科技,后来才发现是编译器把拷贝优化掉了。嗯,从那以后我再也不敢忽略DoNotOptimize了。
警告:微基准测试很容易被“欺骗”。比如你测一个函数,它内部有分支预测,第一次调用时分支预测器还没学习,后面就快了。所以一定要预热,或者用随机数据来测试。
微基准测试的另一个坑是测量精度。如果你的函数执行时间只有几纳秒,直接用clock_gettime可能误差很大。我建议用Google Benchmark这类库,它会自动做多次迭代、统计误差。
29.3 吞吐量测试
吞吐量测试,关注的是“单位时间能处理多少”。比如你的网络库每秒能处理多少请求,或者你的压缩库每秒能压缩多少数据。
吞吐量测试通常需要模拟并发。我常用的方法是:
- 固定线程数(比如4个、8个、16个)
- 每个线程持续发送请求
- 统计总请求数,除以总时间
看一个简单的吞吐量测试框架:
#include <atomic>
#include <thread>
#include <chrono>
#include <iostream>
std::atomic<long> total_requests{0};
void worker(int duration_sec) {
auto start = std::chrono::steady_clock::now();
while (true) {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(now - start).count();
if (elapsed >= duration_sec) break;
// 模拟处理一个请求
do_something(); // 你的库函数
total_requests.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
const int num_threads = 8;
const int test_duration = 10; // 测试10秒
std::vector<std::thread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(worker, test_duration);
}
for (auto& t : threads) t.join();
double throughput = total_requests.load() / (double)test_duration;
std::cout << "吞吐量: " << throughput << " req/s" << std::endl;
return 0;
}
这里有个细节:std::memory_order_relaxed。因为total_requests只做累加,不需要严格的顺序保证,用relaxed就够了。如果你用seq_cst,性能会下降不少。
吞吐量测试的关键:要找到系统的“瓶颈点”。是CPU满了?内存带宽不够?还是锁竞争?我建议配合perf工具一起使用,看看热点在哪里。
29.4 延迟测试
延迟测试,关注的是“一个请求要等多久”。吞吐量高不代表延迟低——你想想看,一个系统可能每秒处理100万请求,但每个请求要等100毫秒。这在实时系统中是不可接受的。
延迟测试通常要统计分布,而不是只看平均值。平均值会掩盖问题。比如:
- P50(中位数):一半请求比这个快
- P99:99%的请求比这个快
- P999:99.9%的请求比这个快
我见过一个系统,平均延迟只有5毫秒,但P99延迟高达500毫秒。为什么?因为偶尔有GC暂停或者锁竞争。如果你只看平均值,根本发现不了这个问题。
延迟测试的代码示例:
#include <vector>
#include <chrono>
#include <algorithm>
#include <iostream>
void latency_test(int num_samples) {
std::vector<double> latencies;
latencies.reserve(num_samples);
for (int i = 0; i < num_samples; ++i) {
auto start = std::chrono::high_resolution_clock::now();
do_something(); // 你的库函数
auto end = std::chrono::high_resolution_clock::now();
double us = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
latencies.push_back(us);
}
std::sort(latencies.begin(), latencies.end());
std::cout << "P50: " << latencies[num_samples * 50 / 100] << " us" << std::endl;
std::cout << "P90: " << latencies[num_samples * 90 / 100] << " us" << std::endl;
std::cout << "P99: " << latencies[num_samples * 99 / 100] << " us" << std::endl;
}
注意:延迟测试时,测量本身也会引入延迟。high_resolution_clock的调用开销大约在几十纳秒到几百纳秒之间。如果你的被测函数本身只有几微秒,这个开销就不能忽略。我一般会先测一次空循环的开销,然后从结果中减去。
29.5 知识体系图
下面这张图总结了库性能基准测试的核心知识结构:
29.6 避坑指南与个人经验
最后,分享几个我踩过的坑:
- 不要在生产环境做基准测试。我曾经直接在线上服务器跑测试,结果把业务请求给影响了。基准测试一定要在隔离环境做。
- 注意CPU亲和性。如果你在多核机器上测试,线程可能会在不同核心间迁移,导致缓存失效。我一般会用
taskset或pthread_setaffinity_np把线程绑定到固定核心。 - 关闭动态频率调整。Linux的
cpufreq服务会让CPU频率动态变化,导致测试结果不稳定。测试前最好把CPU调到performance模式。 - 内存分配器的影响。不同的malloc实现(glibc的ptmalloc、jemalloc、tcmalloc)性能差异很大。测试时一定要明确你用的是哪个分配器。
我的习惯:每次提交代码前,都会跑一遍性能回归测试。如果某个函数的性能下降了超过5%,我会立刻排查。性能问题越早发现,修复成本越低。
好了,关于库的性能基准测试就聊到这里。记住:没有测量就没有优化。先搞清楚你的库到底跑多快,再谈怎么优化。
公众号:蓝海资料掘金营,微信deep3321