关于 -fno-exceptions
作为优化技术的一番抱怨
最近我第二次在過去半年內因為一個名為 _HAS_EXCEPTIONS
的糟糕 MSVC 陷阱而遇到问题。第二次花了約一個小時才意識到問題——第一次花了三天的時間——包括我之前寫了一篇關於在Windows上為Node.js擴展使用的Google地址檢查工具的文章[#7]。如果你對Windows上Node.js擴展中的異常問題感興趣,文章最後有一些相關鏈接。
在构建过程中关闭 C++ 异常是否真的有优化效果,这个问题聚焦于是否实际上有优化效果。
在C++里,绝对没有异常这种情况是不可能的没有 const
变量的 C++ 和没有 switch
语句的 C++ 一样不常见。这个可怕的编译器选项是出于必要而产生的,并且继承自早期编译器异常实现不佳的时代。一项于 2006 年进行的行业研究,尽管没有实际的基准测试,但委员会中有包括 Bjarne Stroustrup 在内的几位知名人物,得出结论,得益于编译技术的进步,C++ 异常早期遇到的性能问题几乎得到了完全解决 [#5]。
我将在后面的章节中介绍仍然有效但不使用它们的几个理由。
异常是计算机语言中最重要的功能之一这并不是夸张。异常能够使错误处理更加结构化,从而简化代码。每一种现代高级语言都有异常功能。而C++这种独特的语言,既支持高级编程也支持低级编程,当然也不例外(此处双关)。
然而,C++中的异常有不好的名声。有一个非常著名的Google编码风格文档,在2000年代禁止在Google代码中使用它们[#1]。还有浏览器阵营,在第二次浏览器大战期间,他们决定不使用异常。C++异常也颇具争议。当一位C++编译器工程师被问及异常时,他谨慎地不想引发争议[#2]。似乎就连LLVM团队自己,这些C++编译器工程师,也避免使用它们!新手C++程序员被告知C++异常既危险又耗时。C++异常令人感到害怕。
如果你刚开始接触 C++ 的内存管理——记住一个简单的原则——始终通过 const
引用捕获异常,并且只抛出 std::exception
对象。这样你就可以高枕无忧,再也不用担心内存问题了。
yeah,但是例外不是很快嘛?
对吧?
真的是吗?
把代码和基准测试给我看看已经有相当多的基准测试涉及到抛出异常 [#3] [#4]。如果你还不清楚——抛出异常成本很高——它的成本比返回错误码要高很多倍。
所以如果你计划有一个10%的异常率,就像上述两个基准中的第二个那样,那么你就不太适合使用异常处理。异常是用来处理 异常 情况的。当你有一个10%的比率时,这已经是正常操作的一部分了,你需要另一种错误处理的方式。
这个故事特别讲到了带有 -fno-exceptions
的编译器参数及其带来的好处。
所以在深入具体内容之前,让我们先弄清楚 -fno-exceptions
是做什么的?当一个 C++ 函数被编译时,编译器会生成所谓的“栈展开代码”——用于销毁局部对象的代码。当异常需要通过多个函数调用来传播时,它会沿着栈展开链来销毁这些局部对象。使用 -fno-exceptions
编译的代码不包含完整的栈展开信息,这意味着如果异常到达这部分代码,会触发 std::terminate
并终止程序。
为了突出函数调用,我们将使用一个仅进行函数调用的程序。
注意这里的分支预测优化。这些差异如此之小,除非你正确理解这一点,否则你实际上主要在测量分支预测性能。这远远超过了栈展开带来的任何影响,可以说后者几乎可以忽略不计。
我们想衡量启用异常功能后对程序编译的影响。另外需要注意的是,我们认为实际抛出异常是一个非常罕见的情况——我们想衡量的仅仅是使用异常所带来的影响,而非实际抛出异常的影响。
我们将以几种不同的模式来编译(您可以在文末找到包含完整代码及所有#ifdef
的仓库链接)。
no_unwind
:不使用任何异常,手动抛出的异常被编译时宏替换为std::abort
,编译时:
-fno-exceptions -fno-rtti -fno-unwind-tables -fomit-frame-pointer
这些标志用于编译器设置,以禁用异常处理、运行时类型信息和帧指针,提高编译效率和减小程序大小。
unwind_dontcare
: 完全不抛出异常,手动抛出被编译时宏通过std::abort
替换,但编译器启用了异常支持——这对于因性能原因而禁用异常的代码来说是一个非常重要的测试,这些代码本身并不使用异常。unwind_noexcept
: 完全不抛出异常,手动抛出被编译时宏通过std::abort
替换,但编译器启用了异常支持,并且fibonacci
函数被标记为noexcept
。unwind_throwing
: 可以抛出异常,但在fibonacci
函数中没有任何catch
块——仅在main
函数中有一个catch
块。unwind_catching
: 你所看到的完整异常处理方式。- 还有一个普通的 C 测试——适用于那些坚信我们在切换到 C++ 编译器时损失了一点性能的人。
我用 gcc
、clang
和 Intel OneAPI 进行了所有测试,除了 -funroll-loops
选项默认未开启外,没有发现显著差异。我们主要会讨论 x86-64
架构下的 gcc
结果。请记住,x86-64
在硬件上对异常处理和栈展开有很好的支持,所以结果在其他架构上可能会略有差异。
我们来分析一下这些结果怎么样?
如果既不抛也不捕,启用异常与否都没啥区别。 抛异常的成本较小,而每次捕获异常的成本较高。在这种场景下,确实有一些实际工作要做——在每次调用前保存处理程序。
说到普通的C语言时,答案是——不,大多数情况下,在C++模式下编译C代码一般不会带来性能损耗。
更令人惊讶的是——去掉帧指针的二进制稍微大了一点。不使用帧指针怎么会变得更大呢?
那么,这就是第一节-fomit-frame-pointer
,另一个不怎么好的优化手段。
省略帧指针可以在开始时节省一条指令,但是这样做也迫使编译器使用基于稍微大一些的指令访问所有局部变量,而不是使用更高效的 % rsp
指令,而是使用更高效的 % rbp
指令访问。
-省略栈帧指针 -fno-exceptions
随便(让编译器自己决定,随它去吧)
常规优化(-O2,这是编译器的常规优化选项)递归展开(类似循环展开的优化方法)已经生效。这些函数变得更大,运行速度也略快一些。正如前面提到的LLVM编译器工程师所说,异常的抛出和捕获妨碍了编译器的优化,使得递归无法展开。
第二课需要注意的一个非常重要的事情是:我们在函数大小上节省了几十字节的努力,完全被递归展开导致的500字节增加所掩盖,现在看起来完全微不足道。 这在未来值得考虑。
极致优化(-O3)好吧,使用 -O3
时,gcc
现在在异常处理上更胜一筹。对于所有情况,递归展开都是可能的。不仅抛出和捕获操作现在实际上已经没有性能开销——这完全是巧合——得益于更有效的 CPU 流水线利用,在每一级递归中进行捕获实际上比不使用异常更快。 只需查看 stalled-cycles-backend
计数器即可发现问题所在。
'./test-cc-O3-unwind_catching' 性能计数器统计信息:
19 565,33 毫秒 任务时钟周期 # 1,000 个 CPU 被利用
184 次 上下文切换次数 # 每秒 9,404 次
0 次 CPU 迁移 # 每秒 0,000 次
110 次 页面错误 # 每秒 5,622 次
66 614 649 139 个 周期 # 3,405 GHz (49,99%)
6 001 820 007 个 前端闲置周期 # 前端周期闲置 9,01% (50,01%)
862 867 946 个 后端闲置周期 # 后端周期闲置 1,30% (50,02%)
146 312 773 990 个 指令 # 每周期 2,20 条指令
# 平均每条指令空闲周期 0,04 (50,01%)
26 776 756 708 个 分支 # 每秒 1.369 亿
414 037 604 个 分支错误 # 所有分支中有 1.55% 发生错误 (49,98%)
时间: 19,568759571 秒
用户时间: 19,566978000 秒
系统时间: 0,000000000 秒
性能计数器统计 './test-cc-O3-no_unwind':
21 431,10 毫秒 任务时钟周期 # 每秒 1,000 个 CPU 被利用
157 次上下文切换次数 # 每秒 7,326 次上下文切换
0 次 CPU 迁移次数 # 每秒 0,000 次 CPU 迁移
52 次页面故障 # 每秒 2,426 次页面故障
72 985 918 854 周期数 # 3,406 GHz (50.00%)
6 651 978 854 前端闲置周期 # 9,11% 的前端闲置周期 (50.00%)
9 548 404 958 后端闲置周期 # 13,08% 的后端闲置周期 (50.00%)
134 993 845 095 条指令 # 每条周期 1,85 条指令
# 每条指令 0,07 个闲置周期 (50.00%)
19 862 285 441 分支数 # 每秒 926,797 百万分支
788 489 544 分支错误数 # 所有分支中 3,97% 的分支错误 (50.00%)
总耗时 21,433689694 秒
用户运行时间 21,428887000 秒
系统运行时间 0,003999000 秒
我不想深入到这个700字节的函数中去看为什么结果——这些结果完全是偶然的——在不同的CPU上可能会不一样——因为这是一台较老的AMD CPU,优化可能不够完美。但无论如何,这个教训是正确的,非常重要。
第三课
只需避免调整编译器设置。编译器作者几乎不可能遗漏任何隐藏的性能优化选项,他们一般都很清楚自己在做什么。
在栈展开时添加代码正如先前所说,栈展开代码的任务就是销毁局部变量。然而,在第一次测试中,该函数并没有包含任何带有析构器的局部变量。我们将会看到,即使添加一个带有析构器的变量也会使得执行时间差异无法测量——由于这个差异极其微小。不过这样我们就能够看出代码大小的变化。下面是第二个测试的代码:
我们增加了一个无法优化的人造对象,它有一个非平凡的析构器,当异常传播时必须被调用。尽管我非常小心地避免了任何内存分配,但这已经足以占据执行时间的大部分——现在执行时间的差异几乎无法察觉。
然而,代码大小的实验结果仍然保持不变。相比之下,尽管代码大小的略微增加在未优化的情况下仅略有增加,这些循环和递归展开优化的效果显得更加明显。
我们可以通过最后一个例子来查看栈展开过程所需的额外代码示例。
这是未优化的原始版本的代码。析构函数的调用代码是函数末尾的一个小子例程,调用了局部对象的析构函数。新的 x86-64
endbr64
指令——称为 间接分支追踪 的安全特性——标记有效的跳转位置,并且使得函数开头和栈展开例程的开头容易被识别。当函数正常返回时,一个 jmp
指令允许跳过栈展开过程。try
/ catch
语句将它们的入口点保存在一个特殊的内存段中,该内存段充当第二个栈。有时,特别是对于标准库,使用静态表可以提高效率。更多详细信息可参见[#6]。
很少有情况下不使用异常是正确的选择。例如,在嵌入式软件领域——代码大小可能是一个关键因素——如果编译器支持,可以省略编译器运行时的一些部分。
不要忘记,大多数标准的 C++ 编译器总是会使用预编译的 C++ 运行库,并且这个运行库支持异常。
此外,在非常底层的代码中,特别是在预期错误率非常高时,比如 Rust 中的 Result
或 C++ 中的新 std::expected
这样的替代方案可以是更好的选择。
值得一提的是,Rust——不使用异常,而是仅依赖于更高性能的Result
——正试图成为C的替代品而不是C++。
由于C++同时具备高级和低级语言的特点,它提供了相应的机制和功能。
结论部分- 如果你不使用异常,并且决定在编译时禁用它们,你可以在某些情况下期望有一些非常小的执行时间的减少,但通常这些减少在优化构建中会完全消失。
- 减少栈展开代码所获得的代码大小节省大约在5%到10%之间,但这些节省与优化中的循环展开相比,简直不值一提。
- 如果你决定实际使用异常——但是并没有大量抛出——每个
try
和catch
块都有一个小的代价,这些代价在经过优化的构建中会变得微不足道。 throw
语句可能妨碍编译器的优化,因此尽量避免在紧密循环中使用它。- 如果你经常抛出异常,你应该尝试使用其他错误处理机制之一。
如果你的应用程序是独立的,那么这可能只影响你自己——在你调试它时。但是如果你正在构建一个别人会链接的库或目标文件,我强烈建议你不要这样做,以免迟早会有一个用户(或用户)浪费大量时间调试一个非常奇怪的问题。
你可以在下面找到用于此故事的所有代码(请根据实际情况补充链接)。
GitHub - mmomtchev/cpp-exceptions-cost: C++异常的真正代价。通过在github.com创建账户来参与mmomtchev/cpp-exceptions-cost的开发……github.com参考文献:
- Google C++ 编码风格指南 https://google.github.io/styleguide/cppguide.html#Exceptions
- 异常与性能,LLVM 讨论 https://discourse.llvm.org/t/exceptions-and-performance/56185
- 调查 C++ 异常的性能开销:https://pspdfkit.com/blog/2020/performance-overhead-of-exceptions-in-cpp/
- "P2544R0 C++ 异常正变得越来越成问题
- C++ 性能技术报告书 https://www.open-std.org/jtc1/sc22/wg21/docs/TR18015.pdf
- 栈展开及异常处理简介 https://www.zyma.me/post/stack-unwind-intro/
- 使用 ASAN 在 Windows 上调试 Node.js 插件中的随机内存损坏(使用 MSVC):https://mmomtchev.medium.com/debugging-random-memory-corruption-with-asan-in-a-node-js-addon-on-windows-with-msvc-6246af0c22c7
在 Windows 上使用 Node.js 插件中的 C++ 异常处理引发这个故事的难题:
或者在 Windows 上遇到 Node.js 模块中的可怕陷阱 _HAS_EXCEPTIONS
。
如果你正在构建 Node.js 扩展,微软与 Node.js 核心团队联手为你埋下了一个真正糟糕的地雷。MSVC 提供了两种不同的 std::exception
实现,它们在内存大小和行为上有所不同。如果你的部分代码在没有启用异常的情况下编译,而另一部分代码启用了异常,你将得到一个勉强能运行的二进制文件,但会有一些非常细微的内存对齐错误。这种情况也适用于 Node.js 本身,以及默认情况下禁用异常的 node-gyp
。Node.js 并不使用 C++ 异常,因而不会触发该问题,但是任何在 Windows 上运行并使用异常的 Node.js 扩展会受到影响。
正因为这个问题,我决定研究一下这两个曾经让我头疼的编译器选项 -fno-exceptions
和 /EH*
带来的益处。特别是 MSVC,它是其中的罪魁祸首,因为它的默认行为偏离了 C++ 标准,这意味着几乎每个项目都会对其进行修改。
这里有更多的关于这个话题的信息。
- SWIG JavaScript 手册中的一条说明:
- 在
gdal-async
里有个注释:
- 一个例子概要,展示了在 MSVC 中,
std::exception
的行为根据宏_HAS_EXCEPTIONS
的设置会有所不同。
我是因为一起巨大的司法丑闻而失业的工程师,这起丑闻涉及因性勒索动机对法国警察和一些世界上最大的IT公司进行勒索,现我在法国靠社会福利生活度日。
我的专业领域是把C++和JavaScript结合起来。
我是 SWIG JavaScript Evolution 的作者,并开发了 hadron
构建系统。我写了很多将 C++ 库绑定到 JavaScript 的工具,并且还负责维护这些工具。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章