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


107

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

因此,内存成本非常明显。最重要的是实例上的内存开销(特别是如果实例很小,例如,如果它们仅打算包含一个整数:在这种情况下,每个实例中都有一个虚拟指针可能会使实例的大小增加一倍。)虚拟表所用的内存空间,我想它与实际方法代码所用的空间相比通常可以忽略不计。

这让我想到了一个问题:将方法虚拟化是否有可衡量的性能成本(即速度影响)?在每次调用方法时,都会在运行时在虚拟表中进行查找,因此,如果对这个方法的调用非常频繁,并且如果此方法很短,那么性能可能会受到影响吗?我猜这取决于平台,但是有人在运行一些基准测试吗?

我问的原因是,我遇到了一个错误,该错误恰好是由于程序员忘记定义虚拟方法而引起的。这不是我第一次看到这种错误。我想:我们为什么要添加虚拟关键字,而不是需要时取出时,我们绝对相信这是它的虚拟关键字没有必要?如果性能成本很低,我想我会在团队中简单推荐以下内容:只需在每个类中默认使每个方法都是虚拟的,包括析构函数,并且仅在需要时才将其删除。这听起来对您来说疯狂吗?



7
比较虚拟呼叫与非虚拟呼叫并不能完全解决。它们提供了不同的功能。如果要比较虚拟函数调用和C等价函数,则需要增加实现虚拟函数等效功能的代码成本。
马丁·约克

要么是switch语句,要么是大的if语句。如果您很聪明,可以使用函数指针表重新实现,但是出错的可能性要高得多。
马丁·约克2009年


7
问题是关于不需要虚拟的函数调用,因此比较是有意义的。
Mark Ransom

Answers:


104

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

因此,除非该函数像一个普通的Get()/ Set()访问器之类,否则其中的开销真的不值得担心,在该访问器中,除了内联之外的任何东西都是浪费的。内联到0.5ns的函数的7ns开销很严重;一个需要500毫秒执行的函数的7 ns开销是没有意义的。

虚拟函数的巨大代价实际上并不是在vtable中查找函数指针(通常只是一个周期),而是间接跳转通常无法分支预测。这可能会导致较大的流水线气泡,因为处理器无法提取任何指令,直到间接跳转(通过函数指针的调用)已退出并计算出新的指令指针为止。因此,虚拟函数调用的成本要比看汇编看起来要大得多……但是仍然只有7纳秒。

编辑:安德鲁,不确定,其他人也提出了一个很好的观点,即虚函数调用可能会导致指令高速缓存未命中:如果您跳转到不在高速缓存中的代码地址,则整个程序将陷入死机状态。指令从主存储器中取出。这始终是一个严重的停顿:在氙气灯上,大约有650个循环(根据我的测试)。

但这不是虚拟函数特有的问题,因为如果您跳转到不在缓存中的指令,那么即使直接调用函数也会导致未命中。重要的是该函数是否最近才运行过(使它更有可能在缓存中运行),以及您的体系结构是否可以预测静态(而非虚拟)分支并将这些指令提前提取到缓存中。我的PPC没有,但也许是Intel最新的硬件。

我的计时控制用于控制icache丢失对执行的影响(故意,因为我试图隔离检查CPU管道),因此它们降低了该成本。


3
以周期为单位的成本大约等于获取和分支退休结束之间的流水线级数。这不是微不足道的成本,它可以加起来,但是除非您试图编写一个紧密的高性能循环,否则可能会有更大的性能鱼供您油炸。
Crashworks

比它长7纳秒。如果正常通话时间是1纳秒,那么如果正常通话时间是70纳秒,则微不足道。
马丁·约克

如果看一下时序,我发现对于内联成本为0.66ns的函数,直接函数调用的差分开销为4.8ns,而虚函数为12.3ns(与内联函数相比)。您指出,如果函数本身花费一毫秒,那么7 ns毫无意义。
Crashworks

2
更像是600个循环,但这是一个好点。我将其排除在时间之外,因为由于管道气泡和prolog / epilog,我只对开销感兴趣。对于直接函数调用,icache未命中同样容易发生(氙气没有icache分支预测变量)。
Crashworks

2
较小的细节,但是对于“但是这不是特定于...的问题”,对于虚拟调度来说,这有点糟糕,因为必须在缓存中有一个额外的页面(如果碰巧跨越页面边界,则有两个页面) -用于类的虚拟调度表。
托尼·德罗伊

19

调用虚拟函数时,绝对有可衡量的开销-调用必须使用vtable解析该类型对象的函数地址。额外的说明是您最少的担心。vtable不仅会阻止许多潜在的编译器优化(由于类型是编译器的多态性),它们还会破坏I-Cache。

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

但是我认为,默认情况下将所有内容虚拟化是可以用其他方式解决的问题的全面解决方案。

也许您可以看看类的设计/记录/编写方式。通常,类的标头应明确说明派生类可以覆盖哪些函数以及如何调用它们。让程序员编写此文档有助于确保正确地将其标记为虚拟。

我还要说,将每个函数都声明为虚拟函数可能会导致更多的错误,而不仅仅是忘记将某些函数标记为虚拟函数。如果所有功能都是虚拟的,则所有内容都可以用基类(公共,受保护,私有)代替-一切都变得公平。然后,偶然或有意的子类可能会更改功能的行为,从而在基础实现中使用时会引起问题。


丢失的最大优化是内联,尤其是在虚拟函数通常很小或为空的情况下。
Zan Lynx

@Andrew:有趣的观点。但是,我在某种程度上不同意您的最后一段:如果基类具有save依赖于基类中函数的特定实现的函数write,那么在我看来,要么save是编码不良,要么write应该是私有的。
MiniQuark

2
仅仅因为写是私有的并不能防止它被覆盖。这是默认情况下不使事物虚拟化的另一个论点。无论如何,我想到的都是相反的情况-通用且编写良好的实现被具有特定且不兼容行为的事物所替代。
Andrew Grant

在缓存上进行投票-在任何大型的面向对象的代码库上,如果您不遵循代码局部性性能实践,则虚拟调用很容易导致缓存未命中并导致停顿。
不确定2009年

icache停滞真的很严重:在我的测试中有600个周期。
Crashworks

9

这取决于。:)(您还有其他期望吗?)

一旦一个类获得了一个虚函数,它就不能再成为POD数据类型(也可能不是以前的POD数据类型,在这种情况下不会有什么不同),这使得不可能进行全部优化。

纯POD类型上的std :: copy()可以求助于简单的memcpy例程,但是非POD类型必须更谨慎地处理。

由于必须初始化vtable,因此构建速度会慢很多。在最坏的情况下,POD和非POD数据类型之间的性能差异可能会很大。

在最坏的情况下,您可能会看到执行速度降低了5倍(该数字来自我最近为重新实现一些标准库类而做的一个大学项目。构建容器时,只要存储的数据类型达到vtable)

当然,在大多数情况下,您不太可能看到任何可衡量的性能差异,这只是要指出,在某些边界情况下,这样做可能会付出高昂的代价。

但是,性能不是这里的主要考虑因素。由于其他原因,使所有内容虚拟化不是一个完美的解决方案。

允许在派生类中覆盖所有内容,这使得维护类不变式变得更加困难。当可以随时重新定义其任何一种方法时,如何保证类保持一致状态?

将所有内容虚拟化可能会消除一些潜在的错误,但同时也会引入新的错误。


7

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

但是,如果您不需要的话,就用开销来烦恼自己,这可能会太过分了。而且大多数类都不是要继承的-创建良好的基类不仅需要将其功能虚拟化。


好的答案,但是,IMO,在下半年还不够强调:坦白地说,如果不需要,笨拙地浪费自己的开销,尤其是当使用这种语言时,“不要为自己付出的钱付钱”不使用。” 将所有内容默认为虚拟的,直到有人证明为什么/应该是非虚拟的才是可恶的政策。
underscore_d

5

虚拟调度比某些替代方案要慢一个数量级-并不是由于间接,而是因为防止了内联。下面,我通过将虚拟调度与在对象中嵌入“类型(标识)号”的实现进行对比,并使用switch语句选择特定于类型的代码,以进行说明。这样可以完全避免函数调用的开销-只需执行局部跳转即可。通过特定类型功能的强制本地化(在交换机中),在维护性,重新编译依赖性等方面存在潜在成本。


实施方式

#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';
  }
}

性能结果

在我的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倍。当然,这是特定于经过测试的确切系统/编译器标志和版本等,但通常是指示性的。


关于虚拟发货的评论

必须指出的是,虚函数调用的开销很少会变得很重要,而且仅对于通常称为琐碎的函数(如getter和setter)而言。即使这样,您也可以提供一个功能来一次获取并设置很多东西,从而将成本降到最低。人们过分担心虚拟派遣方式-在发现笨拙的替代方案之前要进行概要分析。它们的主要问题是它们执行了离线函数调用,尽管它们还对执行的代码进行了非本地化,从而改变了缓存利用率模式(更好或更(通常)更糟)。


我问了一个有关您的代码的问题,因为使用g++/ clang和有一些“奇怪”的结果-lrt。我认为值得在以后的读者中提及。
Holt

@Holt:给出了令人惊讶的结果,这是一个好问题!如果有一半的机会,我会在几天内仔细研究一下。干杯。
托尼·德罗伊

3

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

您放弃的最大事情是由于内联而可能进行的优化。如果使用常量参数调用函数,它们可能会特别好。这很少能带来真正的改变,但是在少数情况下,这可能是巨大的。


关于优化:
重要的是要了解并考虑语言构造的相对成本。大O表示法只是故事的一半- 您的应用程序如何扩展。另一半是前面的常数。

根据经验,除非有明确而明确的迹象表明它是瓶颈,否则我不会竭力避免使用虚函数。干净的设计永远是第一位的-但只有一个利益相关者不应该过分伤害他人。


人为的例子:一百万个小元素阵列上的空虚拟析构函数可能会翻阅至少4MB的数据,从而破坏您的缓存。如果可以内联该析构函数,则不会触及数据。

在编写库代码时,这些考虑还为时过早。您永远不知道函数周围会出现多少个循环。


2

尽管其他人对虚拟方法的性能都是正确的,但我认为真正的问题是团队是否了解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类中都将virtual关键字添加到Foo的前面,我们将得到以下结果:

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

几乎每个人都期望的。

现在,您提到存在错误,因为有人忘记添加虚拟关键字。因此,请考虑以下代码(将virtual关键字添加到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()

virtual关键字在层次结构中向下而不是向上。它永远不会使基类方法成为虚拟方法。多态性是何时开始在层次结构中首次遇到虚拟方法的。以后的类无法使先前的类具有虚拟方法。

不要忘记,虚方法意味着该类为将来的类提供了重写/更改其某些行为的能力。

因此,如果您有删除虚拟关键字的规则,则可能没有预期的效果。

C ++中的virtual关键字是一个强大的概念。您应该确保团队的每个成员都真正了解这个概念,以便可以按设计使用它。


汤米,您好,感谢您的教程。我们遇到的错误是由于基类方法中缺少“虚拟”关键字所致。顺便说一句,我是说要使所有功能都是虚拟的(不是相反的),然后,当显然不需要时,删除“虚拟”关键字。
MiniQuark

@MiniQuark:Tommy Hui在说,如果将所有函数虚拟化,程序员可能最终会在派生类中删除关键字,而没有意识到它没有任何作用。您将需要某种方式来确保删除virtual关键字始终在基类上发生。
M. Dudley

1

根据您的平台,虚拟呼叫的开销可能非常不理想。通过声明每个虚拟函数,您实际上是通过函数指针全部调用它们。至少这是额外的取消引用,但是在某些PPC平台上,它将使用微码或慢速指令来完成此操作。

出于这个原因,我建议您反对您的建议,但是如果它可以帮助您防止错误,那么值得进行权衡。但是,我不禁认为必须找到一些中间立场。


-1

只需几个额外的asm指令即可调用虚拟方法。

但我不认为您会担心fun(int a,int b)与fun()相比有一些额外的“推送”指令。因此,也不必担心虚拟机,直到您处于特殊情况并看到它确实会导致问题为止。

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


作为对“ xtofl”和“ Tom”评论的回应。我用3个功能进行了小型测试:

  1. 虚拟
  2. 正常
  3. 正常,带有3个int参数

我的测试是一个简单的迭代:

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

结果如下:

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

它是由VC ++在调试模式下编译的。我每个方法仅进行了5次测试,并计算了平均值(因此结果可能非常不准确)...不管怎样,假设有1亿次调用​​,这些值几乎相等。而且带有3个额外的push / pop的方法比较慢。

要点是,如果您不喜欢与push / pop进行类比,那么在代码中考虑其他if / else吗?你想想CPU流水线当你添加额外的if / else ;-)而且,你永远不知道什么CPU的代码将运行......常见的编译器生成的代码更优化的一个CPU和优化的较少为其他(英特尔C ++编译器


2
额外的asm可能只会触发页面错误(非虚函数不会出现)-我认为您大大简化了问题。
xtofl

2
+1以xtofl的评论。虚函数引入了间接性,从而引入了管道“气泡”并影响了缓存行为。
汤姆(Tom)2009年

1
在调试模式下计时任何事情都是没有意义的。MSVC在调试模式下生成非常慢的代码,并且循环开销可能掩盖了大部分差异。如果您追求高性能,是的,您应该考虑最小化快速路径中的if / else分支。有关低级x86性能优化的更多信息,请参见agner.org/optimize。(还有x86标签Wiki
Peter Cordes

1
@Tom:这里的关键是非虚函数可以内联,但虚函数不能(除非编译器可以进行虚虚拟化,例如,如果您final在重写中使用并且您有一个指向派生类型而不是基类型的指针) )。该测试每次都调用相同的虚函数,因此可以完美预测。除了call吞吐量受限之外,没有其他管道气泡。间接call可能要多一些。即使对于间接分支,分支预测也能很好地发挥作用,尤其是当分支始终位于同一目的地时。
彼得·科德斯

这落入了微基准测试的常见陷阱:当分支预测变量很热并且没有其他事情发生时,它看起来很快。间接的错误预测开销call比直接的错误开销高call。(是的,普通call指令也需要预测。提取阶段必须在解码该块之前知道要提取的下一个地址,因此它必须根据当前块地址而不是指令地址来预测下一个提取块。预测该块中有分支指令的地方...)
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.