虚函数与性能 - C++

147

在我的课程设计中,我广泛使用抽象类和虚函数。我认为虚函数会影响性能,但是我觉得这种性能差异不太明显,而且似乎我正在进行过早的优化。对吗?


根据我的回答,我建议将此问题关闭为 https://dev59.com/b3VD5IYBdhLWcg3wBm1g 的重复。 - Suma
可能是在C++中使用接口会有性能惩罚吗?的重复问题。 - Bo Persson
2
如果你正在进行高性能计算和数字计算,不要在计算核心中使用任何虚拟化:它肯定会破坏所有性能并防止编译时的优化。对于程序的初始化或结束,这并不重要。当使用接口时,您可以随意使用虚拟化。 - Vincent
尝试这个基准测试。在紧密循环中有10%的差异,在单个调用中有20%的差异。 - puio
15个回答

196

你的问题让我很好奇,所以我测试了我们使用的3GHz顺序PowerPC CPU的一些时间。我运行的测试是创建一个简单的带有get/set函数的四维向量类。

class TestVec 
{
    float x,y,z,w; 
public:
    float GetX() { return x; }
    float SetX(float to) { return x=to; }  // and so on for the other three 
}

然后我设置了三个包含1024个这些向量的数组(足够小以适应L1缓存),并运行了一个循环,将它们相互添加(A.x = B.x + C.x)了1000次。我使用定义为inlinevirtual和常规函数调用的函数进行了测试。以下是结果:

  • inline: 8ms (每次调用0.65纳秒)
  • direct: 68ms (每次调用5.53纳秒)
  • virtual: 160ms (每次调用13纳秒)

因此,在这种情况下(所有数据都适合缓存),虚函数调用比内联调用慢大约20倍。但是这到底意味着什么呢?每次通过循环时,恰好会调用 3 * 4 * 1024 = 12,288 次函数(1024个向量乘以四个分量乘以每个加法的三个调用),因此这些时间代表了 1000 * 12,288 = 12,288,000 次函数调用。虚循环比直接循环多花费了92毫秒,因此每次调用的额外开销是7纳秒

从这个结果可以得出结论:是的,虚函数比直接函数慢得多,但是,除非你计划每秒调用它们一千万次,否则并不重要。

另见:生成的汇编代码对比。



















24
我的测试测量了一组被反复调用的虚函数。你的博客文章假设代码的时间成本可以通过计算操作次数来测量,但这并非总是正确的;在现代处理器上,vfunc 的主要成本是由分支预测错误引起的流水线气泡。 - Crashworks
10
这将成为测试GCC LTO(链接时优化)的绝佳基准;尝试启用LTO重新编译此内容:http://gcc.gnu.org/wiki/LinkTimeOptimization,看看20倍因素会发生什么。 - lurscher
你介意公开你的基准测试代码吗?我相信我们中的一些人会有兴趣在不同的硬件上进行比较。 - chew socks
1
如果一个类有一个虚函数和一个内联函数,那么非虚方法的性能是否也会受到影响?仅仅因为这个类是虚拟的吗? - thomthom
4
不,虚拟/非虚拟是每个函数的属性。只有当一个函数被标记为虚拟函数或者它重写了一个基类中被标记为虚拟函数的函数时,它才需要通过vtable进行定义。通常你会看到一个类有一组用于公共接口的虚拟函数,还有许多内联的存取器等。(严格来说,这是与实现相关的,编译器甚至可能对标记为“内联”的函数使用虚拟指针,但编写这样的编译器的人会疯掉。) - Crashworks
显示剩余2条评论

99

一个好的经验法则是:

除非你能证明它,否则它不是性能问题。

虚函数的使用可能会对性能产生微小影响,但不太可能影响应用程序的整体性能。 寻找性能改进的更好方法在于算法和I/O。

一篇关于虚函数(以及其他内容)的优秀文章是Member Function Pointers and the Fastest Possible C++ Delegates


1
纯虚函数会对性能产生影响吗?我只是好奇,因为它们似乎只是用来强制实现的。 - thomthom
2
@thomthom:没错,纯虚函数和普通虚函数之间没有性能差异。 - Greg Hewgill
当我在使用HPC创建体素模型(用于多种形状)时,我尝试使用虚函数,但最终不得不退而使用函数指针。使用函数指针的版本执行时间为3-5秒,而使用虚函数的版本需要20-40分钟。代码相同,唯一的区别是如何“查找”函数,即虚函数与指针。 - Fernando Barbosa Gomes

46

当Objective-C(在该语言中,所有方法都是虚拟的)是iPhone的主要语言,而可怕的Java是Android的主要语言时,我认为在我们的3 GHz双核塔上使用C++虚函数是相当安全的。


4
我不确定iPhone是高性能代码的好例子:http://www.youtube.com/watch?v=Pdk2cJpSXLg - Crashworks
14
@Crashworks:iPhone根本不是代码的例子,它是硬件的例子——特别是缓慢的硬件,这正是我在这里要表达的观点。如果这些声称“缓慢”的语言足够适用于低功率硬件,虚函数不会成为一个巨大的问题。 - Chuck
57
iPhone使用的是ARM处理器。用于iOS的ARM处理器被设计为低MHz和低功耗。CPU上没有分支预测的硅,因此从虚拟函数调用中错过分支预测不会导致性能开销。此外,iOS硬件的MHz足够低,以至于缓存未命中不会使处理器停顿300个时钟周期,同时从RAM检索数据。在较低的MHz下,缓存未命中不那么重要。简而言之,在iOS设备上使用虚拟函数不会导致性能开销,但这是硬件问题,并不适用于桌面CPU。 - HaltingState
4
作为一名长期使用Java的程序员刚刚接触C++,我想补充一下:Java的JIT编译器和运行时优化器具有在预定循环次数后实时编译、预测甚至内联某些函数的能力。然而,我不确定C++是否具有这样的特性,因为它缺乏运行时调用模式。因此,在C++中我们可能需要更加小心。 - Alex Suo
@AlexSuo,我不确定你的观点是什么?当被编译时,C++当然不能基于运行时可能发生的情况进行优化,因此预测等操作必须由CPU本身完成...但是好的C++编译器(如果得到指示)会在运行之前大力优化函数和循环。 - underscore_d

41
在非常性能关键的应用程序中(比如视频游戏),虚函数调用可能会太慢。在现代硬件上,最大的性能问题是缓存未命中。如果数据不在缓存中,则可能需要数百个周期才能可用。
普通函数调用在 CPU 获取新函数的第一条指令并且该指令不在缓存中时,可以产生指令缓存未命中。
虚函数调用首先需要从对象加载虚表指针。这可能导致数据缓存未命中。然后从虚表加载函数指针,这可能会导致另一个数据缓存未命中。然后调用该函数,就像非虚函数一样,可能会出现指令缓存未命中。
在许多情况下,两个额外的缓存未命中不是问题,但在性能关键代码的紧密循环中,它可能会显著降低性能。

7
没错,但是任何在紧密循环中被反复调用的代码(或虚函数表)当然很少会受到缓存失效的影响。此外,虚函数表指针通常与被调用方法将访问的对象中的其他数据在同一缓存行中,因此通常只会多产生一个缓存失效。 - Qwertie
5
@Qwertie 我不认为那是必然的真相。如果循环体(如果大于L1缓存)可以“退役”vtable指针、函数指针,那么随后的迭代将不得不等待每次迭代访问L2和更高级别缓存。 - Ghita

31

摘自Agner Fog的《C++软件优化》手册第44页:

调用虚成员函数所需的时间比调用非虚成员函数多几个时钟周期,前提是函数调用语句始终调用相同版本的虚函数。如果版本更改,则会产生10到30个时钟周期的错误预测惩罚。对于虚函数调用的预测和错误预测规则与switch语句相同...


感谢这个参考。Agner Fog的优化手册是最好的硬件优化利用标准。 - Arto Bendiken
根据我的回忆和快速搜索 - https://dev59.com/OWQn5IYBdhLWcg3wJ0UO - 我怀疑这对于 switch 来说并不总是正确的。如果 case 值完全是任意的,那么可以肯定。但是如果所有的 case 都是连续的,编译器可能能够将其优化为跳转表(啊,这让我想起了美好的 Z80 时代),这应该是(缺乏更好的术语)常数时间。并不建议尝试用 switch 替换 vfuncs,这是荒谬的。 ;) - underscore_d
@underscore_d 我认为你说得对,vtable可以被优化为跳转表,但是Agner关于“虚函数调用的预测和误判规则与switch语句相同”的说法也是正确的。如果vtable被实现为switch-case,那么有两种可能性:1)如果case是连续的,则会被优化为跳转表(正如你所说),2)如果case不是连续的,则无法被优化为跳转表,因此会像Anger所说的那样“获得10-30个时钟周期的误判惩罚”。 - HCSF

10

没错。在计算机运行速度为100MHz时,每次方法调用都需要在虚函数表中查找才能被调用,这是问题的根源。但是今天...在拥有比我的第一台计算机更多内存的1级缓存下运行3GHz CPU?完全没有问题。从主RAM中分配内存的成本比如果所有函数都是虚函数的成本还要高。

这就像古老的编程时代一样,人们说结构化编程很慢,因为所有代码被分成了函数,每个函数都需要堆栈分配和函数调用!

唯一考虑虚函数性能影响的时候,是当它被大量使用并且被实例化到遍布整个代码中的模板代码中时。即使是这种情况,我也不会花太多精力!

附:想想其他“易于使用”的语言-它们所有的方法都在表层下是虚函数,而它们现在也没有变慢。


6
即使在今天,避免函数调用对于高性能应用程序仍然很重要。不同之处在于,现代编译器可以可靠地内联小函数,因此我们不必因编写小函数而受到速度惩罚。至于虚函数,智能CPU可以对它们进行智能分支预测。旧计算机速度较慢这个事实,并不是真正的问题——是的,它们比现代计算机慢得多,但那时我们知道这一点,所以我们给它们分配了更小的工作量。1992年如果我们播放MP3,我们知道可能需要将超过一半的CPU资源专门分配给该任务。 - Qwertie
6
MP3的起源可以追溯到1995年。而在1992年,我们仅有386处理器,不可能播放MP3音频格式,即使CPU能够播放也需要50%的处理器时间,意味着需要一个较好的多任务操作系统、空闲进程和抢占式调度程序,然而当时消费市场上并不存在这些东西。因此,一旦电源打开,处理器就会100%地运转,没有其他选择。 - v.oddou

8
除了执行时间之外,还有另一个性能指标。Vtable也占用内存空间,在某些情况下可以避免:ATL使用编译时“模拟动态绑定”和模板来实现“静态多态”,这有点难以解释;基本上,您将派生类作为参数传递给基类模板,因此在编译时,基类“知道”每个实例的派生类是什么。虽然不能让您在基类型集合中存储多个不同的派生类(这是运行时多态),但从静态意义上讲,如果您想创建一个与预先存在的模板类X相同的类Y,并具有此类覆盖的挂钩,则只需要覆盖您关心的方法,然后就可以获得类X的基本方法而无需具有vtable。

在具有大型内存占用的类中,单个vtable指针的成本不高,但是COM中的一些ATL类非常小,如果永远不会发生运行时多态情况,则节省vtable的成本是值得的。

另请参阅 这个其他的SO问题

顺便提一下,这里有一个我找到的帖子,讨论了CPU时间性能方面的问题。


1
它被称为参数多态性 - tjysdsg

5

没错,如果你对虚函数调用的成本感到好奇,你可能会觉得这篇文章很有趣。


1
所链接的文章没有考虑到虚拟调用中非常重要的一部分,那就是可能的分支预测错误。 - Suma

4
我认为虚函数只有在紧密循环中调用许多虚函数,并且仅当它们导致页面错误或其他“重型”内存操作时,才会成为性能问题。
虽然像其他人所说,在现实生活中,这几乎永远不会成为您的问题。如果您认为有问题,请运行分析器,进行一些测试,并验证是否真的存在性能问题,然后再尝试优化代码。

2
在紧密循环中调用任何东西可能会使所有代码和数据保持在高速缓存中... - Greg Rogers
3
是的,但如果这个右侧循环正在迭代对象列表,那么每个对象都可能通过相同的函数调用在不同的地址上调用虚函数。 - Dominik Grabiec

3

当类方法不是虚函数时,编译器通常会进行内联处理。相反,当您使用指向某个具有虚函数的类的指针时,真实地址只能在运行时确定。

这可以通过测试很好地说明,时间差约为700%(!):

#include <time.h>

class Direct
{
public:
    int Perform(int &ia) { return ++ia; }
};

class AbstrBase
{
public:
    virtual int Perform(int &ia)=0;
};

class Derived: public AbstrBase
{
public:
    virtual int Perform(int &ia) { return ++ia; }
};


int main(int argc, char* argv[])
{
    Direct *pdir, dir;
    pdir = &dir;

    int ia=0;
    double start = clock();
    while( pdir->Perform(ia) );
    double end = clock();
    printf( "Direct %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    Derived drv;
    AbstrBase *ab = &drv;

    ia=0;
    start = clock();
    while( ab->Perform(ia) );
    end = clock();
    printf( "Virtual: %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    return 0;
}

虚函数调用的影响高度取决于具体情况。如果调用次数较少且函数内部执行了大量工作,那么它的影响可能可以忽略不计。但是,如果在执行某些简单操作时需要重复多次进行虚拟调用,那么影响可能会非常大。

4
++ia 相比,虚函数调用代价昂贵。那又怎样? - Bo Persson
这是一个展示只有10%差异的基准测试。https://quick-bench.com/q/hU7VjdB0IP7rxjYuH46xbocVBxY - puio

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