基准测试:意料之中与意料之外
我正在给我的极客时间专栏写关于 std::format
和 std::print
的内容,介绍 C++20 和 C++23 引入的这两个对格式化输出有极大改善的功能。在结尾时,我想对比一下标准库和 fmt 库的性能——毕竟后者的作者是这两个功能的提案人,这个库可以被看作是这两个功能的参考实现。测试代码本身非常简单(使用 Google Benchmark),就是反复进行格式化输出:
void fmt_print(benchmark::State& s)
{
while (s.KeepRunning()) {
fmt::print(stdout, "The answer is {}.\n", 42);
}
}
BENCHMARK(fmt_print);
void std_print(benchmark::State& s)
{
while (s.KeepRunning()) {
std::print(stdout, "The answer is {}.\n", 42);
}
}
BENCHMARK(std_print);
// 其他测试,如 format 结果往 cout 输出,print 到 cout,等等
我在 Windows、Linux 和 macOS 上,使用 MSVC、GCC、Clang 并启用优化来构建这样的测试程序,运行则使用 --benchmark_out=benchmark_fmt.txt --benchmark_out_format=console > /dev/null
这样的命令行,抛弃标准输出,然后检查生成的文件里的测试结果。基本上不出所料,fmt::print
到 stdout
在大部分情况下具有最高的性能。但是,测试结果中有几处比较出乎意料:
- Windows/MSVC 下性能最高的方式是把
fmt::format
的结果输出到ostream
,且耗时明显比 Linux 和 macOS 要高。 - 对于 Ubuntu Linux 24.04 LTS 自带的 libfmt-dev(9.1.0),
print
到stdout
和cout
性能差距不大,并且测试结果还是用cout
稍快一点。使用最新版本的 fmt 库(最近的标签是 11.1.4)则没有这个问题。 - 当使用 Clang 自带的 libc++ 库时,
std::print
比fmt::print
慢出很多(近十倍,远超出其他环境下的差值)。但是,如果使用 macOS 自带的 Apple Clang 又没有这个问题。
于是,我对这些问题一一进行了探究。
Windows 下的问题分析
直接向 ostream
输出 format
的结果,要比 print
快,这个问题我很快就搞明白了。原因实际上是 print
的一个易用性特性——终端输出转码。如果你使用目前越来越流行的 /utf-8
选项在 MSVC 项目上的话,下面的代码多半是不能工作的:
cout << "你好,世界!\n";
而这样的代码则没有问题:
print("你好,世界!\n");
在这个环境下,print
会自动帮你做一个从 UTF-8 到 UTF-16 的转码,然后用 WriteConsoleW
来输出。这个开销在内码就是 UTF-8 的 Linux 和 macOS 下是不存在的。这里还有一个有趣的细节:Windows 上 _isatty
认为 nul
是一个终端(Unix 则不认为 /dev/null
是终端),这导致即使在测试中重定向输出时,仍能观察到 UTF-8 转换的性能影响。
如果你不需要这样的转码,那至少在使用 fmt 库时可以抑制该功能——使用宏定义 /DFMT_UNICODE=0
即可。此时,我们会发现 fmt::print
可以获得最高的性能。但我想你应该不需要这么做,因为输出到文件时并没有这个问题。
至于测试结果里 Windows 平台比其他平台明显慢这个问题,在真实环境里看起来也不应该构成问题,因为测试表明原因是:在 Windows 上把终端重定向到文件比直接打开文件写要慢很多倍。同样,在直接输出到文件时并没有什么问题。
Linux 下新老 fmt 库的比较
通过 perf 可以观察到,老版 fmt 库的 print(FILE*, ...)
和 print(ostream&, ...)
都有两部分开销:
- 使用
vformat_to
格式化到一个缓冲区 - 分别使用
fwrite
和ostream::write
来把缓冲区中的数据写到流里
顺便说一下,GCC 的 libstdc++ 标准库里的实现逻辑也大致如此,虽然细节上有少许不同。所以,跟这个老版本 fmt 相比,std::print
和 fmt::print
性能差异不大。
最新版本的 fmt 库实现了显著的性能提升。最明显的地方在于,在 print(FILE*, ...)
下面找不到对 fwrite
的调用了。仔细看一下 fmt 的源码,就会发现,fmt 跳过了 FILE
这层的抽象,直接针对 GNU/Linux 的 glibc 和 Apple 的 libc 进行了优化,在 vformat_to
这一步可以直接把数据格式化到 FILE
的缓冲区里!这个极致优化真让人佩服得五体投地了。
显然,封装还是有一点性能代价的,fmt 的作者也只做到了在这两个指定 libc 实现下的优化,而不能使其放之四海皆准,更不能跳过 ostream
的封装来做同样的优化了。
libc++ 库的问题分析
libc++ 的特别问题又有所不同,但也很容易可以通过 perf 看到:使用 std::print
时,有大量时间花在内部函数 __is_posix_terminal
和它调用的函数 isatty
上。使用 fmt::print
则只在 Windows 上有对 _isatty
的调用。细看下来,Apple Clang 的行为是个意外(而非优化),因为:
- 它没有定义宏
_LIBCPP_HAS_NO_VENDOR_AVAILABILITY_ANNOTATIONS
,导致_LIBCPP_AVAILABILITY_HAS_PRINT
是 0。 - 在
_LIBCPP_AVAILABILITY_HAS_PRINT
被定义为 0 时,__is_terminal
会简单地返回false
;否则,在 macOS 和 Linux 下__is_terminal
会产生对__is_posix_terminal
的调用,导致该性能问题。 - 实际上这两种不同的行为并没有可见的功能差异,因而这个调用是不必要的。
还真是让人头晕……不过,至少在 GitHub 上目前已经有问题报告了,虽然迟迟没有修复。
结论
根据上述分析和测试结果,我目前建议如下:
- 如果你的项目关注输出性能(如可能每秒输出数万行或更多),且 fmt 库在功能上能满足你的需求——或者 C++ 标准还没开到可以使用标准库里的对应功能——那就使用 fmt 库吧。
- 反过来,如果你的项目对格式化输出的性能没有极致需求,C++ 标准已经是 20 或 23,且需要输出 chrono 里的对象或定制自己对象的格式化输出等,那可能还是使用标准库的功能最为简单。
希望标准库的实现里也尽早采纳类似 fmt 库的优化方案吧。
下面几个测试的结果供你简单参考一下(相同的处理器)。(Windows 下的结果由于测试方法的缘故,数值的参考意义不大,因此不列出。)
Linux GCC 14:
-------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------
printf 96.6 ns 96.5 ns 7365357
ostream 109 ns 109 ns 6520014
ostream_fmt_format 121 ns 121 ns 5700770
ostream_std_format 177 ns 177 ns 3668678
fmt_print 68.5 ns 68.5 ns 10412858
std_print 132 ns 132 ns 5262806
fmt_print_cout 86.6 ns 86.6 ns 7987899
std_print_cout 137 ns 137 ns 5150745
macOS Apple Clang 16:
-------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------
printf 195 ns 195 ns 3579025
ostream 450 ns 450 ns 1555863
ostream_fmt_format 188 ns 188 ns 3721603
ostream_std_format 227 ns 227 ns 3092993
fmt_print 140 ns 140 ns 5025126
std_print 205 ns 205 ns 3418570
fmt_print_cout 161 ns 161 ns 4357353
std_print_cout 261 ns 260 ns 2690094
macOS Clang 19:
-------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------
printf 194 ns 194 ns 3608266
ostream 446 ns 446 ns 1568557
ostream_fmt_format 187 ns 187 ns 3742475
ostream_std_format 222 ns 222 ns 3151109
fmt_print 140 ns 139 ns 5025414
std_print 1317 ns 1317 ns 529689
fmt_print_cout 160 ns 160 ns 4382889
std_print_cout 1528 ns 1528 ns 460969