为什么C++虚拟调用并不比非虚拟调用慢多少



在我看来,对于C++虚拟调用,它需要:

  1. 从符号表中获取对象的类型
  2. 从类型表中获取v-table
  3. 使用v表中的函数签名搜索函数
  4. 调用函数

对于非虚拟(如在C中)调用,只需要#4。

我认为#3应该是最耗时的。考虑到C++中实时重写的性质,我看不出对上述步骤进行编译时优化的潜力有多大。因此,对于具有长函数签名的复杂类继承,C++虚拟调用应该比非虚拟调用慢得多。

但所有的说法都是相反的,为什么?

  1. 从符号表中获取对象的类型
  2. 从类型表中获取v-table
  3. 使用v表中的函数签名搜索函数
  4. 调用函数

这是对基于v-table-based的调度工作原理的理解不足。它要简单得多:

  1. 从对象指针中获取v-table。为有问题的函数选择正确的v表(如果使用了多个基类)
  2. 向这个v表指针添加一个特定的偏移量(编译时间决定),从而获取一个特定函数指针
  3. 调用那个函数指针

每个对象都有一个v-table指针,该指针指向该对象原始类型的v-table。因此,不需要从"符号表"中获取类型。不需要搜索v表。编译时可以根据编译时提供的函数签名来确定v表中需要访问的指针。这一切都与编译器如何对类中的每个虚拟函数进行索引有关。它可以确定每个虚拟函数的特定顺序,因此当编译器调用它时,它可以确定调用哪个函数。

所以总体来说速度相当快。

在处理虚拟基类时,它有点复杂,但总体思路仍然相同。

与普通函数调用相比,虚拟函数调用的开销是两个额外的fetch操作(一个用于获取v-pointer的值,另一个用于获得方法的地址)
在大多数情况下,此开销不足以在性能评测中显示出来。

此外,在某些情况下,如果要调用的virtual函数可以在编译时确定,则智能编译器将这样做,而不是在运行时这样做。

1&2) 它不需要从任何"符号表"中检索对象的类型。v-table通常由对象中的隐藏字段指向。因此,检索v-table基本上是一个指针间接操作。

3) 未"搜索"v-table。每个虚拟函数在v表中都有一个固定的索引/偏移量,在编译时确定。所以这基本上是从指针的偏移量中提取。

因此,虽然它比直接的C风格调用慢,但并不像你建议的那样困难。它类似于C:中的类似内容

struct MyObject_vtable {
int (*foo)();
void (*bar)(const char *arg);
};
struct MyObject {
int m_instanceVariable1;
int m_instanceVariable2;
struct MyObject_vtable *__vtable;
};
struct MyObject * obj = /* ... construct a MyObject instance */;
// int result = obj->foo();
int result = (*(obj->__vtable.foo))();
// obj->bar("Hello");
(*(obj->__vtable.bar))("Hello");

此外,虽然这可能有点超出了问题的范围,但值得注意的是,编译器通常可以在编译时确定要调用的函数,在这种情况下,它可以直接调用该函数,而无需经过虚拟调用机制。例如:

MyObject obj1;
int result1 = obj1.foo();
MyObject *obj2 = getAMyObject();
int result2 = obj2->foo();

在这种情况下,在编译时就知道第一次调用要调用哪个foo(),所以可以直接调用它。对于第二个调用,getAMyObject()可能返回从MyObject派生的类的某个对象,该对象已覆盖foo(),因此必须使用虚拟调用机制。

实际上,这是一个瓶颈问题。。。


。。。但让我们首先用一个图表(64位)来修改您的假设。虽然对象模型是特定于实现的,但安腾ABI中使用的虚拟表(gcc、clang、icc…)的思想在C++中相对普遍。

class Base { public: virtual void foo(); int i; };
+-------+---+---+
| v-ptr | i |pad|
+-------+---+---+
class Derived: public Base { public: virtual void foo(); int j; };
+-------+---+---+
| v-ptr | i | j |
+-------+---+---+

在单个(非虚拟)基类的情况下,v-ptr是对象的第一个成员。因此,获得v-ptr很容易。从那时起,偏移量是已知的(在编译时),因此这只是一些指针算术,然后通过指针解引用进行函数调用。

让我们看看它的生活感谢LLVM:

%class.Base = type { i32 (...)**, i32 }
~~~~~~~~~~^  ^~~
v-ptr          i
%class.Derived = type { [12 x i8], i32 }
~~~~~~~~^  ^~~
Base         j
define void @_Z3fooR4Base(%class.Base* %b) uwtable {
%1 = bitcast %class.Base* %b to void (%class.Base*)***
%2 = load void (%class.Base*)*** %1, align 8
%3 = load void (%class.Base*)** %2, align 8
tail call void %3(%class.Base* %b)
ret void
}
  • %1:指向v-table的指针(通过位转换获得,在CPU方面是透明的)
  • %2:v表本身
  • %3:指向Derived::foo(表的第一个元素)的指针

它基本上是两个读取(一个从对象实例获取vtable ptr,另一个从vtable获取函数指针)和一个函数调用。内存通常相当热,并停留在缓存中,而且由于没有任何分支,CPU可以很好地进行管道传输,以隐藏大量开销。

也许C中的动态多态性示例可以帮助说明这些步骤。假设你在C++中有这些类:

struct Base {
int someValue;
virtual void bar();
virtual int foo();
void foobar();
};
struct Derived : Base {
double someOtherValue;
virtual void bar();
};

好吧,在C中,您可以通过这种方式实现相同的层次结构:

struct Base {
void** vtable;
int someValue;
};
void Base_foobar(Base* p);
void Base_bar_impl(Base* p);
int Base_foo_impl(Base* p);
void* Base_vtable[] = {(void*)&Base_bar_impl, (void*)&Base_foo_impl};
void Base_construct(Base* p) {
p->vtable = Base_vtable;
p->someValue = 0;
};
void Base_bar(Base* p) {
(void(*)())(p->vtable[0])();  // this is the virtual dispatch code for "bar".
};
int Base_foo(Base* p) {
return (int(*)())(p->vtable[1])();  // this is the virtual dispatch code for "foo".
};

struct Derived {
Base base;
double someOtherValue;
};
void Derived_bar_impl(Base* p);
void* Derived_vtable[] = {(void*)&Derived_bar_impl, (void*)&Base_foo_impl};
void Derived_construct(Derived* p) {
Base_construct(&(p->base));
p->base.vtable = Derived_vtable;  // setup the new vtable as part of derived-class constructor.
p->someOtherValue = 0.0;
};

显然,C++中的语法要简单得多(duh!),但正如您所看到的,动态调度并没有什么复杂的,只是在函数指针的(静态)表中简单地查找一个vtable指针,该指针是在构建对象时设置的。此外,上面的任何内容对于编译器来说都不难自动完成(即,编译器可以很容易地获取上面的C++代码并生成下面的相应C代码)。在多重继承的情况下,这也很容易,每个基类都有自己的vtable指针,派生类必须为其每个基类设置这些指针,仅此而已,在向上或向下转换层次结构时,唯一需要应用指针偏移的关键点是(因此,使用C++风格的转换运算符很重要!)。

总的来说,当严肃的人讨论虚拟函数的开销时,他们并不是在谈论执行函数调用所需的"复杂"步骤(因为这相当琐碎,有时会被优化掉)。他们最有可能谈论的是与缓存相关的问题,例如丢弃预取器(通过难以预测的调度调用),以及阻止编译器将函数打包到最终可执行文件(或DLL)中所需的位置附近(甚至内联到)。到目前为止,这些问题是虚拟函数的主要开销,但这些问题并没有那么严重,一些编译器足够聪明,可以很好地缓解这些问题。

最新更新