第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 知识体系图

下面这张图总结了库性能基准测试的核心知识结构:

库性能基准测试知识体系 微基准测试 吞吐量测试 延迟测试 关键点 • 单函数/单操作测试 • 防止编译器优化 • 预热 + 多次迭代 关键点 • 单位时间处理量 • 模拟并发场景 • 找到系统瓶颈 关键点 • 统计分布(P50/P99) • 测量开销修正 • 关注尾部延迟 通用方法论:环境一致 · 预热 · 多轮取中位数 · 配合perf分析 常见陷阱:忽略预热 · 被编译器优化 · 测量精度不足 · 只看平均值

29.6 避坑指南与个人经验

最后,分享几个我踩过的坑:

  • 不要在生产环境做基准测试。我曾经直接在线上服务器跑测试,结果把业务请求给影响了。基准测试一定要在隔离环境做。
  • 注意CPU亲和性。如果你在多核机器上测试,线程可能会在不同核心间迁移,导致缓存失效。我一般会用tasksetpthread_setaffinity_np把线程绑定到固定核心。
  • 关闭动态频率调整。Linux的cpufreq服务会让CPU频率动态变化,导致测试结果不稳定。测试前最好把CPU调到performance模式。
  • 内存分配器的影响。不同的malloc实现(glibc的ptmalloc、jemalloc、tcmalloc)性能差异很大。测试时一定要明确你用的是哪个分配器。

我的习惯:每次提交代码前,都会跑一遍性能回归测试。如果某个函数的性能下降了超过5%,我会立刻排查。性能问题越早发现,修复成本越低。

好了,关于库的性能基准测试就聊到这里。记住:没有测量就没有优化。先搞清楚你的库到底跑多快,再谈怎么优化。


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