浅谈 C++ 异常的性能
起因
这件事的开端有些离奇,Y7n05h 在完成 Leetcode 题目 LRU Cache 时写出了这样的代码:
1 |
class LRUCache { |
这样的代码虽然被 Accept 了,但性能并不好,在运行时间上只击败了 5% 的 C++ 提交。对此成绩,Y7n05h 属实不能接受。
代码中对以元素的查找都是的,链表的访问也都是从头尾访问或是直接通过指针访问链表节点,甚至链表节点也会被复用,减少内存分配器的开销。
排除众多无可优化之处,Y7n05h 凭借直觉认为是 异常 拖慢了程序的运行。
这也好办,只需修改 LRUCache::get
和 LRUCache::put
即可。可以看到,代码中对异常的应用实际上只有用来检测 key
是否在 map
中。那么改用 std::unordered_map::count
来判断就好。
1 |
int get(int key) { |
修改后,代码的运行时间击败了 90% 左右的提交。
那么问题来了,为什么一个异常对性能的影响这么大?
性能分析
为了本地环境测试两版程序的性能,Y7n05h 编写了如下测试代码:
1 |
int main() { |
两版程序均使用相同的编译参数进行编译:
1 |
clang++ -stdlib=libc++ -O2 -flto -g lru1.cpp |
为了尽可能展现现代 C++ 程序所能获得的编译器优化后的性能差异,Y7n05h 开启了 O2
和 lto
。
测试环境:
1 |
clang version: 13.0.1 |
这是使用异常机制的程序性能火焰图:
这是不使用异常机制的程序性能火焰图:
如果这还不够明显,那还可依看看 perf diff
的输出:
1 |
21.05% -19.30% libc.so.6 [.] __vfprintf_internal |
可以看到 execute_cfa_program
、uw_frame_state_for
、uw_update_context_1
在改动前后变化很大。一个重要线索是这些和栈展开有关的函数都在 libgcc_s.so.1
中。
从此处可知, clang 编译的程序也是使用 libgcc 实现的栈展开机制。
异常的实现
在此前 Y7n05h 只知道异常的原理是栈展开,但对细节一概不知道。借这次的机会来稍微看看异常处理的原理吧。
1 |
/* SimpleException.cpp */ |
看看 bar
函数的反汇编(使用 Intel 风格):
1 |
0x0000000000401995 <+0>: push rbp |
可以看到汇编中使用 __cxa_throw
将异常抛出,call __cxa_throw
执行结束并不返回到后面的一条汇编,而是跳转到对应的 catch
语句(后文会提及)或 terminate
(如果异常不被 catch
)。
这是在抛出异常时的部分调用栈(箭头由 调用者 指向 被调用者):
__cxa_throw
-> _Unwind_RaiseException
-> uw_init_context_1
-> uw_frame_state_for
-> _Unwind_Find_FDE
-> search_object
-> classify_object_over_fdes
__cxa_throw
-> _Unwind_RaiseException
-> uw_init_context_1
-> uw_frame_state_for
-> execute_cfa_program
对这段代码涉及的代码不算太长但也不短,感兴趣的读者可以去 gcc
的源码里面自行查找,Y7n05h 在这里就不放了。
看到这些函数,就不会问出:「一次异常和一次 if-else
的性能比较」的问题了。
注:和一次 if-else
相比,异常机制 必然 有更多的开销。因为抛出异常的机制的代码数量和单次 if-else
相比是多个数量级的差异,根本没有比较的意义。但用这样的对比来说明「错误码」和「异常」这两种错误处理的机制的性能是荒谬的(详见下文)。
因此,异常机制对代码的 bad path
是通常是劣化。
刚刚看完了 throw
的实现,再看看 catch
:
1 |
0x00000000004019e3 <+0>: push rbp |
若 bar
不抛出异常,0x00000000004019f4 <+17>: call 0x401995 <bar(unsigned int)>
执行完成后会正常返回至 0x00000000004019f9 <+22>: lea rax,[rip+0x98604] # 0x49a004
;若 bar
抛出异常,执行完这次 0x00000000004019f4 <+17>: call 0x401995 <bar(unsigned int)>
后会回到 0x0000000000401a0a <+39>: mov rdi,rax
。
可以看到这个 try { ... }
其实并没有开销,开销在 catch
上。
与使用错误码相比,使用异常的方案在 happy path
(不抛出异常的情况) 上,消除了一次条件跳转(或多次条件跳转,如果结果需要在多层函数调用间逐层返回的话),众所周知,条件跳转是有可能带来 分支预测惩罚
的。因此,异常机制对代码的 happy path
是优化。
再看看 __cxa_begin_catch
和 __cxa_end_catch
的部分调用栈(箭头由 调用者 指向 被调用者):
__cxa_begin_catch
-> __cxa_get_globals
__cxa_end_catch
-> __cxa_get_globals_fast
__cxa_end_catch
-> _Unwind_DeleteException
这些调用栈都很浅。对整个 throw
、catch
的机制来说,复杂度在 throw
上,catch
只做了很少的工作,这一点从调用栈的长度也能看出一点端倪。
是否该使用异常?
上文的讨论中,已经说明了异常机制在代码的 happy path
比错误码具有更多的优势,在 bad path
与错误码相比则具有劣势。
在较深的调用栈中逐层返回错误码可能需要使用多次 if-else
完成多次条件跳转,这也带来了多次潜在的 分支预测惩罚
。此时使用异常所需的成本 可能 相对较低。
高效使用异常必然该是 扬长避短 的,只讨论 happy path
上 exception
的优势,或是只讨论 bad path
上 exception
的劣势都是片面的。
讨论异常的使用必然需要综合这两方面的影响。
因此,在绝大多数情况都在 happy path
时,happy path
带来的优化比 bad path
带来的劣化多,使用异常才比使用错误码有优势。
回到最开始的 leetcode 题目上去,异常在查找 LRU 缓存中不存在的 key
时被抛出,显然不是(至少在这个测试的情景中显然不是)极少数情况。Y7n05h 在这里的对异常的用法,将异常作为了一种常见情况的控制流,已经背离了扬长避短的做法,性能差也就不奇怪了。
更详细、严谨的说明异常和一些别的处理错误的方式的比较,以及何时该使用异常请看参考资料 1 和参考资料 2。
异常的原理
对于 Y7n05h 而言,至此已经解决所遇到的问题了:异常有多慢,异常的正确使用场景是什么也都有了答案。
这一段只是为了浅浅的看看为什么 throw
会那么慢,这也就需要看看 __cxa_throw
究竟是怎么实现的。
如需详细、具体的了解 C++ 异常的实现原理请看参考资料 3,那是非常棒的资料。
—
在抛出异常时,程序需要在 ELF
的 .eh_frame section
的信息的指导下完成对栈的两次遍历(一次用来查找 catch
一次用来逐层的对栈上的元素执行析构)。
.eh_frame section
占据的空间一点也不小。
对上面用作示例的 SimpleException.cpp
程序 .eh_frame section
占据了 0x108 bytes
,而 .text section
也才占据了 0x1ef bytes
。
参考资料
1:Bjarne Stroustrup. C++ exceptions and alternatives[G/OL]. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1947r0.pdf.
2:Herb Sutter. Zero-overhead deterministic exceptions: Throwing values[G/OL]. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0709r4.pdf.
3:Nico Brailovsky. C++ exceptions under the hood[G/OL]. https://monkeywritescode.blogspot.com/p/c-exceptions-under-hood.html.
4:江召伟. c++ 异常处理[G/OL]. https://www.cnblogs.com/jiangzhaowei/p/4989197.html.
浅谈 C++ 异常的性能