C++ 异常性能三年后

大约三年前,在 C++异常展开中注意到严重的性能问题。由于展开路径上的竞争,系统核心越多,问题越严重,展开速度可能降低几个数量级。由于向后兼容性的限制,这种竞争不易消除,P2544讨论了通过 C++语言变化来解决此问题的方法。

但幸运的是,人们找到了侵入性较小的解决方案。首先,Florian Weimer更改了 glibc,提供了一种无锁机制来查找给定共享对象的(静态)展开表。这消除了“简单”C++程序最严重的竞争。例如,在一个微基准测试中,该测试调用一个进行一些计算(每个函数调用 100 次 sqrt)的函数,并以一定概率抛出异常,以前随着核心数量的增加,可扩展性非常差。使用他的补丁,现在在双插槽 EPYC 7713 上使用 gcc 14.2 看到以下性能发展(运行时间,以毫秒为单位):

线程0%失败2929292929292942
0.1%失败2929292929292932
1%失败2930303030303234
10%失败3636373737374765

这几乎是完美的。128 个线程稍慢,但这是可以预期的,因为一个 EPYC 只有 64 个核心。随着失败率的提高,展开本身会变慢,但在这里仍然可以接受。因此,大多数 C++程序都没问题。

然而,对于我们的用例来说,这还不够。我们在运行时动态生成机器代码,并且希望能够通过生成的代码传递异常。glibc 的\_dl\_find\_object 机制未用于即时编译(JIT)代码,而是 libgcc 维护自己的查找结构。历史上,这是一个带有全局锁的简单列表,性能当然很差。但通过一系列补丁,我们设法将 libgcc 更改为使用无锁 B 树来维护动态展开帧。使用与上述类似的实验,但现在是 JIT 生成的代码(使用 LLVM 19),我们得到以下结果:

线程0%失败3238486448365962
0.1%失败3232323232486268
1%失败4140404053698083
10%失败123113103116128127131214

这些数字比静态生成的代码噪声更大,但总体观察结果相同:展开现在可以随着核心数量扩展,即使在具有大量核心的机器上,我们也可以安全地使用 C++异常。

那么现在一切都完美了吗?不。首先,只有 gcc 有完全可扩展的帧查找机制。clang 有自己的实现,据我所知,由于 DwarfFDECache 中的全局锁,它仍然不能正常扩展。请注意,至少在许多 Linux 发行版中,clang 默认使用 libgcc,因此该问题在那里并不立即明显,但纯 llvm/clang 构建将存在可扩展性问题。其次,通过 JIT 生成的代码展开要慢得多,这很不幸。但不可否认,问题没有这里显示的那么严重,带有 JIT 代码的基准测试只是由于静态代码和 JIT 代码的交互方式而有更多的堆栈帧需要展开。并且优先考虑静态展开而不是动态展开帧是有意义的,因为大多数人从不即时生成代码。

总体而言,我们现在对展开机制非常满意。瓶颈已经消失,即使在高核心数量下性能也很好。对于高失败率来说仍然不合适,类似于P709的东西会更好,但我们可以接受现状。

阅读 8
0 条评论