【问题标题】:What is the performance cost of having a virtual method in a C++ class?在 C++ 类中使用虚拟方法的性能成本是多少?
【发布时间】:2010-10-14 15:15:41
【问题描述】:

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

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

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

我问的原因是我遇到了一个错误,该错误恰好是由于程序员忘记定义虚拟方法所致。这不是我第一次看到这种错误。我想:为什么我们在需要时添加 virtual 关键字,而不是在我们绝对确定 不需要 需要 virtual 关键字时删除 ?如果性能成本低,我想我会在我的团队中简单地推荐以下内容:只需在每个类中默认将 every 方法设置为虚拟,包括析构函数,仅在需要时将其删除.你觉得这听起来很疯狂吗?

【问题讨论】:

标签: c++ performance virtual-functions


【解决方案1】:

ran some timings 在 3ghz 有序 PowerPC 处理器上。在该架构上,虚函数调用比直接(非虚)函数调用多花费 7 纳秒。

所以,除非函数类似于普通的 Get()/Set() 访问器,否则真的不值得担心成本,其中除了内联之外的任何东西都是一种浪费。内联到 0.5ns 的函数的 7ns 开销是严重的;一个需要 500ms 执行的函数的 7ns 开销是没有意义的。

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

编辑: Andrew、Not Sure 和其他人也提出了一个很好的观点,即虚函数调用可能会导致指令缓存未命中:如果您跳转到不在缓存中的代码地址,那么整个程序在从主存储器中取出指令时完全停止。这总是一个显着的停顿:在氙气灯上,大约 650 个周期(根据我的测试)。

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

我的时序控制 icache 未命中对执行的影响(故意的,因为我试图单独检查 CPU 管道),所以他们打折了这个成本。

【讨论】:

  • 周期成本大致等于从获取到分支退休结束之间的流水线阶段数。这不是一个微不足道的成本,它可以加起来,但除非你试图编写一个紧凑的高性能循环,否则可能会有更大的性能鱼供你炒。
  • 比什么长 7 纳秒。如果一个正常的调用是 1 纳秒,那么如果一个正常的调用是 70 纳秒,那么它就不是了。
  • 如果你看一下时间,我发现对于一个内联成本为 0.66ns 的函数,直接函数调用的差异开销为 4.8ns,而虚函数为 12.3ns(与内联相比) .你提出了一个很好的观点,如果函数本身花费一毫秒,那么 7 ns 就没有任何意义。
  • 更像是 600 个周期,但这是一个好点。我把它排除在时间之外,因为我只对管道泡沫和序言/结语引起的开销感兴趣。 icache 未命中对于直接函数调用同样容易发生(Xenon 没有 icache 分支预测器)。
  • 次要细节,但关于“但是这不是特定于...的问题”,对于虚拟调度来说有点糟糕,因为有一个 extra 页面(或者两个如果它恰好落在一个页面边界上)必须在缓存中 - 用于类的虚拟调度表。
【解决方案2】:

虚拟分派比某些替代方案慢一个数量级 - 与其说是因为间接,不如说是防止内联。下面,我通过对比虚拟分派与在对象中嵌入“类型(识别)编号”的实现并使用 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)。即使这样,您也可以提供一个函数来一次获取和设置很多东西,从而最大限度地降低成本。人们太担心虚拟调度——所以在找到尴尬的替代方案之前做分析。它们的主要问题是它们执行了离线函数调用,尽管它们也会使执行的代码离域,这会改变缓存使用模式(更好或更经常)。

【讨论】:

  • 我就您的代码询问了question,因为我使用g++ / clang-lrt 得到了一些“奇怪”的结果。我认为值得在这里为未来的读者提及。
  • @Holt:很好的问题,给出了令人费解的结果!如果我有一半的机会,我会在几天内仔细研究一下。干杯。
【解决方案3】:

调用虚方法只需要几条额外的 asm 指令。

但我不认为你担心 fun(int a, int b) 与 fun() 相比有几个额外的“推送”指令。所以也不要担心虚拟,直到你处于特殊情况并看到它确实会导致问题。

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


响应 'xtofl' 和 'Tom' cmets。我用 3 个函数做了小测试:

  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 进行类比,请在您的代码中考虑额外的 if/else?当您添加额外的 if/else 时,您是否考虑 CPU 管道;-) 此外,您永远不知道代码将在哪个 CPU 上运行...通常的编译器可以生成对一个 CPU 更优化而对另一个 CPU 不太优化的代码(@ 987654321@)

【讨论】:

  • 额外的 asm 可能只会触发页面错误(非虚拟函数不会出现这种情况)-我认为您将问题过于简单化了。
  • +1 到 xtofl 的评论。虚函数引入了间接性,这会引入管道“气泡”并影响缓存行为。
  • 在调试模式下计时是没有意义的。 MSVC 在调试模式下编写非常慢的代码,循环开销可能隐藏了大部分差异。如果您的目标是高性能,是的,您应该考虑最小化快速路径中的 if/else 分支。有关低级 x86 性能优化的更多信息,请参阅agner.org/optimize。 (还有x86 tag wiki中的一些其他链接
  • @Tom:这里的关键点是非虚函数可以内联,但虚函数不能(除非编译器可以去虚化,例如,如果您在覆盖中使用了 final 并且您有指向派生类型的指针,而不是基类型)。这个测试每次调用同一个虚函数,所以预测完美;除了有限的call 吞吐量之外,没有管道气泡。间接的call 可能是更多的微指令。分支预测即使对于间接分支也很有效,尤其是当它们总是指向同一个目的地时。
  • 这落入了微基准测试的常见陷阱:当分支预测器很热并且没有其他事情发生时,它看起来很快。间接call 的错误预测开销高于直接call。 (是的,正常的call 指令也需要预测。获取阶段必须在解码该块之前知道要获取的下一个地址,因此它必须根据当前块地址而不是指令地址来预测下一个获取块. 以及预测在这个块中有一个分支指令......)
【解决方案4】:

虽然其他人对虚拟方法的性能等都是正确的,但我认为真正的问题是团队是否知道 C++ 中 virtual 关键字的定义。

考虑这段代码,输出是什么?

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

Answer: 就好像把virtual 关键字加到B 上一样?原因是 B::Foo 的签名与 A::Foo() 完全匹配,并且因为 A 的 Foo 是虚拟的,所以 B 的也是。

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

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

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

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

因此,如果您有删除虚拟关键字的规则,它可能不会达到预期的效果。

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

【讨论】:

  • 嗨,Tommy,感谢您的教程。我们遇到的错误是由于基类的方法中缺少“虚拟”关键字。顺便说一句,我的意思是让 all 函数成为虚拟函数(而不是相反),然后,当显然不需要它时,删除“虚拟”关键字。
  • @MiniQuark:Tommy Hui 的意思是,如果你将所有函数都设为虚拟函数,程序员最终可能会删除派生类中的关键字,而没有意识到它没有任何效果。您需要一些方法来确保删除 virtual 关键字总是发生在基类中。
【解决方案5】:

这取决于。 :)(你还期待别的吗?)

一旦一个类获得了一个虚函数,它就不能再是 POD 数据类型了(它之前可能也不是一个,在这种情况下这不会产生影响),这使得整个范围的优化变得不可能.

普通 POD 类型上的 std::copy() 可以诉诸简单的 memcpy 例程,但非 POD 类型必须更加小心地处理。

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

在最坏的情况下,您可能会看到执行速度减慢 5 倍(该数字取自我最近为重新实现一些标准库类所做的一个大学项目。我们的容器在数据类型化后大约需要 5 倍的时间来构建它存储了一个vtable)

当然,在大多数情况下,您不太可能看到任何可衡量的性能差异,这只是指出在某些边界情况下,成本可能很高。

但是,性能不应该是您在这里的主要考虑因素。 由于其他原因,将所有东西都虚拟化并不是一个完美的解决方案。

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

将一切虚拟化可能会消除一些潜在的错误,但也会引入新的错误。

【讨论】:

    【解决方案6】:

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

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

    不过,在我看来,默认情况下将所有内容都设为虚拟是对您可以通过其他方式解决的问题的一揽子解决方案。

    也许你可以看看类是如何设计/记录/编写的。一般来说,一个类的标题应该非常清楚哪些函数可以被派生类覆盖以及它们是如何被调用的。让程序员编写此文档有助于确保它们被正确标记为虚拟。

    我还要说,将每个函数声明为虚拟可能会导致更多错误,而不仅仅是忘记将某些东西标记为虚拟。如果所有功能都是虚拟的,那么一切都可以被基类取代——公共的、受保护的、私有的——一切都变得公平。子类可能会偶然或有意地改变函数的行为,从而在基础实现中使用时会导致问题。

    【讨论】:

    • 最大损失的优化是内联,尤其是在虚函数通常很小或为空的情况下。
    • @Andrew:有趣的观点。不过,我有点不同意你的最后一段:如果基类有一个函数save,它依赖于基类中函数write 的特定实现,那么在我看来,save 的编码很差, 或 write 应该是私有的。
    • 仅仅因为 write 是私有的并不能阻止它被覆盖。这是默认情况下不使事物虚拟化的另一个论据。无论如何,我的想法正好相反——通用且编写良好的实现被具有特定且不兼容行为的东西所取代。
    • 投票赞成缓存 - 在任何大型面向对象的代码库中,如果您不遵循代码局部性性能实践,您的虚拟调用很容易导致缓存未命中并导致摊位。
    • icache 停顿可能非常严重:我的测试中有 600 个周期。
    【解决方案7】:

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

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


    关于优化:
    了解和考虑语言结构的相对成本很重要。大 O 符号只是故事的一半 - 您的应用程序如何扩展。另一半是它前面的常数因子。

    根据经验,我不会特意避开虚函数,除非有明确而具体的迹象表明它是一个瓶颈。简洁的设计始终是第一位的 - 但只有一个利益相关者不应该过度伤害他人。


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

    在编写库代码时,这样的考虑远非为时过早。你永远不知道你的函数会有多少循环。

    【讨论】:

    • +1 用于提及内联。我可以想象这样的情况,在循环中调用的非虚拟函数可以被内联,然后例如整个循环向量化。那么差异可能很大
    【解决方案8】:

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

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

    【讨论】:

      【解决方案9】:

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

      但是,如果您不需要x,就让自己承担开销,这可能有点过头了。而且大多数类都不是为了继承而设计的——创建一个好的基类需要的不仅仅是使其函数虚拟化。

      【讨论】:

      • 很好的答案,但是,IMO,在下半场不够强调:坦率地说,如果你不需要它,就让自己笨拙地处理开销,特别是在使用这种口头禅是“不要为你不使用的东西付费。”在有人证明它为什么可以/应该是非虚拟的之前,将所有东西默认设置为虚拟是一种可恶的政策。
      猜你喜欢
      • 1970-01-01
      • 2010-09-20
      • 2022-11-19
      • 1970-01-01
      • 1970-01-01
      • 2011-02-12
      • 1970-01-01
      • 2012-01-10
      • 1970-01-01
      相关资源
      最近更新 更多