What is the performance cost of having a virtual method in a C++ class?
在C++类(或其任何父类)中至少有一个虚拟方法意味着该类将具有一个虚拟表,并且每个实例都有一个虚拟指针。
所以内存成本是非常清楚的。最重要的是实例的内存成本(尤其是如果实例很小,例如它们只包含一个整数:在这种情况下,每个实例中都有一个虚拟指针可能使实例的大小增加一倍。至于虚拟表占用的内存空间,我想与实际方法代码占用的空间相比,通常可以忽略不计。
这就引出了我的问题:是否有一个可度量的性能成本(即速度影响)使一个方法成为虚拟的?在运行时,在每个方法调用时都会在虚拟表中进行查找,因此,如果对此方法的调用非常频繁,并且此方法非常短,那么可能会对性能造成可度量的影响?我想这取决于平台,但是有人运行过基准测试吗?
我之所以问这个问题,是因为我遇到了一个错误,它碰巧是由于一个程序员忘记定义一个虚拟方法。这不是我第一次看到这种错误。我想:为什么我们要在需要时添加虚拟关键字,而不是在绝对确定不需要时删除虚拟关键字?如果性能成本较低,我想我只建议在我的团队中使用以下方法:在默认情况下,只需在每个类中使每个方法都是虚拟的,包括析构函数,并且只在需要时将其移除。你觉得这很疯狂吗?
我在3GHz的PowerPC处理器上运行了一些计时。在该体系结构中,虚拟函数调用比直接(非虚拟)函数调用长7纳秒。
因此,不值得担心成本,除非函数类似于一个普通的get()/set()访问器,其中除inline之外的任何东西都是浪费的。一个函数的7ns开销(内联到0.5ns)很严重;一个函数的7ns开销(需要500ms才能执行)毫无意义。
虚拟函数的巨大成本并不是在vtable中查找函数指针(通常只是一个循环),而是间接跳转通常无法进行分支预测。这可能导致大管道气泡,因为在间接跳转(通过函数指针的调用)失效并计算出新的指令指针之前,处理器无法获取任何指令。所以,虚拟函数调用的成本比从程序集的角度看要大得多…但仍然只有7纳秒。
编辑:Andrew,不确定,其他人也提出了一个很好的观点,即虚拟函数调用可能导致指令缓存未命中:如果跳转到不在缓存中的代码地址,那么当从主内存中提取指令时,整个程序将完全停止。这总是一个重要的停顿:氙气,大约650个周期(根据我的测试)。
但是,这不是特定于虚拟函数的问题,因为如果跳转到不在缓存中的指令,即使是直接的函数调用也会导致遗漏。重要的是函数是否在最近运行过(使其更可能在缓存中),以及您的体系结构是否能够预测静态(而非虚拟)分支并提前将这些指令提取到缓存中。我的PPC没有,但可能是英特尔最新的硬件。
我的计时控制了icache未命中对执行的影响(故意的,因为我试图孤立地检查CPU管道),所以它们会降低成本。
在调用虚函数时肯定会有可测量的开销——调用必须使用vtable来解析该类型对象的函数地址。额外的指示是你最不担心的。vtables不仅可以阻止许多潜在的编译器优化(因为类型是多态的编译器),而且还可以破坏I-cache。
当然,这些惩罚是否重要取决于应用程序、执行这些代码路径的频率以及继承模式。
不过,在我看来,默认情况下一切都是虚拟的,这是一个可以用其他方法解决的问题的综合解决方案。
也许您可以看看类是如何设计/记录/编写的。通常,类的头应该非常清楚哪些函数可以被派生类重写,以及如何调用它们。让程序员编写此文档有助于确保它们正确标记为虚拟的。
我还想说,将每个函数声明为虚函数可能会导致更多的错误,而不仅仅是忘记将某个函数标记为虚函数。如果所有的功能都是虚拟的,那么所有的东西都可以被基本类(公共的、受保护的、私有的)所取代,所有的东西都变成了公平的游戏。然后,由于意外或意图子类可以改变函数的行为,从而在基本实现中使用时导致问题。
型
视情况而定。:)(你还期待什么吗?)
一旦一个类得到了一个虚拟函数,它就不能再是pod数据类型了(它可能以前也不是,在这种情况下,这不会有什么区别),这使得整个优化过程都不可能。
普通pod类型上的std::copy()可以使用简单的memcpy例程,但非pod类型必须更小心地处理。
由于vtable必须初始化,因此构造速度会慢很多。在最坏的情况下,POD数据类型和非POD数据类型之间的性能差异可能非常显著。
在最坏的情况下,您可能会看到执行速度慢了5倍(这个数字取自我最近做的一个大学项目,该项目是为了重新实现一些标准的库类。一旦存储的数据类型具有vtable,我们的容器就需要大约5倍的时间来构建)
当然,在大多数情况下,您不太可能看到任何可测量的性能差异,这只是简单地指出,在某些边界情况下,这可能是昂贵的。
但是,在这里,性能不应该是您的主要考虑因素。由于其他原因,使一切虚拟化并不是一个完美的解决方案。
允许在派生类中重写所有内容会使维护类不变量变得更加困难。当一个类的任何一个方法可以在任何时候被重新定义时,该类如何保证它保持一致的状态?
使一切虚拟化可能会消除一些潜在的错误,但它也引入了新的错误。
如果你需要虚拟调度的功能,你必须付出代价。C++的优点在于,可以使用编译器提供的虚拟调度的高效实现,而不是实现自己的可能低效版本。
然而,如果你不需要x的话,在头顶上笨拙地移动可能有点太远了。而大多数类的设计并不是从继承而来——要创建一个好的基类,需要的不仅仅是使其函数虚拟化。
虚拟调度比某些替代方法慢了一个数量级——这不是由于间接操作,而是由于防止了内联。下面,我将通过将虚拟分派与在对象中嵌入"类型(标识)号"的实现进行对比,并使用switch语句选择特定于类型的代码来说明这一点。这样就完全避免了函数调用开销——只需执行本地跳转。通过强制本地化(在交换机中)类型特定的功能,可能会对可维护性、重新编译依赖项等造成成本。
实施
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | #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 << ' '; // ---------------------------- 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 << ' '; // ---------------------------- 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 << ' '; } } |
性能结果
在我的Linux系统上:
1 2 3 4 5 6 7 8 9 10 11 | ~/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倍。当然,这是特定于具体的系统测试/编译器标志和版本等,但通常是指示性的。
重新虚拟调度的注释
但必须指出的是,虚拟函数调用开销是很少有意义的,而且通常只用于被称为琐碎函数(如getter和setter)的函数。即使这样,您也可以提供一个单一的函数来同时获取和设置大量的内容,从而将成本降到最低。人们过于担心虚拟调度方式——在找到笨拙的替代方案之前,先进行分析。它们的主要问题是它们执行了一个离线函数调用,尽管它们也会使执行的代码去域化,从而改变缓存利用模式(更好或(更经常)更糟)。
在大多数情况下,额外的成本实际上是零。(请原谅这个双关语)。EJAC已经发布了合理的相关措施。
您放弃的最大的一件事是由于内联而可能进行的优化。如果用常量参数调用函数,它们会特别好。这很少有真正的区别,但在少数情况下,这可能是巨大的。
关于优化:了解和考虑语言结构的相对成本是很重要的。大O符号只是故事的一半——您的应用程序如何扩展。另一半是前面的常数因子。
根据经验,我不会为了避免虚拟功能而不择手段,除非有明确而具体的迹象表明它是一个瓶颈。一个干净的设计总是第一位的——但只有一个利益相关者不应该过度伤害其他人。
人为的例子:一百万个小元素数组上的一个空的虚拟析构函数可能会穿透至少4MB的数据,从而破坏您的缓存。如果这个析构函数可以被内联掉,那么数据就不会被访问。
在编写库代码时,这样的考虑还为时过早。你永远不知道在你的函数周围会有多少个循环。
型
虽然其他人对虚拟方法的性能等都是正确的,但我认为真正的问题是团队是否知道C++中虚拟关键词的定义。
考虑这个代码,输出是什么?
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 27 28 29 30 31 32 33 34 35 | #include <stdio.h> class A { public: void Foo() { printf("A::Foo() "); } }; class B : public A { public: void Foo() { printf("B::Foo() "); } }; 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; } |
没什么奇怪的:
1 2 3 | A::Foo() B::Foo() A::Foo() |
号
因为没有什么是虚拟的。如果在A类和B类中都将虚关键字添加到foo的前面,那么我们将得到输出:
1 2 3 | A::Foo() B::Foo() B::Foo() |
几乎是每个人都期望的。
现在,您提到了存在错误,因为有人忘记添加虚拟关键字。所以考虑一下这段代码(其中虚拟关键字被添加到A类,而不是B类)。那么输出是什么呢?
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 27 28 29 30 31 32 33 34 35 | #include <stdio.h> class A { public: virtual void Foo() { printf("A::Foo() "); } }; class B : public A { public: void Foo() { printf("B::Foo() "); } }; 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的不是虚拟的情况。那么输出是什么呢?在这种情况下,输出为
1 2 3 | A::Foo() B::Foo() A::Foo() |
号
虚拟关键字在层次结构中向下工作,而不是向上工作。它从不使基类方法成为虚拟的。在层次结构中第一次遇到虚拟方法是在多态性开始时。以后的类无法使以前的类具有虚拟方法。
别忘了,虚拟方法意味着这个类为未来的类提供了覆盖/更改其某些行为的能力。
因此,如果您有删除虚拟关键字的规则,它可能没有预期的效果。
C++中的虚拟关键词是一个强大的概念。您应该确保团队中的每个成员都真正了解这个概念,以便可以按照设计使用它。
根据平台的不同,虚拟调用的开销可能非常不受欢迎。通过声明每个虚函数,您实际上是通过一个函数指针调用它们。至少这是一个额外的解引用,但是在一些PPC平台上,它将使用微编码或其他缓慢的指令来完成这一点。
出于这个原因,我建议不要你的建议,但如果它有助于你防止错误,那么它可能值得权衡。不过,我忍不住想,一定有一些中间地带值得寻找。
调用虚拟方法只需要几个额外的asm指令。
但我不认为你担心fun(int a,int b)与fun()相比,它有一些额外的"push"指令。所以也不要担心虚拟机,除非您处于特殊情况下并看到它确实会导致问题。
另外,如果您有一个虚拟方法,请确保您有一个虚拟析构函数。这样可以避免可能出现的问题
回应"xtofl"和"tom"的评论。我做了3个功能的小测试:
我的测试是一个简单的迭代:
1 2 3 | for(int it = 0; it < 100000000; it ++) { test.Method(); } |
结果如下:
它是由VC++在调试模式下编译的。我每种方法只做了5次测试,并计算了平均值(所以结果可能非常不准确)。无论如何,假设有1亿次呼叫,这些值几乎相等。加3次推/弹的方法比较慢。
要点是,如果您不喜欢与push/pop进行类比,可以在代码中考虑额外的if/else吗?当您添加额外的if/else;-)时,您是否考虑了CPU管道?此外,您永远不知道代码将在哪个CPU上运行…通常编译器可以为一个CPU生成更优化的代码,而对于另一个(英特尔C++编译器)则不太理想。