Virtual——潜在的性能杀手
virtual
关键字是 C++ 运行时多态的重要特征,在我的日常学习中感受并不深。但是在优化代码性能时,组长却极力强调去除 virtual
,并指出它会带来不小的性能负担。
由于我对 virtual
的理解并不深入,当时只是照做。不过优化之后确实有了一些性能提升,因此决定尝试认真了解一下 virtual
的机制,并记录下这个过程。
需要说明的是,目前我对 virtual
的认识还没达到“深入理解”的程度,内容也可能会随着我对 C++ 更深入的学习不断补充和修正(但愿我会想起来更新)。
在查阅资料过程中,我总结了一些比较有意思的看法:
virtual
的性能问题主要来自于阻碍编译器inline
优化;- 在没有明确瓶颈时,没必要过早优化;
- 很多网上的 benchmark 数据可能过时,不妨自己验证;
- 为了“去
virtual
”而打破封装未必值得,还是要兼顾设计合理性。
性能测试
使用 Google Benchmark 分别测试了小对象与大对象在调用普通函数与虚函数时的性能表现。
测试项 | N=1,000 (ns) | N=10,000 (ns) | N=100,000 (ns) |
---|---|---|---|
Small_Normal | 489 | 4,971 | 50,812 |
Small_Virtual | 1,485 | 14,697 | 149,316 |
Large_Normal | 511 | 7,693 | 108,530 |
Large_Virtual | 1,327 | 17,204 | 230,358 |
分析总结:
虚函数比普通函数慢
在小对象上,虚函数调用比普通调用慢约 3 倍;大对象上大约慢 2.5 倍。
原因是虚函数通过 vtable 调用,增加了一次间接寻址,破坏了 CPU 指令预测和内联优化。对象大小影响普通调用更大
小对象的普通函数更快(5μs vs. 7.7μs),但虚函数差异不大,说明大对象的复制和访问成本对普通函数影响更大,而虚函数已经有固定的额外开销。耗时基本线性增长
不论是普通函数还是虚函数,调用时间都随 N 增大而线性增长,说明整体调用成本稳定,无明显隐藏开销。大对象下虚函数绝对耗时更高
在 N=100000 的场景下,大对象虚函数耗时超过 230ms,而小对象约 149ms,说明大对象的缓存和内存访问开销仍然显著影响调用效率。
开销来源
1. 间接调用
虚函数不能通过静态地址直接调用,而是通过 vptr
(虚指针)查表找到目标函数地址后跳转:
1 |
|
代价:
- 增加一次指针间接寻址;
- 阻碍编译器
inline
优化; - 对分支预测不友好;
- 指令缓存和流水线效率下降。
2. 无法内联
- 普通函数如果可
inline
,可在调用处直接展开,避免函数栈帧开销; - 虚函数调用在运行时决定目标,编译器无法内联展开。
3. 增加对象结构复杂度
- 每个含虚函数的对象含一个
vptr
指针; - 每个类编译时生成一个
vtable
; - 多重继承、虚继承会有多个
vptr
,增加开销与管理复杂度。
4. 编译器优化受限
虚函数调用对编译器的分析造成障碍:
- 不能进行跨函数优化(IPO);
- 死代码移除、代码去重受限;
- 很难做“去虚拟化(de-virtualization)”优化。
一些取舍与思考
方面 | 正面(优势) | 负面(代价) |
---|---|---|
灵活性 | 支持多态、可扩展设计 | 类型体系复杂,调试和维护成本上升 |
性能 | —— | 间接调用、编译器优化难度加大 |
内存 | —— | 每个对象额外含一个 vptr ,类含 vtable |
可维护性 | 接口清晰、设计优雅 | 若滥用抽象,反而使结构难以理解 |
使用建议:何时该用 virtual,何时不该用?
适合使用 virtual 的情况
- 接口设计稳定,需要支持多种实现;
- 插件式系统或运行时策略切换场景;
- 框架或库中暴露统一入口;
- 对性能不敏感,但强调可扩展性与解耦。
不建议使用 virtual 的情况
- 热路径性能敏感代码(图形渲染、游戏循环、音频编解码等);
- 调用频率极高(每秒数百万次)的函数;
- 只有一个实现,后续也不会扩展;
- 可以通过模板 / CRTP 替代运行时多态。
可选替代方案:在性能与多态之间求平衡
1. CRTP(Curiously Recurring Template Pattern)
利用模板实现“编译期多态”,既支持内联,又不需要虚函数:
1 |
|
适合单继承、可通过模板展开的场景。
2. 函数对象或 Lambda
可用 std::function
、函数对象或 lambda 实现策略注入等模式,适用于组合行为逻辑而非类型派发。
3. 手写 vtable 表(LLVM/Dyno 风格)
极端情况下,可以手写一个 vtable 表,用函数指针模拟虚函数行为,节省 RTTI 和对象元信息开销,常见于编译器、解释器等系统中。
其他
在我们的项目中,图算法中存在的大量的Graph Edge Vertex,完美符合上面提到的2点:
- 调用频率极高(每秒数百万次)的函数;
- 可以通过模板 / CRTP 替代运行时多态。
Edge的创建少则上万,多则百万,效率和内存开销都很大,而且也没有真正的运行时多态的需求,所以采用了参见上一篇的CRTP进行优化,将虚函数改为模板函数。
在最终的测试中,改动前后的性能提升其实很小(2%~3%),但是分配的堆内存由11MB降低到了8MB(当然也不仅仅虚函数是这一点影响到了内存)。
附上benchmark代码:
1 |
|