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

时间:2012-12-05 04:30:48

标签: c++ performance

在我不知情的情况下,对于C ++虚拟调用,它需要:

  1. 从符号表
  2. 获取对象的类型
  3. 从类型表中获取v表
  4. 使用v-table中的函数签名
  5. 搜索函数
  6. 调用该函数。
  7. 对于非虚拟(例如C)呼叫,只需要#4。

    我认为#3应该是最耗时的。鉴于C ++中实时覆盖的性质,我看不到上述步骤的编译时间优化的可能性。因此,对于具有长函数签名的复杂类继承,C ++虚拟调用应该比非虚拟调用慢得多。

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

6 个答案:

答案 0 :(得分:7)

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

对基于v-table的调度如何工作的理解很差。它简单得多:

  1. 从对象指针获取v表。为相关函数选择正确的v表(如果使用了多个基类)。
  2. 为此v表指针添加一个特定的偏移量,确定编译时间,从而获取特定的函数指针。
  3. 调用该函数指针。
  4. 每个对象都有一个v表指针,该指针指向该对象的原始类型的v表。因此,无需从"符号表"中获取类型。不需要搜索v表。它的编译时间可以根据编译时提供的函数签名确切地确定需要访问v表中的哪个指针。它完全是关于编译器如何索引类中的每个虚函数。它可以确定每个虚函数的特定顺序,因此当编译器调用它时,它可以确定要调用的函数。

    总体来说它非常快。

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

答案 1 :(得分:4)

通过普通函数调用进行虚函数调用的开销是两个额外的fetch操作(一个用于获取v指针的值,另一个用于获取方法的地址)。
在大多数情况下,这种开销并不足以显示性能分析。

此外,在某些情况下,如果要在编译时确定要调用的virtual函数,智能编译器将执行此操作而不是在运行时执行此操作。

答案 2 :(得分:4)

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

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(),因此必须使用虚拟调用机制。

答案 3 :(得分:2)

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


...但是让我们首先用图表(64位)修改你的假设。虽然对象模型是特定于实现的,但是在Itanium 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的指针(由一个bitcast获取,它是透明的CPU)
  • %2:v-table本身
  • %3:指向Derived::foo(表格的第一个元素)的指针

答案 4 :(得分:1)

它基本上是两个读取(一个从对象实例获取vtable ptr,一个从vtable获取函数指针)和一个函数调用。内存通常相当热,并且保留在缓存中,并且由于没有任何分支,CPU可以非常好地管理这些以隐藏大量费用。

答案 5 :(得分:0)

可能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 ++中的语法简单得多(呃!),但正如你所看到的,动态调度并不复杂,只需在带有vtable的函数指针的(静态)表中进行简单的查找在构造对象时设置的指针。此外,编译器自动执行上述操作并不困难(即,编译器可以轻松地获取上面的C ++代码并在下面生成相应的C代码)。在多重继承的情况下,它同样容易,每个基类都有自己的vtable指针,派生类必须为每个基类设置那些指针,就是这样,只有你现在需要的唯一粘性点在向上或向下构建层次结构时应用指针偏移量(因此使用C ++样式的转换运算符的重要性!)。

总的来说,当认真的人讨论虚函数的开销时,他们并不是在谈论执行函数调用所需的“复杂”步骤(因为这是相当简单的,有时会被优化掉)。他们最有可能讨论与缓存相关的问题,例如抛弃预取器(通过难以预测调度的调用)并阻止编译器将函数打包到最终可执行文件中需要它们的位置(或者甚至内联到它们)(或DLL)。到目前为止,这些问题是虚函数的主要开销,但仍然没有那么重要,而且有些编译器足够智能,可以很好地缓解这些问题。