大约三年前,在 C++异常展开中注意到严重的性能问题。由于展开路径上的竞争,系统核心越多,问题越严重,展开速度可能降低几个数量级。由于向后兼容性的限制,这种竞争不易消除,P2544讨论了通过 C++语言变化来解决此问题的方法。
但幸运的是,人们找到了侵入性较小的解决方案。首先,Florian Weimer更改了 glibc,提供了一种无锁机制来查找给定共享对象的(静态)展开表。这消除了“简单”C++程序最严重的竞争。例如,在一个微基准测试中,该测试调用一个进行一些计算(每个函数调用 100 次 sqrt)的函数,并以一定概率抛出异常,以前随着核心数量的增加,可扩展性非常差。使用他的补丁,现在在双插槽 EPYC 7713 上使用 gcc 14.2 看到以下性能发展(运行时间,以毫秒为单位):
线程 | 0%失败 | 29 | 29 | 29 | 29 | 29 | 29 | 29 | 42 |
---|---|---|---|---|---|---|---|---|---|
0.1%失败 | 29 | 29 | 29 | 29 | 29 | 29 | 29 | 32 | |
1%失败 | 29 | 30 | 30 | 30 | 30 | 30 | 32 | 34 | |
10%失败 | 36 | 36 | 37 | 37 | 37 | 37 | 47 | 65 |
这几乎是完美的。128 个线程稍慢,但这是可以预期的,因为一个 EPYC 只有 64 个核心。随着失败率的提高,展开本身会变慢,但在这里仍然可以接受。因此,大多数 C++程序都没问题。
然而,对于我们的用例来说,这还不够。我们在运行时动态生成机器代码,并且希望能够通过生成的代码传递异常。glibc 的\_dl\_find\_object 机制未用于即时编译(JIT)代码,而是 libgcc 维护自己的查找结构。历史上,这是一个带有全局锁的简单列表,性能当然很差。但通过一系列补丁,我们设法将 libgcc 更改为使用无锁 B 树来维护动态展开帧。使用与上述类似的实验,但现在是 JIT 生成的代码(使用 LLVM 19),我们得到以下结果:
线程 | 0%失败 | 32 | 38 | 48 | 64 | 48 | 36 | 59 | 62 |
---|---|---|---|---|---|---|---|---|---|
0.1%失败 | 32 | 32 | 32 | 32 | 32 | 48 | 62 | 68 | |
1%失败 | 41 | 40 | 40 | 40 | 53 | 69 | 80 | 83 | |
10%失败 | 123 | 113 | 103 | 116 | 128 | 127 | 131 | 214 |
这些数字比静态生成的代码噪声更大,但总体观察结果相同:展开现在可以随着核心数量扩展,即使在具有大量核心的机器上,我们也可以安全地使用 C++异常。
那么现在一切都完美了吗?不。首先,只有 gcc 有完全可扩展的帧查找机制。clang 有自己的实现,据我所知,由于 DwarfFDECache 中的全局锁,它仍然不能正常扩展。请注意,至少在许多 Linux 发行版中,clang 默认使用 libgcc,因此该问题在那里并不立即明显,但纯 llvm/clang 构建将存在可扩展性问题。其次,通过 JIT 生成的代码展开要慢得多,这很不幸。但不可否认,问题没有这里显示的那么严重,带有 JIT 代码的基准测试只是由于静态代码和 JIT 代码的交互方式而有更多的堆栈帧需要展开。并且优先考虑静态展开而不是动态展开帧是有意义的,因为大多数人从不即时生成代码。
总体而言,我们现在对展开机制非常满意。瓶颈已经消失,即使在高核心数量下性能也很好。对于高失败率来说仍然不合适,类似于P709的东西会更好,但我们可以接受现状。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。