在C++类中使用虚方法的性能代价是什么?

131

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

因此,内存成本非常清晰。最重要的是实例上的内存成本(特别是如果实例很小,例如如果它们只是用于包含整数:在这种情况下,在每个实例中放置一个虚指针可能会使实例的大小翻倍。至于虚表所用的内存空间,我想通常与实际方法代码所用的空间相比可以忽略不计。

这就引出了我的问题:使方法变为虚方法是否会有可衡量的性能成本(即速度影响)?在运行时,每次方法调用都会在虚表中查找,因此如果对此方法进行非常频繁的调用,并且该方法非常短,则可能会产生可衡量的性能损失?我想这取决于平台,但是否有人运行过一些基准测试?

我提问的原因是我遇到了一个错误,这个错误是由于程序员忘记定义一个方法为虚方法。这不是我第一次看到这种错误。 我想:为什么我们在需要时添加虚关键字,而不是在我们绝对确定不需要时删除虚关键字?如果性能成本低,我想我会在我的团队中简单地建议以下做法:默认情况下,使每个类中的所有方法都变为虚方法,包括析构函数,在需要时再将其删除。这听起来很疯狂吗?


10
将虚拟调用和非虚拟调用进行比较是没有意义的,它们提供不同的功能。如果您想将虚拟函数调用与C语言中相应的等效方式进行比较,则需要添加实现虚拟函数等效功能的代码的成本。 - Martin York
12
这个问题涉及到不需要使用虚函数的函数调用,因此比较是有意义的。 - Mark Ransom
1
如果您在使用Visual C++,考虑使用“override”这个非标准扩展:http://msdn.microsoft.com/en-us/library/z8ew2153.aspx。它非常适合在编译时检测此类错误。 - Jon
默认将所有东西都虚拟化,除非有人证明它不能/不应该是虚拟的,这是一种可怕的政策。 - underscore_d
显示剩余6条评论
9个回答

131

我在一台3ghz的顺序执行PowerPC处理器上运行了一些时间测试。在该架构上,虚函数调用比直接(非虚)函数调用多花费7纳秒。

因此,除非函数是像微不足道的Get()/Set()访问器之类的东西,在这种情况下,除了内联之外的任何操作都有点浪费,否则没有必要担心成本。对于一个内联到0.5ns的函数而言,7ns的开销是严重的;对于一个需要500ms才能执行的函数而言,7ns的开销是无意义的。

虚函数的大成本并不是查找vtable中的函数指针(通常只需要一个周期),而是间接跳转通常无法被分支预测。这可能会导致大型流水线气泡,因为处理器在间接跳转(通过函数指针调用)已退役并计算出新的指令指针之前无法获取任何指令。因此,虚函数调用的成本比从汇编代码中看起来的要大得多...但仍然只有7纳秒。

编辑:安德鲁(Andrew)、不确定和其他人还提出了一个非常好的观点,即虚函数调用可能会导致指令缓存未命中:如果跳转到一个不在缓存中的代码地址,那么整个程序就会停止,直到从主内存中获取指令。这总是一个重大的停顿:在Xenon上,约为650个周期(根据我的测试)。

然而,这不仅是虚函数的问题,因为即使直接函数调用也会导致缓存未命中,如果跳转到不在缓存中的指令。重要的是函数最近是否被运行过(使其更可能在缓存中),以及您的架构是否可以预测静态(非虚拟)分支并提前将这些指令获取到缓存中。我的 PPC 不行,但也许 Intel 的最新硬件可以做到。
我的计时控制了 icache 未命中对执行的影响(故意这样做,因为我试图独立地检查 CPU 流水线),因此它们排除了这个成本。

3
在流水线的取指和分支结束之间的阶段数量大致等于循环成本。这不是一个微不足道的成本,它可能会累加,但除非你试图编写高效的紧密循环,否则可能有更重要的性能问题需要解决。 - Crashworks
比什么长 7 纳秒?如果一个普通调用是 1 纳秒,那么这很重要;但如果一个普通调用是 70 纳秒,那就不重要了。 - Martin York
1
如果你看一下时间,我发现一个花费0.66ns的内联函数,直接函数调用的差异开销为4.8ns,虚函数为12.3ns(与内联相比)。你提出了一个很好的观点,如果函数本身需要1毫秒,那么7ns就无关紧要了。 - Crashworks
你的计时和测试忽略了虚函数在大型程序中会引起的缓存未命中成本,这是非常具有误导性的。如果我没记错的话,Xenon处理器在缓存未命中时会停顿约900个周期。 - Not Sure
2
更接近于600个周期,但这是一个很好的观点。我没有将其计入计时,因为我只关注由流水线气泡和 prolog/epilog 导致的开销。icache miss 发生在直接函数调用时同样容易发生(Xenon 没有 icache 分支预测器)。 - Crashworks
2
细节不大,但是关于“然而这不是特定于问题的...”,对于虚拟调度来说情况会稍微糟糕一些,因为需要额外的一页(如果恰好跨越页面边界,则需要两页)在缓存中 - 用于类的虚拟调度表。 - Tony Delroy

22

调用虚函数确实会带来可度量的开销 - 调用必须使用虚表来解析该类型对象的函数地址。多出来的指令是你最不用担心的事情。虚表不仅会防止许多潜在的编译器优化(因为类型是多态的,所以编译器无法进行优化),还可能使你的I-Cache异常。

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

然而,在我看来,一切默认为虚函数是一个解决问题的笼统方法,你可以通过其他方式来解决这个问题。

也许你可以看看类的设计/文档/编写方式。通常,类的头文件应该非常清楚地说明哪些函数可以被派生类重载以及它们如何被调用。让程序员编写这些文档有助于确保它们被正确标记为虚函数。

此外,我认为将每个函数都声明为虚函数可能会比只是忘记标记某些函数为虚函数更容易导致错误。如果所有函数都是虚函数,那么基类中的所有内容 - 公共的、保护的、私有的 - 都可以被替换。通过意外或有意修改子类的行为,可能会在基本实现中使用时引起问题。


1
最大的优化缺失在于内联,特别是虚函数经常很小或为空时。 - Zan Lynx
@Andrew:有趣的观点。然而,我在你最后一段话上有些不同意:如果一个基类有一个函数save,它依赖于基类中函数write的特定实现,那么在我看来,要么save编码不良,要么write应该是私有的。 - MiniQuark
2
仅仅因为write是私有的并不意味着它不能被覆盖。这是不默认将事物设置为虚拟的另一个论点。无论如何,我在考虑相反的情况——一个通用且良好编写的实现被具有特定和不兼容行为的东西所替代。 - Andrew Grant
在缓存方面投了赞成票 - 在任何大型面向对象的代码库中,如果你没有遵循代码局部性性能实践,你的虚拟调用很容易导致缓存未命中并引起停顿。 - Not Sure
在Java中,所有的方法都是虚拟的,这就是我得到灵感的地方。但是,它们确实有两种保护措施来防止派生类重新定义基类依赖的方法时可能出现的错误:1)对于方法使用final关键字,2)私有方法自动为final。 - MiniQuark
显示剩余3条评论

12

这取决于情况。 :) (你还有别的期望吗?)

一旦一个类获得虚函数,它就不能再是 POD 数据类型了(它之前可能也不是,此时不会有任何影响),这使得许多优化变得不可能。

对于纯 POD 类型,std::copy() 可以使用简单的 memcpy 程序,但非-POD 类型必须更加小心地处理。

构造变得更慢了,因为必须初始化 vtable。在最坏的情况下,POD 和非-POD 数据类型之间的性能差异可能显著。

在最坏的情况下,您可能会看到 5 倍的执行速度变慢(这个数字来自我最近重新实现几个标准库类的大学项目。我们的容器在存储的数据类型获得 vtable 后,构造时间大约慢了 5 倍)

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

然而,在这里性能不应该是您的主要考虑因素。使一切都成为虚拟的并不是解决方案。

允许派生类中的所有内容被覆盖会使维护类不变量变得更加困难。当其任何一个方法可能随时被重新定义时,该类如何保证保持一致的状态呢?

使所有东西都虚拟化可能消除了一些潜在的错误,但也引入了新的错误。


8

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

然而,如果您不需要它,那么负担这种开销可能有点过头了。大多数类都不是为继承而设计的 - 要创建一个好的基类需要更多的工作,而不仅仅是使其函数成为虚函数。


好的回答,但在第二部分中,我认为不够强调:如果你不需要它,那么让自己承担额外负担是相当疯狂的 - 特别是当使用这种语言的口号是“不要为你不使用的东西付费”时。默认情况下使所有内容虚拟,直到有人证明为什么它可以/应该是非虚拟的是一项可憎的政策。 - underscore_d

7

虚函数调用相比于一些替代方法慢了一个数量级,这并不是因为间接性,而是因为防止内联。下面,我将通过将虚函数调用与将“类型(标识)号”嵌入对象并使用switch语句选择特定于类型的代码的实现进行对比来说明这一点。这可以完全避免函数调用开销-只需进行本地跳转。强制在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倍。当然,这是特定于测试的确切系统/编译器标志和版本等,但通常具有指示性。


关于虚分派的评论

必须说,虚函数调用开销很少有显著影响,只有在经常被调用的琐碎函数(如获取器和设置器)中才会有影响。即使是这种情况,您也可以提供一个单一的函数来一次性获取和设置很多东西,从而最小化成本。人们过于担心虚分派-因此请在寻找棘手的替代方案之前进行性能分析。它们的主要问题是执行了一个不在同一行中的函数调用,尽管它们还将执行的代码去地方化,这会改变缓存利用模式(更好或者(更常见的)更差)。


我针对你的代码发了一个问题,因为我在使用g++/clang-lrt时得到了一些“奇怪”的结果。我认为这值得提及,以便未来的读者参考。 - Holt
@Holt:好问题,考虑到结果有些神秘,我会在接下来的几天里仔细研究一下,如果有机会的话。谢谢。 - Tony Delroy

4
在大多数情况下,额外的成本几乎可以忽略不计(请原谅我打了个双关语)。ejac已经发布了合理的相对措施。
你要放弃的最重要的东西是可能由于内联而进行的优化。如果使用常量参数调用函数,它们可以尤其好。这很少会有实际的影响,但在一些情况下,这可能是巨大的。
关于优化:
了解和考虑您的语言结构的相对成本非常重要。大O符号只是故事的一半 - 您的应用程序如何扩展。另一半是它前面的常数因子。
作为一个经验法则,除非有明确而具体的迹象表明它是瓶颈,否则我不会特意避免虚函数。清晰的设计总是第一位的 - 但它只是一个利益相关者,不应该对其他人造成不必要的伤害。
假设例子:对于一个包含一百万个小元素的数组的空虚拟析构函数,可能会穿过至少4MB的数据,从而破坏您的缓存。如果那个析构函数可以被内联消除,那么数据就不会被触及。
在编写库代码时,这些考虑远非过早。您永远不知道会有多少循环将围绕您的函数。

在提及内联函数时给个赞。我可以想象这样一种情况,在循环中调用非虚函数,可以将其内联,然后例如整个循环向量化。这时候差异可能是显著的。 - PKua

3

虽然其他人正确地谈到了虚方法的性能等问题,但我认为真正的问题在于团队是否了解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 前面添加 virtual 关键字,我们将得到以下输出:

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

几乎是每个人都期望的。

现在,你提到有些bug是因为有人忘记添加virtual关键字。那么考虑以下代码(在A类中添加了virtual关键字,但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中添加了virtual关键字,结果与A::Foo()的结果相同。原因是B::Foo的签名与A::Foo()完全匹配,并且由于A的Foo是虚拟的,所以B的Foo也是虚拟的。

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

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

虚拟关键字在继承层次结构中向下起作用,而不是向上。它从来不会使基类方法成为虚拟的。当多态性开始时,第一次遇到虚拟方法是在继承层次结构中。后面的类没有办法使之前的类有虚拟方法。

不要忘记,虚拟方法意味着该类赋予未来类覆盖/更改其某些行为的能力。

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

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


嗨Tommy,感谢你的教程。我们遇到的错误是由于基类方法中缺少了“virtual”关键字造成的。顺便说一下,我建议将所有函数都设为虚函数(而不是相反),然后在明确不需要时再删除“virtual”关键字。 - MiniQuark
@MiniQuark:Tommy Hui 表示,如果你将所有函数都设置为虚函数,那么程序员可能会在派生类中删除关键字,而没有意识到它没有任何效果。你需要确保在基类中始终删除虚关键字的某种方式。 - sourcenouveau

1
根据您的平台,虚拟调用的开销可能非常不可取。通过声明每个函数为虚拟函数,您实际上是通过函数指针调用它们。至少这是一个额外的解引用,但在某些PPC平台上,它将使用微码或其他缓慢的指令来完成此操作。
出于这个原因,我建议您不要采纳您的建议,但如果它可以帮助您防止错误,则可能值得权衡。不过,我不能不想到一定有一些折中方案值得找到。

0

调用虚方法只需要添加几个额外的汇编指令。

但是我认为你不必担心fun(int a, int b)相比fun()多了几个'push'指令。所以在没有特殊情况并且看到它真的会导致问题之前,不要担心虚函数。

附言:如果你有一个虚方法,请确保你有一个虚析构函数。这样可以避免可能出现的问题。


作为对“xtofl”和“Tom”评论的回应,我进行了三个函数的小测试:
  1. 虚拟
  2. 正常
  3. 带有3个int参数的正常函数
我的测试是一个简单的迭代:
for(int it = 0; it < 100000000; it ++) {
    test.Method();
}

这里是结果:

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

这是在VC++的调试模式下编译的。我每种方法只进行了5次测试,并计算了平均值(因此结果可能相当不准确)...无论如何,假设有1亿个调用,这些值几乎相等。而带有3个额外push/pop的方法则更慢。

主要问题是,如果您不喜欢与push/pop的类比,请考虑代码中的额外if/else?当您添加额外的if/else时,是否考虑过CPU流水线呢?另外,您永远不知道代码将在哪个CPU上运行...通常编译器可以为一个CPU生成更优化的代码,但对于另一个CPU则不太优化(Intel C++ Compiler


2
额外的汇编可能会触发页面错误(这对于非虚拟函数则不会出现)- 我认为你过于简单化了这个问题。 - xtofl
2
+1 给 xtofl 的评论。虚函数引入了间接性,这会引入 "流水线泡沫" 并影响缓存行为。 - Tom
1
在调试模式下计时任何东西都是毫无意义的。MSVC在调试模式下生成非常慢的代码,而循环开销可能会隐藏大部分差异。如果你的目标是高性能,那么你应该考虑尽量减少快速路径中的if/else分支。请参阅http://agner.org/optimize/以了解有关低级x86性能优化的更多信息。(还有一些其他链接在[x86标签wiki](https://stackoverflow.com/tags/x86/info)中) - Peter Cordes
1
@Tom:这里的关键点是非虚函数可以内联,但虚函数不行(除非编译器可以去虚拟化,例如如果您在覆盖中使用了final并且您有一个指向派生类型而不是基类型的指针)。这个测试每次调用相同的虚函数,所以它完美地预测了;没有其他流水线气泡,除了有限的call吞吐量。那个间接的call可能会多出几个uops。分支预测即使对于间接分支也很有效,特别是如果它们总是到同一个目标。 - Peter Cordes
这属于微基准测试的常见陷阱:当分支预测器处于热状态且没有其他操作时,它看起来很快。相对于直接调用,间接调用的错误预测开销更高。(是的,普通的调用指令也需要预测。在解码此块之前,获取阶段必须知道下一个要获取的地址,因此必须根据当前块地址而不是指令地址预测下一个获取块的位置,以及预测此块中的分支指令位置...) - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接