看了 C++ 的虚函数的一些文章,对于其性能消耗的优化思路整理了一下。

1、开销

对于虚函数,编译器可以通过关键字 virtual 识别,并为它们创建虚表,虚表保存着指向函数地址的信息。当一个具有虚函数的对象(object) 被创建时,编译器也会为它创建指向虚表的虚指针。

所以要访问到对应的虚函数,每次执行需要先解引用虚指针,偏移到虚表对应函数位置,然后调用。虚函数的开销主要体现在:

  • 由于这个操作需要在运行时完成,所以虚函数没法像普通函数一样被 inline,同时也就失去了一些优化机会(inline 后可以进行更多的优化,如常量传播)。
  • 现代 CPU 在进行分支跳转前会进行分支预测,对于虚函数而言,硬件在很晚的时机才会知晓要跳转的位置,这可能会导致存在分支预测失败的额外开销。
  • 多次调用不同实现的相同符号的虚函数,对 cache 不友好。

根据性能测试结果,文章作者给出了以下建议:

  • 合理安排对象的内存位置。带来更好的局部性。
  • 尝试把一些小的函数脱虚(变成普通函数调用一样)。小函数的调用开销比执行更高。
  • 把数组里的对象先按类型排好序。这样对 cache 会更友好。

2、优化

2.1、strict-vtable-pointers

-fstrict-vtable-pointers 是一个编译器参数,用来向编译器保证在对象的生命周期内,虚指针是固定不变的。这样编译器就不需要多次对虚指针解引用了,对于执行频率高的虚函数和分支预测可能都有一定的优化。

先看一个例子,在正常的虚函数调用下,每次虚函数调用之前,都会需要指令先解引用虚指针:

加上 -fstrict-vtable-pointers 后就不需要重复解引用虚指针了,因为我们向编译器保证了虚指针不会中途被改变。

2.2、multivector

multivector 是不同类型数组的集合,声明类型为 template <typename... Types> std::tuple<std::vector<Types>...>,按类型来划分到不同的容器中,实际上就是借助 template 来消除虚函数动态派发的操作,同时也更好地满足局部性。(点链接查看具体数据结构实现)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
//实现
template <typename Function, typename T>
void for_all_p(Function fn) {
    std::vector<T>& my_vector = get_vector<T>();
    for (auto it = my_vector.begin(); it != my_vector.end(); ++it) {
        fn(*it);
    }
}

template <typename Function, typename T, typename Arg, typename... Args>
void for_all_p(Function fn) {
	for_all_p<Function, T>(fn);
	for_all_p<Function, Arg, Args...>(fn);
}

// 调用
jsl::multivector<circle, line, rectangle, monster> mv;
bitmap b(640, 480);
// Filling the container with random shapes
// ... ...
mv.for_all([&b](auto& o)  { o.draw(b); });

还有把运行时多态改造成在编译期解析的方式,称之为 CRTP(Curiously Recurring Template Pattern)。如果模版看起来还理解吃力的话,可以用之前推荐过的工具 c++ insight 先解析一遍。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <class T>
class Base {
public:
    void Foo() { // 作为转发
        static_cast<T*>(this)->FooImpl();
    }
};

class Child1 : public Base<Child1> {
public:
    void FooImpl() {
        std::cout << "hello1" << std::endl;
    }
};

class Child2 : public Base<Child2> {
public:
    void FooImpl() {
        std::cout << "hello2" << std::endl;
    }
};

template <class T>
void Print(Base<T>& base) {
    base.Foo();
}

2.3、final override

来看个具体的函数调用的案例:

可以直接从它们的二进制指令看出区别:

实际上对于 playground 函数的参数,在继承链上,如果我们确认 Grandchild 类型不会扩展子类,或者某个函数不会被重写了,则可以通过 final标记虚函数表示该函数不会有重写了。编译器就可以用直接函数调用来替代虚函数调用了,同时可能带来 inline 等优化。

final 也可以用在对象上:

2.4、Speculative Devirtualization

字节有篇文章也讲到这个的原理,有点像分支预测一样,把一个大概率调用的虚函数提前预测判断,是的话就直接调用,否则用指针间接调用(可以结合 PGO 的 profile 信息辅助判断)。

1
2
3
4
5
addr = vtable.do_something.addr@foo  // 虚函数指针
if (addr == FooImpl::do_something)   // 假设大概率为 FooImpl 对象
	FooImpl::do_something()          // 直接地址调用
else  
	call *addr                       // 指针间接调用

虽然增加了指令,但实际上只是一个地址比较的指令,而且直接调用可以被优化成 inline,有更多的后续优化机会,这里是优化了 indirect call 的消耗。但预测失败是可能导致负优化的,所以需要结合 PGO 的 profile 信息进行操作(Indirect Call Promotion)。

2.5、WHOLE PROGRAM OPTIMIZATION

知晓全部的继承链图信息是关键,使用 LTO 是其中一种方式,在链接期同时也具备了链接信息(internal、external 等),可以做 devirtualization 的操作。

2.6、internal-linkage

当一个类被定义在匿名命名空间,且在当前编译单元中没有子类,则可以把虚函数调用确切地改为对函数的直接调用。

参考