深入分析:std::print 与 fmt::print 的性能问题及原因

基准测试:意料之中与意料之外

我正在给我的极客时间专栏写关于 std::formatstd::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::printstdout 在大部分情况下具有最高的性能。但是,测试结果中有几处比较出乎意料:

  1. Windows/MSVC 下性能最高的方式是把 fmt::format 的结果输出到 ostream,且耗时明显比 Linux 和 macOS 要高。
  2. 对于 Ubuntu Linux 24.04 LTS 自带的 libfmt-dev(9.1.0),printstdoutcout 性能差距不大,并且测试结果还是用 cout 稍快一点。使用最新版本的 fmt 库(最近的标签是 11.1.4)则没有这个问题。
  3. 当使用 Clang 自带的 libc++ 库时,std::printfmt::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 格式化到一个缓冲区
  • 分别使用 fwriteostream::write 来把缓冲区中的数据写到流里

顺便说一下,GCC 的 libstdc++ 标准库里的实现逻辑也大致如此,虽然细节上有少许不同。所以,跟这个老版本 fmt 相比,std::printfmt::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 的行为是个意外(而非优化),因为:

  1. 它没有定义宏 _LIBCPP_HAS_NO_VENDOR_AVAILABILITY_ANNOTATIONS,导致 _LIBCPP_AVAILABILITY_HAS_PRINT 是 0。
  2. _LIBCPP_AVAILABILITY_HAS_PRINT 被定义为 0 时,__is_terminal 会简单地返回 false;否则,在 macOS 和 Linux 下 __is_terminal 会产生对 __is_posix_terminal 的调用,导致该性能问题。
  3. 实际上这两种不同的行为并没有可见的功能差异,因而这个调用是不必要的。

还真是让人头晕……不过,至少在 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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值