在C ++类中使用虚拟方法的性能成本是多less?

在C ++类(或其任何父类中)中至less有一个虚拟方法意味着该类将具有一个虚拟表,并且每个实例都将具有一个虚拟指针。

所以内存成本相当明显。 最重要的是实例的内存开销(特别是当实例很小的时候,例如,如果它们只是包含一个整数),在这种情况下,在每个实例中都有一个虚拟指针可能会使实例的大小增加一倍。虚拟表所占用的内存空间,相比于实际的方法代码所占用的空间,我想它通常是微不足道的。

这使我想到了我的问题:是否有一个可衡量的性能成本(即速度影响)使虚拟方法成为可能? 在运行时,在每个方法调用时都会在虚拟表中进行查找,所以如果对这个方法的调用非常频繁,并且这个方法非常短,那么可能会出现可衡量的性能问题。 我想这取决于平台,但有谁运行一些基准?

我问的原因是我遇到了一个错误,这是因为程序员忘记定义一个虚拟方法。 这不是我第一次看到这种错误。 我想:为什么我们在需要时添加虚拟关键字,而不是删除虚拟关键字,当我们绝对确定它不需要? 如果性能成本低,我想我会在我的团队中简单地推荐以下内容:在每个课程中简单地使每个方法默认为虚拟(包括析构函数),并且只在需要时将其删除。 这听起来很疯狂吗?

我在 3ghz的有序PowerPC处理器上运行了一些时间 。 在该架构上,虚拟函数调用比直接(非虚拟)函数调用花费的时间要长7纳秒。

所以,除非函数像Get()/ Set()访问器那样简单,否则不值得担心成本,除了inline之外,其他任何东西都是浪费的。 对于内联到0.5ns的function,7ns的开销是严重的; 一个需要500毫秒才能执行的函数的开销是毫无意义的。

虚拟函数的大代价并不是vtable中函数指针的查找(通常只是一个单一的周期),但间接跳转通常不能被分支预测。 这可能导致一个大的pipe道冒泡,因为直到间接跳转(通过函数指针的调用)已经退休并计算出新的指令指针,处理器才能获取任何指令。 所以,虚拟函数调用的代价比查看程序集的代价要大得多,但仍然只有7纳秒。

编辑:安德鲁,不确定,和其他人也提出了一个很好的观点,虚拟函数调用可能会导致指令caching未命中:如果你跳转到一个代码地址是不是在caching中,那么整个程序来到一个死的停顿,而指令从主存储器中取出。 这总是一个显着的失速:在氙气,约650个周期(通过我的testing)。

但是,这不是虚拟函数特有的问题,因为即使是直接函数调用,如果跳转到不在caching中的指令,也会导致错误。 重要的是这个函数是否在最近才运行(使之更可能在caching中),以及你的架构是否可以预测静态(而不是虚拟)分支并提前将这些指令提取到caching中。 我的PPC不,但也许是英特尔最新的硬件。

我的时间控制icache的影响没有执行(故意,因为我试图检查CPUpipe道孤立),所以他们打折的成本。

调用虚函数时肯定会有可测量的开销 – 调用必须使用vtable来parsing该types对象的函数的地址。 额外的指示是你最小的担心。 vtables不仅阻止了许多潜在的编译器优化(因为types是编译器的多态),它们还可以摧毁你的I-Cache。

当然,这些惩罚是否重要取决于您的应用程序,这些代码path的执行频率以及您的inheritance模式。

在我看来,把所有东西都默认为虚拟是一个你可以通过其他方式解决的问题的全面解决scheme。

也许你可以看看课程是如何devise/logging/书写的。 通常,类的头文件应该很清楚哪些函数可以被派生类覆盖,以及如何调用它们。 让程序员编写这个文档有助于确保它们被正确标记为虚拟。

我也想说,把每个函数声明为虚拟的可能会导致更多的错误,而不是忘记标记为虚拟的东西。 如果所有的function都是虚拟的,那么所有的function都可以被基础类别 – 公共的,保护的,私人的 – 所有东西都变成公平的游戏。 意外或意图的子类可以改变function的行为,然后在基本实现中使用时会导致问题。

这取决于。 :)(你有什么期望吗?)

一旦一个类获得了一个虚拟函数,它就不能再是一个POD数据types了(它可能不会是一个之前的,在这种情况下这不会有什么区别),并且使得整个范围的优化变得不可能。

普通PODtypes的std :: copy()可以使用简单的memcpy例程,但非PODtypes必须更仔细地处理。

由于vtable必须被初始化,所以构造变得慢很多。 在最坏的情况下,POD和非POD数据types之间的性能差异可能是显着的。

在最坏的情况下,你可能会看到执行速度降低了5倍(这个数字是我最近为了重新实现一些标准库类而从大学项目中获得的),只要存储的数据types得到虚函数表)

当然,在大多数情况下,你不可能看到任何可衡量的性能差异,这只是指出,在一些边界情况下,这可能是昂贵的。

但是,性能不应该成为您在这里的主要考虑因素。 一切虚拟化并不是一个完美的解决scheme,其他原因。

允许派生类中的所有内容都被覆盖,这使得保持类不变性更加困难。 当一个类的任何一个方法可以在任何时候被重新定义时,一个类如何保证它保持一致的状态?

让一切都变得虚拟可能会消除一些潜在的错误,但也会引入新的错误。

如果你需要虚拟调度的function,你必须付出代价。 C ++的优点是可以使用编译器提供的虚拟调度的高效实现,而不是自己实现的可能效率低下的版本。

但是,如果你不需要这个开销,可能会花费太多。 而且大多数类不是被devise为inheritance的 – 创build一个好的基类不仅仅是使其function变得虚拟。

虚拟调度比一些替代方法慢了一个数量级 – 这不是由于间接的内定,而是内联的预防。 下面我通过对比虚拟调度与在对象中embedded“type(-identifying)number”的实现并使用switch语句来selecttypes特定的代码来说明。 这完全避免了函数调用的开销 – 只是做一个本地跳转。 通过特定于types的function强制本地化(在交换机中),可维护性,重新编译依赖性等成本是可能的。


实施

 #include <iostream> #include <vector> // virtual dispatch model... struct Base { virtual int f() const { return 1; } }; struct Derived : Base { virtual int f() const { return 2; } }; // alternative: member variable encodes runtime type... struct Type { Type(int type) : type_(type) { } int type_; }; struct A : Type { A() : Type(1) { } int f() const { return 1; } }; struct B : Type { B() : Type(2) { } int f() const { return 2; } }; struct Timer { Timer() { clock_gettime(CLOCK_MONOTONIC, &from); } struct timespec from; double elapsed() const { struct timespec to; clock_gettime(CLOCK_MONOTONIC, &to); return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec); } }; int main(int argc) { for (int j = 0; j < 3; ++j) { typedef std::vector<Base*> V; V v; for (int i = 0; i < 1000; ++i) v.push_back(i % 2 ? new Base : (Base*)new Derived); int total = 0; Timer tv; for (int i = 0; i < 100000; ++i) for (V::const_iterator i = v.begin(); i != v.end(); ++i) total += (*i)->f(); double tve = tv.elapsed(); std::cout << "virtual dispatch: " << total << ' ' << tve << '\n'; // ---------------------------- typedef std::vector<Type*> W; W w; for (int i = 0; i < 1000; ++i) w.push_back(i % 2 ? (Type*)new A : (Type*)new B); total = 0; Timer tw; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) { if ((*i)->type_ == 1) total += ((A*)(*i))->f(); else total += ((B*)(*i))->f(); } double twe = tw.elapsed(); std::cout << "switched: " << total << ' ' << twe << '\n'; // ---------------------------- total = 0; Timer tw2; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) total += (*i)->type_; double tw2e = tw2.elapsed(); std::cout << "overheads: " << total << ' ' << tw2e << '\n'; } } 

performance结果

在我的Linux系统上:

 ~/dev g++ -O2 -o vdt vdt.cc -lrt ~/dev ./vdt virtual dispatch: 150000000 1.28025 switched: 150000000 0.344314 overhead: 150000000 0.229018 virtual dispatch: 150000000 1.285 switched: 150000000 0.345367 overhead: 150000000 0.231051 virtual dispatch: 150000000 1.28969 switched: 150000000 0.345876 overhead: 150000000 0.230726 

这表明联机式转换方式约为(1.28 – 0.23)/(0.344 – 0.23)= 9.2倍。 当然,这是特定于确切的系统testing/编译器标志和版本等,但通常是指示性的。


评论RE虚拟分发

必须说的是,虚拟函数调用的开销是很less有意义的,然后只是为了所谓的微不足道的函数(比如getter和setter)。 即使那样,你也许能够提供一个function来一次获取和设置很多东西,从而最大限度地降低成本。 人们担心虚拟调度的方式太多了,所以在find别扭的方法之前,也要进行剖析。 与他们的主要问题是,他们执行一个外联函数调用,虽然他们也离开代码执行改变caching利用模式(更好或(更经常)更糟糕)。

在大多数情况下额外的成本几乎没有。 (赦免双关语)。 ejac已经发布了明智的相关措施。

你放弃的最大的事情是由于内联可能的优化。 如果用常量参数调用函数,它们可能会特别好。 这很less有真正的区别,但在less数情况下,这可能是巨大的。


关于优化:
了解和考虑你的语言结构的相对成本是很重要的。 大O符号是故事的一半 – 你的应用程序如何扩展 。 另一半是前面的不变因素。

作为一个经验法则,除非有清楚明确的迹象表明这是一个瓶颈,否则我不会为了避免虚拟function而走出困境。 干净的devise始终是第一位的 – 但只有一个利益相关者不应该不当地伤害他人。


构造示例:一百万个小元素的arrays上的空虚拟析构函数可能会播放至less4MB的数据,从而颠覆您的caching。 如果这个析构函数可以被内联,数据将不会被触及。

编写库代码时,这样的考虑是不成熟的。 你永远不知道有多less个循环会放在你的函数中。

虽然其他人都对虚拟方法等的性能是正确的,但我认为真正的问题是团队是否知道C ++中虚拟关键字的定义。

考虑这个代码,输出是什么?

 #include <stdio.h> class A { public: void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

这里没有什么奇怪的:

 A::Foo() B::Foo() A::Foo() 

没有什么是虚拟的。 如果在A和B类中都将虚拟关键字添加到Foo的前面,我们可以得到这个输出:

 A::Foo() B::Foo() B::Foo() 

几乎所有人都期望。

现在,你提到有一些错误,因为有人忘了添加一个虚拟的关键字。 所以考虑这个代码(虚拟关键字被添加到A,而不是B类)。 那么输出是什么?

 #include <stdio.h> class A { public: virtual void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

答:就像虚拟关键字被添加到B一样? 原因是B :: Foo的签名完全符合A :: Foo(),因为A的Foo是虚拟的,B也是虚拟的。

现在考虑一下B的Foo是虚拟的,而A的不是。 那么输出是什么? 在这种情况下,输出是

 A::Foo() B::Foo() A::Foo() 

虚拟关键字在层次结构中向下工作,而不是向上。 它从来没有使基类方法虚拟。 在层次结构中遇到虚拟方法的第一次是多态性开始。 以后的课程没有办法让以前的课程具有虚拟方法。

不要忘记,虚拟方法意味着这个class级给予未来class级重写/改变其某些行为的能力。

所以,如果你有一个规则去除虚拟关键字,它可能没有预期的效果。

C ++中的虚拟关键字是一个强大的概念。 你应该确保团队中的每个成员都知道这个概念,以便能够按照devise使用。

根据您的平台,虚拟呼叫的开销可能是非常不可取的。 通过声明每个函数虚拟你实质上通过一个函数指针来调用它们。 至less这是一个额外的解引用,但在一些PPC平台上,它将使用微码或其他缓慢的指令来实现这一点。

由于这个原因,我build议不要这样做,但是如果它能帮助你防止错误,那么它可能是值得的。 不过,我不禁认为必须有一些值得寻找的中间立场。

它只需要一些额外的asm指令来调用虚拟方法。

但是我不认为你担心乐趣(int a,int b)与fun()相比有一些额外的“推”指令。 所以,不要担心虚拟,直到你处于特殊情况,并且确实会导致问题。

PS如果你有一个虚拟的方法,确保你有一个虚拟的析构函数。 这样你就可以避免可能的问题


回应“xtofl”和“汤姆”的评论。 我做了三个function的小testing:

  1. 虚拟
  2. 正常
  3. 正常与3个int参数

我的testing是一个简单的迭代:

 for(int it = 0; it < 100000000; it ++) { test.Method(); } 

这里的结果是:

  1. 3,913秒
  2. 3,873秒
  3. 3,970秒

它是在debugging模式下由VC ++编译的。 我只做了每个方法5个testing,并计算了平均值(所以结果可能相当不准确)…无论如何,这些值几乎是相等的假设1亿个电话。 而3个额外推/stream行的方法则较慢。

主要的一点是,如果你不喜欢push / pop的类比,想想你的代码中额外的if / else? 当你添加额外的if / else时,你会考虑CPUpipe道吗?)另外,你永远不会知道代码将运行在哪个CPU上…通常的编译器可以为一个CPU生成更优化的代码, C ++编译器 )