【问题标题】:Virtual functions and performance - C++虚函数和性能 - C++
【发布时间】:2010-10-01 18:42:06
【问题描述】:

在我的类设计中,我广泛使用抽象类和虚函数。我有一种感觉,虚拟功能会影响性能。这是真的?但我认为这种性能差异并不明显,看起来我正在做过早的优化。对吧?

【问题讨论】:

标签: c++ performance optimization virtual-functions


【解决方案1】:

你的问题让我很好奇,所以我继续在我们使用的 3GHz 有序 PowerPC CPU 上运行了一些时序。我运行的测试是用 get/set 函数制作一个简单的 4d 矢量类

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 的函数和常规函数调用来运行它。结果如下:

  • 内联:8 毫秒(每次调用 0.65 纳秒)
  • 直接:68 毫秒(每次调用 5.53 纳秒)
  • 虚拟:160 毫秒(每次调用 13 纳秒)

因此,在这种情况下(所有内容都放入缓存中),虚函数调用比内联调用慢约 20 倍。但这究竟意味着什么?循环中的每一次行程都会准确地导致3 * 4 * 1024 = 12,288 函数调用(1024 个向量乘以四个分量乘以每次添加的三个调用),因此这些时间代表1000 * 12,288 = 12,288,000 函数调用。虚拟循环比直接循环多花 92 毫秒,因此每次调用的额外开销为每个函数 7 纳秒

由此我得出结论:,虚函数比直接函数慢得多,,除非你打算每秒调用一千万次,没关系。

另请参阅:comparison of the generated assembly.

【讨论】:

  • 但是如果它们被多次调用,它们通常会比只调用一次更便宜。请参阅我无关的博客:phresnel.org/blog,标题为“认为无害的虚拟函数”的帖子,但这当然取决于您的代码路径的复杂性
  • 我的测试测量了一小组重复调用的虚拟函数。您的博客文章假设代码的时间成本可以通过计数操作来衡量,但这并不总是正确的;现代处理器上 vfunc 的主要成本是由分支错误预测引起的管道泡沫。
  • 这将是 gcc LTO(链接时间优化)的一个很好的基准;尝试在启用 lto 的情况下再次编译:gcc.gnu.org/wiki/LinkTimeOptimization 看看 20 倍因子会发生什么
  • 如果一个类有一个虚函数和一个内联函数,非虚函数的性能也会受到影响吗?仅仅是因为类的性质是虚拟的?
  • @thomthom 不,虚拟/非虚拟是每个功能的属性。仅当函数被标记为虚拟或覆盖将其设为虚拟的基类时,才需要通过 vtable 定义函数。您会经常看到具有一组用于公共接口的虚函数的类,然后是大量的内联访问器等等。 (从技术上讲,这是特定于实现的,编译器甚至可以对标记为“内联”的函数使用虚拟指针,但编写这样一个编译器的人会发疯。)
【解决方案2】:

一个好的经验法则是:

在你能证明之前,这不是性能问题。

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

Member Function Pointers and the Fastest Possible C++ Delegates 是一篇讨论虚函数(以及更多内容)的优秀文章。

【讨论】:

  • 纯虚函数呢?它们会以任何方式影响性能吗?只是想知道它们似乎只是为了强制实施。
  • @thomthom:正确,纯虚函数和普通虚函数没有性能差异。
【解决方案3】:

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

【讨论】:

  • 我不确定 iPhone 是否是高性能代码的好例子:youtube.com/watch?v=Pdk2cJpSXLg
  • @Crashworks:iPhone 根本不是代码示例。这是一个硬件示例——特别是慢速硬件,这就是我在这里要强调的一点。如果这些被称为“慢”的语言对于功能不足的硬件来说足够好,那么虚拟功能就不会成为一个大问题。
  • iPhone 运行在 ARM 处理器上。用于 iOS 的 ARM 处理器专为低 MHz 和低功耗使用而设计。 CPU 上没有用于分支预测的芯片,因此没有来自虚拟函数调用的分支预测未命中的性能开销。此外,iOS 硬件的 MHz 足够低,以至于在从 RAM 检索数据时,缓存未命中不会使处理器停顿 300 个时钟周期。高速缓存未命中在较低的 MHz 上不太重要。简而言之,在 iOS 设备上使用虚拟功能没有开销,但这是硬件问题,不适用于桌面 CPU。
  • 作为一个长期接触 C++ 的 Java 程序员,我想补充一点,Java 的 JIT 编译器和运行时优化器能够在运行时编译、预测甚至内联一些预定义数字后的函数的循环。但是我不确定 C++ 在编译和链接时是否具有这样的功能,因为它缺少运行时调用模式。因此,在 C++ 中,我们可能需要更加小心。
  • @AlexSuo 我不确定你的意思?在编译时,C++ 当然不能根据运行时可能发生的情况进行优化,因此预测等必须由 CPU 本身完成……但是好的 C++ 编译器(如果有指示)很早就会竭尽全力优化函数和循环运行时。
【解决方案4】:

在性能非常关键的应用程序(如视频游戏)中,虚拟函数调用可能会太慢。对于现代硬件,最大的性能问题是缓存未命中。如果数据不在缓存中,则可能需要数百个周期才能获得。

当 CPU 获取新函数的第一条指令并且它不在缓存中时,正常的函数调用会产生指令缓存未命中。

虚函数调用首先需要从对象中加载虚表指针。这可能导致数据缓存未命中。然后它从 vtable 加载函数指针,这可能导致另一个数据缓存未命中。然后它调用可能导致指令缓存未命中的函数,就像非虚拟函数一样。

在许多情况下,两个额外的缓存未命中不是问题,但在性能关键代码的紧密循环中,它会显着降低性能。

【讨论】:

  • 是的,但是从紧密循环中重复调用的任何代码(或 vtable)将(当然)很少遭受缓存未命中。此外,vtable 指针通常与被调用方法将访问的对象中的其他数据位于同一缓存行中,因此我们通常只讨论一次额外的缓存未命中。
  • @Qwertie 我认为没有必要这样做。循环体(如果大于 L1 缓存)可能会“退出”vtable 指针、函数指针,随后的迭代将不得不等待 L2 缓存(或更多)每次迭代的访问
【解决方案5】:

来自Agner Fog's "Optimizing Software in C++" manual的第44页:

如果函数调用语句总是调用相同版本的虚函数,调用虚成员函数的时间比调用非虚成员函数多几个时钟周期。如果版本发生变化,那么您将获得 10 - 30 个时钟周期的误预测惩罚。虚函数调用的预测和误判规则与switch语句相同...

【讨论】:

  • 感谢您的参考。 Agner Fog 的优化手册是最佳利用硬件的黄金标准。
  • 根据我的回忆和快速搜索 - stackoverflow.com/questions/17061967/c-switch-and-jump-tables - 我怀疑switch 的情况总是正确。当然,使用完全任意的 case 值。但是,如果所有cases 都是连续的,编译器可能能够将其优化为一个跳转表(啊,这让我想起了 Z80 的美好时光),它应该(为了更好的术语)是恒定的——时间。 我建议尝试用switch 替换vfuncs,这很可笑。 ;)
  • @underscore_d 我认为 vtable 可以优化为跳转表是对的,但是 Agner 关于rules for prediction and misprediction of virtual function calls is the same as for switch statements 的陈述在某种意义上也是正确的,假设 vtable 被实现为 switch-case ,那么有两种可能性:1)如果案例是连续的,它会优化为跳转表(如你所说),2)它不能优化为跳转表,因为案例不连续,所以@ 987654328@ 如愤怒所述。
【解决方案6】:

绝对。当计算机以 100Mhz 运行时,这是一个问题,因为每个方法调用都需要在调用之前对 vtable 进行查找。但是今天.. 在一个 3Ghz CPU 上,它具有比我的第一台计算机更多的内存的一级缓存?一点也不。从主 RAM 分配内存会比所有功能都是虚拟的花费更多时间。

就像过去人们说结构化编程很慢,因为所有代码都被拆分成函数,每个函数都需要堆栈分配和函数调用!

我什至会考虑考虑虚拟函数对性能的影响的唯一一次是,它是否被大量使用并在模板化代码中实例化,最终贯穿所有内容。即便如此,我也不会在这上面花太多力气!

PS 想到了其他“易于使用”的语言——它们所有的方法都是虚拟的,而且它们现在不会爬行。

【讨论】:

  • 好吧,即使在今天,避免函数调用对于高性能应用程序也很重要。不同之处在于,今天的编译器可靠地内联小函数,因此我们不会因编写小函数而遭受速度损失。至于虚函数,智能 CPU 可以对其进行智能分支预测。我认为,旧计算机速度较慢的事实并不是真正的问题——是的,它们要慢得多,但当时我们知道这一点,所以我们给它们的工作量要小得多。在 1992 年,如果我们播放 MP3,我们知道我们可能需要将一半以上的 CPU 用于该任务。
  • mp3 可以追溯到 1995 年。在 92 年,我们几乎没有 386 个,他们不可能播放 mp3,并且 50% 的 cpu 时间假设一个好的多任务操作系统、一个空闲进程和一个抢占式调度程序。这在当时的消费市场上都不存在。从电源打开的那一刻起,它是 100%,故事结束。
【解决方案7】:

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

在内存占用较大的类中,单个 vtable 指针的开销并不大,但 COM 中的一些 ATL 类非常小,如果运行时多态的情况永远不会发生,那么节省 vtable 是值得的发生。

另见this other SO question

顺便说一下,a posting I found 谈到了 CPU 时间性能方面。

【讨论】:

【解决方案8】:

是的,您是对的,如果您对虚函数调用的成本感到好奇,您可能会发现 this post 很有趣。

【讨论】:

  • 链接的文章没有考虑虚拟调用中非常重要的部分,可能是分支预测错误。
【解决方案9】:

我认为虚函数会成为性能问题的唯一方法是,如果许多虚函数在紧密循环中被调用,并且当且仅当它们导致页面错误或发生其他“重”内存操作。

尽管就像其他人所说的那样,在现实生活中这对你来说几乎永远不会成为问题。如果您认为是,请运行分析器,进行一些测试,并验证这是否真的是一个问题,然后再尝试“取消设计”您的代码以获得性能优势。

【讨论】:

  • 在一个紧密的循环中调用任何东西可能会使所有代码和数据在缓存中保持热...
  • 是的,但是如果正确的循环遍历对象列表,那么每个对象都可能通过相同的函数调用在不同的地址调用虚函数。
【解决方案10】:

当类方法不是虚拟的时,编译器通常会内联。相反,当你用指针指向某个类的虚函数时,真正的地址只有在运行时才知道。

测试很好地说明了这一点,时间差 ~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;
}

虚函数调用的影响很大程度上取决于情况。 如果函数内部调用很少且工作量很大 - 它可以忽略不计。

或者,当它是一个多次重复使用的虚拟调用,同时做一些简单的操作时——它可能真的很大。

【讨论】:

【解决方案11】:

在我的特定项目中,我已经为此反复讨论了至少 20 次。尽管在代码重用、清晰度、可维护性和可读性方面有很大的提升,但另一方面,虚拟函数仍然确实存在性能损失。 p>

现代笔记本电脑/台式机/平板电脑上的性能影响是否会很明显……可能不会!但是,在某些嵌入式系统的情况下,性能下降可能是代码效率低下的驱动因素,尤其是在循环中一遍又一遍地调用虚函数时。

这是一篇过时的论文,分析了嵌入式系统环境中 C/C++ 的最佳实践:http://www.open-std.org/jtc1/sc22/wg21/docs/ESC_Boston_01_304_paper.pdf

总结一下:程序员应该了解使用某种构造相对于另一种构造的优缺点。除非你是超级性能驱动者,否则你可能不关心性能损失,应该使用 C++ 中所有简洁的 OO 东西来帮助使你的代码尽可能可用。

【讨论】:

    【解决方案12】:

    根据我的经验,主要相关的是内联函数的能力。如果您的性能/优化需求要求函数需要内联,那么您不能将函数设为虚拟,因为它会阻止这种情况。否则,您可能不会注意到差异。

    【讨论】:

      【解决方案13】:

      需要注意的是:

      boolean contains(A element) {
          for (A current : this)
              if (element.equals(current))
                  return true;
          return false;
      }
      

      可能比这更快:

      boolean contains(A element) {
          for (A current : this)
              if (current.equals(element))
                  return true;
          return false;
      }
      

      这是因为第一种方法只调用一个函数,而第二种方法可能调用许多不同的函数。这适用于任何语言的任何虚函数。

      我说“可能”是因为这取决于编译器、缓存等。

      【讨论】:

        【解决方案14】:

        使用虚函数的性能损失永远不会超过您在设计级别获得的优势。假设调用虚函数的效率比直接调用静态函数的效率低 25%。这是因为通过 VMT 存在一定程度的间接性。然而,与实际执行函数所花费的时间相比,进行调用所花费的时间通常非常小,因此总性能成本将是微不足道的,尤其是在硬件的当前性能下。 此外,编译器有时可以优化并看到不需要虚拟调用并将其编译为静态调用。所以不用担心尽可能多地使用虚函数和抽象类。

        【讨论】:

        • 永远不会,无论目标计算机有多小?
        • 如果您将其表述为The performance penalty of using virtual functions can sometimes be so insignificant that it is completely outweighed by the advantages you get at the design level.,我可能会同意。关键区别在于sometimes,而不是never
        【解决方案15】:

        我一直在问自己这个问题,特别是因为 - 好几年前 - 我也做过这样的测试,比较标准成员方法调用和虚拟方法调用的时间,当时对结果非常生气,有空虚拟呼叫比非虚拟呼叫慢 8 倍。

        今天我不得不决定是否使用虚函数在我的缓冲区类中分配更多内存,在一个对性能非常关键的应用程序中,所以我用谷歌搜索(并找到了你),最后,再次进行了测试.

        // g++ -std=c++0x -o perf perf.cpp -lrt
        #include <typeinfo>    // typeid
        #include <cstdio>      // printf
        #include <cstdlib>     // atoll
        #include <ctime>       // clock_gettime
        
        struct Virtual { virtual int call() { return 42; } }; 
        struct Inline { inline int call() { return 42; } }; 
        struct Normal { int call(); };
        int Normal::call() { return 42; }
        
        template<typename T>
        void test(unsigned long long count) {
            std::printf("Timing function calls of '%s' %llu times ...\n", typeid(T).name(), count);
        
            timespec t0, t1;
            clock_gettime(CLOCK_REALTIME, &t0);
        
            T test;
            while (count--) test.call();
        
            clock_gettime(CLOCK_REALTIME, &t1);
            t1.tv_sec -= t0.tv_sec;
            t1.tv_nsec = t1.tv_nsec > t0.tv_nsec
                ? t1.tv_nsec - t0.tv_nsec
                : 1000000000lu - t0.tv_nsec;
        
            std::printf(" -- result: %d sec %ld nsec\n", t1.tv_sec, t1.tv_nsec);
        }
        
        template<typename T, typename Ua, typename... Un>
        void test(unsigned long long count) {
            test<T>(count);
            test<Ua, Un...>(count);
        }
        
        int main(int argc, const char* argv[]) {
            test<Inline, Normal, Virtual>(argc == 2 ? atoll(argv[1]) : 10000000000llu);
            return 0;
        }
        

        并且真的很惊讶它 - 事实上 - 真的不再重要了。 虽然内联比非虚拟更快是有意义的,而且它们比虚拟更快,但它通常涉及到整个计算机的负载,无论你的缓存是否有必要的数据,虽然你可能能够优化在缓存级别,我认为这应该由编译器开发人员完成,而不是由应用程序开发人员完成。

        【讨论】:

        • 我认为你的编译器很可能会告诉你代码中的虚函数调用只能调用Virtual::call。在这种情况下,它可以内联它。即使您没有要求,也没有什么可以阻止编译器内联 Normal::call。因此,我认为这 3 个操作很可能获得相同的时间,因为编译器正在为它们生成相同的代码。
        猜你喜欢
        • 2011-06-19
        • 1970-01-01
        • 1970-01-01
        • 2019-11-17
        • 2013-06-25
        • 2012-12-20
        • 2015-08-23
        • 1970-01-01
        相关资源
        最近更新 更多