如何使用 Google Benchmark 对 C++ 代码进行基准测试

Everett Pompeii

Everett Pompeii


什么是基准测试?

基准测试是测试代码性能的实践, 以查看它的速度(延迟)或工作量(吞吐量)。 在软件开发中,这一步通常被忽视, 但对于创建和维护快速且高性能的代码至关重要。 基准测试为开发人员提供了必要的指标, 以了解他们的代码在各种工作负载和条件下的表现。 出于与编写单元测试和集成测试以防止功能回归相同的原因, 你应该编写基准测试以防止性能回归。 性能错误也是错误!

用 C++ 编写 FizzBuzz

为了编写基准测试,我们需要一些源代码来进行基准测试。 首先,我们将编写一个非常简单的程序, FizzBuzz

FizzBuzz的规则如下:

写一个程序,打印从 1100 的整数(包含):

  • 对于三的倍数,打印 Fizz
  • 对于五的倍数,打印 Buzz
  • 对于既是三的倍数又是五的倍数的数,打印 FizzBuzz
  • 对于所有其他数,打印这个数字

许多种编写FizzBuzz的方法。 所以我们会选择我最喜欢的一种:

#include <iostream>
int main()
{
for (int i = 1; i <= 100; i++)
{
if ((i % 15) == 0)
std::cout << "FizzBuzz\n";
else if ((i % 3) == 0)
std::cout << "Fizz\n";
else if ((i % 5) == 0)
std::cout << "Buzz\n";
else
std::cout << i << "\n";
}
return 0;
}
  • 1 遍历到 100,每次迭代后递增。
  • 对于每个数字,计算模(除法后的余数)。
  • 如果余数为 0,则该数字是给定因子的倍数:
    • 如果 15 的余数为 0,则打印 FizzBuzz
    • 如果 3 的余数为 0,则打印 Fizz
    • 如果 5 的余数为 0,则打印 Buzz
  • 否则,只需打印数字。

按步骤操作

为了按照这个逐步教程进行操作,您需要安装 git安装 cmake,以及安装 GNU 编译器集合 (GCC) g++

🐰 本文的源码可在 GitHub 上找到

创建一个名为 game.cpp 的 C++ 文件, 并将其内容设置为上述 FizzBuzz 实现。

使用 g++ 构建一个名为 game 的可执行文件,然后运行它。 输出应如下所示:

$ g++ -std=c++11 game.cpp -o game && ./game
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
...
97
98
Fizz
Buzz

🐰 砰! 你在破解编码面试!

在继续进行之前,讨论微基准测试和宏基准测试之间的区别是很重要的。

微基准测试 vs 宏基准测试

有两大类软件基准测试:微基准测试和宏基准测试。 微基准测试的操作层次和单元测试类似。 例如,为一个确定单个数字是 FizzBuzz,还是 FizzBuzz 的函数设立的基准测试,就是一个微基准测试。 宏基准测试的操作层次和集成测试类似。 例如,为一函数设立的基准测试,该函数可以玩完整个 FizzBuzz 游戏,从 1100,这就是一个宏基准测试。

通常,尽可能在最低的抽象级别进行测试是最好的。 在基准测试的情况下,这使得它们更易于维护, 并有助于减少测量中的噪声。 然而,就像有一些端到端测试对于健全性检查整个系统根据预期组合在一起非常有用一样, 拥有宏基准测试对于确保您的软件中的关键路径保持高性能也非常有用。

C++中的基准测试

C++中两个流行的基准测试选项是: Google BenchmarkCatch2

Google Benchmark 是一个强大且多功能的C++基准测试库,允许开发者以高精度测量代码的性能。其主要优点之一是易于集成到现有项目中,特别是那些已经使用GoogleTest的项目。Google Benchmark 提供详细的性能指标,包括测量CPU时间、墙钟时间和内存使用情况的能力。它支持广泛的基准测试场景,从简单的函数基准测试到复杂的参数化测试。

Catch2 是一个现代的、只需头文件的C++测试框架,简化了编写和运行测试的过程。它的主要优点之一是易用性,其语法直观且表达力强,使开发者能够快速清楚地编写测试。Catch2 支持广泛的测试类型,包括单元测试、集成测试、行为驱动开发(BDD)风格的测试和基本的微基准测试功能。

两者都得到 Bencher 的支持。那么为什么选择 Google Benchmark? Google Benchmark 无缝集成到 GoogleTest 中,而 GoogleTest 是 C++ 生态系统的事实标准单元测试框架。 我建议使用 Google Benchmark 来评估代码的延迟,特别是当你已经使用 GoogleTest 时。 也就是说,Google Benchmark 非常适合测量挂钟时间。

重构 FizzBuzz

为了测试我们的 FizzBuzz 应用程序, 我们需要将逻辑与程序的 main 函数解耦。 基准测试工具不能对 main 函数进行基准测试。 为此,我们需要进行一些更改。

让我们将 FizzBuzz 逻辑重构到一个名为 play_game.cpp 的新文件中的几个函数中:

play_game.cpp
#include <iostream>
#include <string>
std::string fizz_buzz(int n) {
if (n % 15 == 0) {
return "FizzBuzz";
} else if (n % 3 == 0) {
return "Fizz";
} else if (n % 5 == 0) {
return "Buzz";
} else {
return std::to_string(n);
}
}
void play_game(int n, bool should_print) {
std::string result = fizz_buzz(n);
if (should_print) {
std::cout << result << std::endl;
}
}
  • fizz_buzz:接收一个整数 n,执行实际的 FizzBuzzFizzBuzz 或数字逻辑,并返回结果作为字符串。
  • play_game:接收一个整数 n,使用该数字调用 fizz_buzz,如果 should_printtrue,则打印结果。

现在,让我们创建名为 play_game.h 的头文件并将 play_game 函数声明添加到其中:

play_game.h
#ifndef GAME_H
#define GAME_H
#include <string>
void play_game(int n, bool should_print);
#endif // GAME_H

然后更新 game.cpp 中的 main 函数以使用头文件中的 play_game 函数定义:

game.cpp
#include "play_game.h"
int main()
{
for (int i = 1; i <= 100; i++)
{
play_game(i, true);
}
}

我们的程序的 main 函数遍历从 1100(包括 100)并为每个数字调用 play_game,将 should_print 设置为 true

基准测试 FizzBuzz

为了对我们的代码进行基准测试,我们需要首先安装 Google Benchmark。

克隆库:

$ git clone https://github.com/google/benchmark.git

进入新克隆的目录:

$ cd benchmark

使用 cmake 创建一个用于放置构建输出的构建目录:

$ cmake -E make_directory "build"

使用 cmake 生成构建系统文件并下载任何依赖项:

$ cmake -E chdir "build" cmake -DBENCHMARK_DOWNLOAD_DEPENDENCIES=on -DCMAKE_BUILD_TYPE=Release ../

最后,构建库:

$ cmake --build "build" --config Release

返回到父目录:

cd ..

现在让我们创建一个名为 benchmark_game.cpp 的新文件:

benchmark_game.cpp
#include "play_game.h"
#include <benchmark/benchmark.h>
#include <iostream>
static void BENCHMARK_game(benchmark::State &state)
{
for (auto _ : state)
{
for (int i = 1; i <= 100; i++)
{
play_game(i, false);
}
}
}
BENCHMARK(BENCHMARK_game);
BENCHMARK_MAIN();
  • play_game.h 导入函数定义。
  • 导入 Google benchmark 库头文件。
  • 创建一个名为 BENCHMARK_game 的函数,该函数接收 benchmark::State 的引用。
  • 迭代 benchmark::State 对象。
  • 对于每次迭代,遍历从 1100 的值(包括 100)。
    • 以当前数字调用 play_game,并将 should_print 设置为 false
  • BENCHMARK_game 函数传递给 BENCHMARK 运行器。
  • 使用 BENCHMARK_MAIN 运行基准测试。

现在我们准备好对我们的代码进行基准测试了:

$ g++ -std=c++11 -isystem benchmark/include -Lbenchmark/build/src -lbenchmark -lpthread play_game.cpp benchmark_game.cpp -o benchmark_game && ./benchmark_game
2023-10-16T14:00:00-04:00
Running ./benchmark_game
Run on (8 X 24 MHz CPU s)
CPU Caches:
L1 Data 64 KiB
L1 Instruction 128 KiB
L2 Unified 4096 KiB (x8)
Load Average: 5.55, 4.62, 4.69
---------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------
BENCHMARK_game 1698 ns 1688 ns 419979

🐰 萝卜快马加鞭!我们得到了第一个基准测试指标!

最后,我们可以休息一下疲惫的开发者大脑…… 开玩笑的,我们的用户想要一个新功能!

用C++编写FizzBuzzFibonacci

我们的关键绩效指标(KPI)下降了,所以我们的产品经理(PM)希望我们添加一个新功能。 经过大量的头脑风暴和用户访谈,决定经典的FizzBuzz已经不够了。 如今的孩子们想要一个新游戏,FizzBuzzFibonacci。

FizzBuzzFibonacci的规则如下:

编写一个程序,打印从 1100 的整数(包括):

  • 对于三的倍数,打印 Fizz
  • 对于五的倍数,打印 Buzz
  • 对于既是三的倍数又是五的倍数的,打印 FizzBuzz
  • 对于是斐波那契数列的数字,只打印 Fibonacci
  • 对于所有其他的,打印该数

斐波那契数列是一个每个数字是前两个数字之和的序列。 例如,从 01开始,斐波那契数列的下一个数字将是 1。 后面是:2, 3, 5, 8 等等。 斐波那契数列的数字被称为斐波那契数。所以我们将不得不编写一个检测斐波那契数的函数。

许多方法可以编写斐波那契数列,同样也有许多方法可以检测一个斐波那契数。 所以我们将采用我的最爱:

play_game.cpp
bool is_fibonacci_number(int n)
{
for (int i = 0; i <= n; ++i)
{
int previous = 0, current = 1;
while (current < i)
{
int next = previous + current;
previous = current;
current = next;
}
if (current == n)
{
return true;
}
}
return false;
}
  • 创建一个名为is_fibonacci_number的函数,该函数接收一个整数并返回一个布尔值。
  • 对从 0 到我们给定的数字 n 的所有数字进行迭代,包括 n
  • 初始化我们的斐波那契数列,从 01 开始,分别作为previouscurrent数字。
  • current数字小于当前迭代的i时进行迭代。
  • previouscurrent数字相加以获得next数字。
  • 更新previous数字为current数字。
  • 更新current数字为next数字。
  • 一旦current大于或等于给定数字n,我们将退出循环。
  • 检查current数字是否等于给定数字n,如果是则返回true
  • 否则,返回false

现在我们需要更新我们的fizz_buzz函数:

play_game.cpp
std::string fizz_buzz_fibonacci(int n)
{
if (is_fibonacci_number(n))
{
return "Fibonacci";
}
else if (n % 15 == 0)
{
return "FizzBuzz";
}
else if (n % 3 == 0)
{
return "Fizz";
}
else if (n % 5 == 0)
{
return "Buzz";
}
else
{
return std::to_string(n);
}
}
  • fizz_buzz函数重命名为fizz_buzz_fibonacci以使其更具描述性。
  • 调用我们的is_fibonacci_number辅助函数。
  • 如果is_fibonacci_number的结果是true则返回Fibonacci
  • 如果is_fibonacci_number的结果是false,则执行相同的FizzBuzzFizzBuzz或数字逻辑并返回结果。

因为我们将fizz_buzz重命名为fizz_buzz_fibonacci,我们也需要更新我们的play_game函数:

play_game.cpp
void play_game(int n, bool should_print) {
std::string result = fizz_buzz_fibonacci(n);
if (should_print) {
std::cout << result << std::endl;
}
}

我们的main函数和BENCHMARK_game函数可以保持完全不变。

基准测试 FizzBuzzFibonacci

现在我们可以重新运行基准测试:

$ g++ -std=c++11 -isystem benchmark/include -Lbenchmark/build/src -lbenchmark -lpthread play_game.cpp benchmark_game.cpp -o benchmark_game && ./benchmark_game
2023-10-16T15:00:00-04:00
Running ./benchmark_game
Run on (8 X 24 MHz CPU s)
CPU Caches:
L1 Data 64 KiB
L1 Instruction 128 KiB
L2 Unified 4096 KiB (x8)
Load Average: 4.34, 5.75, 4.71
---------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------
BENCHMARK_game 56190 ns 56054 ns 12280

回顾我们的终端历史记录, 我们可以大致比较 FizzBuzz 和 FizzBuzzFibonacci 游戏的性能:1698 ns56190 ns。 你的数字可能和我的略有不同。 然而,这两个游戏之间的差异可能在 50 倍左右。 我觉得这很不错!尤其是为我们的游戏添加像 Fibonacci 这样听起来很厉害的功能。 孩子们会喜欢的!

在 C++ 中扩展 FizzBuzzFibonacci

我们的游戏大获成功!孩子们确实喜欢玩 FizzBuzzFibonacci。如此一来,高管们希望能有一部续集。但这是现代世界,我们需要年度经常性收入(ARR),而不是一次性购买!我们游戏的新愿景是开放式的,不再局限于“1”和“100”之间(即使它们是包括在内的)。不,我们要走向新天地!

Open World FizzBuzzFibonacci的规则如下:

编写一个程序,它接受_任何_正整数并打印:

  • 对于三的倍数,打印 Fizz
  • 对于五的倍数,打印 Buzz
  • 对于同时是三和五的倍数的,则打印 FizzBuzz
  • 对于是斐波那契数列的数字,只打印 Fibonacci
  • 对于其他所有数字,打印该数字

为了让我们的游戏适用于任何数字,我们需要接受一个命令行参数。将 main 函数更新为如下所示:

game.cpp
#include "play_game.h"
#include <iostream>
#include <cstdlib>
int main(int argc, char *argv[])
{
if (argc > 1 && std::isdigit(argv[1][0]))
{
int i = std::atoi(argv[1]);
play_game(i, true);
}
else
{
std::cout << "Please, enter a positive integer to play..." << std::endl;
}
return 0;
}
  • 更新 main 函数以接收 argcargv
  • 获取传递给我们游戏的第一个参数,并检查它是否为数字。
    • 如果是,则将第一个参数解析为整数 i
    • 使用新解析出的整数 i 玩我们的游戏。
  • 如果解析失败或未传入任何参数,则默认提示输入有效的值。

现在我们可以用任何数字玩我们的游戏了!重新编译我们的 game 可执行文件,然后运行可执行文件并跟随一个整数以播放我们的游戏:

$ g++ -std=c++11 game.cpp play_game.cpp -o game
$ ./game 9
Fizz
$ ./game 10
Buzz
$ ./game 13
Fibonacci

如果我们省略或提供了无效数字:

$ ./game
Please, enter a positive integer to play...
$ ./game bad
Please, enter a positive integer to play...

哇,那是一次彻底的测试!CI 通过了。我们的老板们感到非常满意。让我们发布它吧!🚀

结束


海绵宝宝三周后
一切正常的模因

🐰 … 也许这是你的职业生涯的结束?


开玩笑的!其实一切都在燃烧!🔥

起初,一切看似进行得顺利。 但在周六早上02:07,我的寻呼机响了起来:

📟 你的游戏起火了!🔥

从床上匆忙爬起来后,我试图弄清楚发生了什么。 我试图搜索日志,但这非常困难,因为一切都在不停地崩溃。 最后,我发现了问题。孩子们!他们非常喜欢我们的游戏,以至于玩了高达一百万次! 在一股灵感的闪现中,我添加了两个新的基准测试:

benchmark_game.cpp
static void BENCHMARK_game_100(benchmark::State &state)
{
for (auto _ : state)
{
play_game(100, false);
}
}
static void BENCHMARK_game_1_000_000(benchmark::State &state)
{
for (auto _ : state)
{
play_game(1000000, false);
}
}
BENCHMARK(BENCHMARK_game_100);
BENCHMARK(BENCHMARK_game_1_000_000);
  • 微基准测试 BENCHMARK_game_100 用于玩数字一百(100)的游戏
  • 微基准测试 BENCHMARK_game_1_000_000 用于玩数字一百万(1_000_000)的游戏

当我运行它时,我得到了这个结果:

$ g++ -std=c++11 -isystem benchmark/include -Lbenchmark/build/src -lbenchmark -lpthread play_game.cpp benchmark_game.cpp -o benchmark_game && ./benchmark_game
2023-11-04T03:00:00-04:00
Running ./benchmark_game
Run on (8 X 24 MHz CPU s)
CPU Caches:
L1 Data 64 KiB
L1 Instruction 128 KiB
L2 Unified 4096 KiB (x8)
Load Average: 4.98, 5.75, 4.96
-------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------
BENCHMARK_game 75547 ns 59280 ns 12560
BENCHMARK_game_100 1249 ns 1243 ns 564689

等一下……再等一下……

BENCHMARK_game_1_000_000 110879642 ns 43628118 ns 17

什么!1,249 ns x 10,000 应该是 12,490,000 ns 而不是 110,879,642 ns 🤯 尽管我的斐波那契序列代码功能上是正确的,但里面一定有性能问题。

修复 C++ 中的 FizzBuzzFibonacci

让我们再看看 is_fibonacci_number 函数:

play_game.cpp
bool is_fibonacci_number(int n)
{
for (int i = 0; i <= n; ++i)
{
int previous = 0, current = 1;
while (current < i)
{
int next = previous + current;
previous = current;
current = next;
}
if (current == n)
{
return true;
}
}
return false;
}

现在考虑到性能问题,我意识到有一个不必要的额外循环。 我们可以完全去掉 for (int i = 0; i <= n; ++i) 循环,只需将 current 值与给定数字 (n) 进行比较 🤦

play_game.cpp
bool is_fibonacci_number(int n)
{
int previous = 0, current = 1;
while (current < n)
{
int next = previous + current;
previous = current;
current = next;
}
return current == n;
}
  • 更新我们的 is_fibonacci_number 函数。
  • 初始化斐波那契数列,以 01 作为 previouscurrent 数字。
  • current 数字小于给定数字 n 时进行迭代。
  • previouscurrent 数进行相加得到 next 数。
  • previous 数字更新为 current 数字。
  • current 数字更新为 next 数字。
  • 一旦 current 大于等于给定数字 n,我们将退出循环。
  • 检查 current 数字是否等于给定数字 n,并返回该结果。

现在让我们重新运行这些基准测试,看看我们做得怎么样:

$ g++ -std=c++11 -isystem benchmark/include -Lbenchmark/build/src -lbenchmark -lpthread play_game.cpp benchmark_game.cpp -o benchmark_game && ./benchmark_game
2023-11-04T05:00:00-04:00
Running ./benchmark_game
Run on (8 X 24 MHz CPU s)
CPU Caches:
L1 Data 64 KiB
L1 Instruction 128 KiB
L2 Unified 4096 KiB (x8)
Load Average: 4.69, 5.02, 4.78
-------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------
BENCHMARK_game 2914 ns 2913 ns 242382
BENCHMARK_game_100 34.4 ns 34.3 ns 20322076
BENCHMARK_game_1_000_000 61.6 ns 61.6 ns 11346874

哦,哇!我们的 BENCHMARK_game 基准回到了原始 FizzBuzz 大概的位置。 如果我能记住那个得分是多少就好了,然而已经过去三周了。 我的终端历史记录没有那么长,而 Google Benchmark 也不存储它的结果。 不过我觉得差不多了!

BENCHMARK_game_100 基准测试几乎降低了 50 倍到 34.4 ns。 而 BENCHMARK_game_1_000_000 基准测试则降低了超过 1,500,000 倍!从 110,879,642 ns61.6 ns

🐰 至少我们在它上线前抓到了这个性能问题……哦,对了。算了……

在 CI 中捕获性能回归

由于我那个小小的性能错误,我们的游戏收到了大量的负面评论,这让高管们非常不满。 他们告诉我不要让这种情况再次发生,而当我询问如何做到时,他们只是告诉我不要再犯。 我该如何管理这个问题呢‽

幸运的是,我找到了这款叫做 Bencher 的超棒开源工具。 它有一个非常慷慨的免费层,因此我可以在我的个人项目中使用 Bencher Cloud。 而在工作中需要在我们的私有云内,我已经开始使用 Bencher Self-Hosted

Bencher有一个内建的适配器, 所以很容易集成到 CI 中。在遵循快速开始指南后, 我能够运行我的基准测试并用 Bencher 追踪它们。

$ g++ -std=c++11 -isystem benchmark/include -Lbenchmark/build/src -lbenchmark -lpthread play_game.cpp benchmark_game.cpp -o benchmark_game
$ bencher run --adapter cpp_google "./benchmark_game --benchmark_format=json"
{
"context": {
"date": "2023-10-16T16:00:00-04:00",
"host_name": "bencher",
"executable": "./benchmark_game",
"num_cpus": 8,
"mhz_per_cpu": 24,
"cpu_scaling_enabled": false,
...
View results:
- BENCHMARK_game (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=077449e5-5b45-4c00-bdfb-3a277413180d&start_time=1697224006000&end_time=1699816009000&upper_boundary=true
- BENCHMARK_game_100 (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=96508869-4fa2-44ac-8e60-b635b83a17b7&start_time=1697224006000&end_time=1699816009000&upper_boundary=true
- BENCHMARK_game_1_000_000 (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=ff014217-4570-42ea-8813-6ed0284500a4&start_time=1697224006000&end_time=1699816009000&upper_boundary=true

使用这个由一个友善的兔子给我的巧妙的时间旅行设备, 我能够回到过去,重演如果我们一直都在使用Bencher的情况下会发生什么。 你可以看到我们首次推出存在问题的FizzBuzzFibonacci实现的位置。 我马上在我的拉取请求评论中得到了CI的失败信息。 就在那天,我修复了性能问题,摆脱了那不必要的额外循环。 没有火灾。顾客都非常开心。

Bencher: 持续性能基准测试

🐰 Bencher

Bencher是一套持续型的性能基准测试工具。 你是否曾经因为性能回归影响到了你的用户? Bencher可以防止这种情况的发生。 Bencher让你有能力在性能回归进入生产环境 之前 就进行检测和预防。

  • 运行: 使用你喜爱的基准测试工具在本地或CI中执行你的基准测试。bencher CLI简单地包装了你现有的基准测验设备并存储其结果。
  • 追踪: 追踪你的基准测试结果的趋势。根据源分支、测试床和度量,使用Bencher web控制台来监视、查询和绘制结果图表。
  • 捕获: 在CI中捕获性能回归。Bencher使用最先进的、可定制的分析技术在它们进入生产环境之前就检测到性能回归。

基于防止功能回归的原因,在CI中运行单元测试,我们也应该使用Bencher在CI中运行基准测试以防止性能回归。性能问题就是错误!

开始在CI中捕捉性能回归 — 免费试用Bencher Cloud

🤖 该文档由 OpenAI GPT-4 自动生成。 它可能不准确并且可能包含错误。 如果您发现任何错误,请在 GitHub 上提出问题.