减小C++中异常和RTTI开销的方法

发布时间 2024-01-06 22:36:34作者: icysky

这是CppCon 2019 Herb Sutter的演讲De-fragmenting C++: Making exceptions and RTTI more affordable and usable的一篇笔记。虽然这场演讲的时间很长,但真正讨论的技术细节内容并不多。演讲对于一些常见争议的讨论值得一看,不过我不会在本文中过多地提及。


零开销抽象

C++中的异常和RTTI一直是争议很大的两个话题,一大主要原因是性能,另一方面则是二进制体积问题。这篇演讲提到了很重要的一点——zero-overhead abstraction与zero-cost abstraction不是一回事。Zero-overhead abstraction指的是你不为你不使用的抽象买单,而对于那些你使用的抽象,你有充足的理由来这样使用。凡是抽象必然会带来一些开销,所以我的观点是我们需要权衡利弊,即我们是否有充足的理由来使用这种抽象。

异常

有时候我们在抛出异常时可能会使用throw new exception这样的写法来支持异常的多态。这不仅会带来很大的额外开销,甚至new本身都可能抛std::bad_alloc异常(尽管我从没见过):

class my_exception : public std::exception {
  // implementation...
};

try {
  // do something may throw my_exception...
} catch (std::exception *e) {
  // Catching my_exception by std::exception * causes overhead.
  // Handle exception ...
}

更好的方法是使用值类型,即将异常放到栈上:

try {
  // do something may throw error code...
} catch (error_code e) {
  // Catch by value.
  // Handle exception ...
}

这样做首先消除了申请堆内存的开销,也没有匹配子类型的开销——error_code是不支持多态的类型,从而达到与返回error code接近甚至更优的性能(在不抛出异常的情况下,使用异常的代码性能会略优于返回错误码的代码)。

RTTI

我们使用RTTI最多的场景可能是dynamic_cast(我个人倒是极少使用,主要是我很少使用继承)来保证down cast的类型安全。但众所周知的是,dynamic_cast需要从虚表中查询类型信息,然后对比type_info,这个操作本身首先就很慢,在Windows上type_info的比较甚至是通过字符串对比来实现的。其次,编译器还需要在生成的二进制文件中存储类型信息,这会占用大量的存储空间,在Windows上占用的空间会相当大。但直接使用static_cast是不安全的,这会带来程序上的BUG,所以我们需要一种没有额外开销的安全的down cast的方法。

与异常的方案类似,使用静态信息可以极大地减小时间的开销,而关于类型信息的对比,像Windows那种字符串比较的方案显然还有巨大的优化空间,这完全可以优化为哈希值对比,而且这个哈希值可以在编译器完成计算。基于这种思想,clang团队提供了clang CFI编译选项。启用clang CFI选项会在使用static_cast向下转换类型时插入类型检查,生成的汇编代码只有5行核心代码:

; rcx == The right-hand side object pointer.
; First do the nullptr check. This could be optimized away but is not today.
; N.B. If the static_cast has to adjust the pointer base, this nullptr check
; already exists.
4885c9 test rcx, rcx
7416 je codegentest!DoCast+0x26

; Next load the RHS vftable and the comparison vftable.
488b11 mov rdx, qword ptr [rcx]
4c8d05ce8f0500 lea r8, [codegentest!MyChild1::`vftable']

; Now do the range check. Jump to the AppCompat check if the range check fails.
492bc0 sub rdx, r8
4883f820 cmp rdx, 20h
7715 ja codegentest!DoCast+0x3b ; Jump to app-compat check

注意前两行是nullptr检查,对于引用类型而言,nullptr检查也可以去掉,所以核心代码只有后5行。在核心代码中,只需要进行两次内存读取,一次加减运算与比较运算来检查vtable偏移量的额外开销。因为这种检查是基于vtable偏移量的,所以不需要引入RTTI信息,类型的比较也只需要进行依次整数比较。在保证类型安全的情况下,这几乎是最高效的方式了,不论是考虑到运行效率还是存储空间效率。不过因为其原理比较的是vtable偏移量,所以比较的对象不能跨DLL,不同DLL创建的对象是不能使用这种方法的。

后记

这场演讲关于代码技巧方面的建议基本就这些内容,中间还有不小的篇幅在讨论C++的错误处理方式以及全局new默认是否应当抛出异常的问题,本文不会就这两个问题展开讨论,感兴趣的话可以去听CppCon 2019的完整演讲。

谈及RTTI,其实我想到的更多的是反射的需求,而不是dynamic_cast。很多时候我们会有反序列化并创建对象这种需求,这种情况下我们通常会考虑使用工厂模式来创建多态对象,但实现工厂模式本身也很麻烦。一种常见的实现方式是在Json等结构化数据中存储类似类型ID的字段,在factory中写一个map之类的数据结构来创建对应的对象。在Java等有runtime的语言中,这一功能可以使用运行时的反射来实现,但在C++中就只能考虑自动生成map或者干脆手写了,但愿静态反射能解决这种需求吧。