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

What is the performance cost of having a virtual method in a C++ class?

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

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

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

我之所以问这个问题,是因为我遇到了一个错误,它碰巧是由于一个程序员忘记定义一个虚拟方法。这不是我第一次看到这种错误。我想:为什么我们要在需要时添加虚拟关键字,而不是在绝对确定不需要时删除虚拟关键字?如果性能成本较低,我想我只建议在我的团队中使用以下方法:在默认情况下,只需在每个类中使每个方法都是虚拟的,包括析构函数,并且只在需要时将其移除。你觉得这很疯狂吗?


我在3GHz的PowerPC处理器上运行了一些计时。在该体系结构中,虚拟函数调用比直接(非虚拟)函数调用长7纳秒。

因此,不值得担心成本,除非函数类似于一个普通的get()/set()访问器,其中除inline之外的任何东西都是浪费的。一个函数的7ns开销(内联到0.5ns)很严重;一个函数的7ns开销(需要500ms才能执行)毫无意义。

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

编辑:Andrew,不确定,其他人也提出了一个很好的观点,即虚拟函数调用可能导致指令缓存未命中:如果跳转到不在缓存中的代码地址,那么当从主内存中提取指令时,整个程序将完全停止。这总是一个重要的停顿:氙气,大约650个周期(根据我的测试)。

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

我的计时控制了icache未命中对执行的影响(故意的,因为我试图孤立地检查CPU管道),所以它们会降低成本。


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

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

不过,在我看来,默认情况下一切都是虚拟的,这是一个可以用其他方法解决的问题的综合解决方案。

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

我还想说,将每个函数声明为虚函数可能会导致更多的错误,而不仅仅是忘记将某个函数标记为虚函数。如果所有的功能都是虚拟的,那么所有的东西都可以被基本类(公共的、受保护的、私有的)所取代,所有的东西都变成了公平的游戏。然后,由于意外或意图子类可以改变函数的行为,从而在基本实现中使用时导致问题。


视情况而定。:)(你还期待什么吗?)

一旦一个类得到了一个虚拟函数,它就不能再是pod数据类型了(它可能以前也不是,在这种情况下,这不会有什么区别),这使得整个优化过程都不可能。

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

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

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

当然,在大多数情况下,您不太可能看到任何可测量的性能差异,这只是简单地指出,在某些边界情况下,这可能是昂贵的。

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

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

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


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

然而,如果你不需要x的话,在头顶上笨拙地移动可能有点太远了。而大多数类的设计并不是从继承而来——要创建一个好的基类,需要的不仅仅是使其函数虚拟化。


虚拟调度比某些替代方法慢了一个数量级——这不是由于间接操作,而是由于防止了内联。下面,我将通过将虚拟分派与在对象中嵌入"类型(标识)号"的实现进行对比,并使用switch语句选择特定于类型的代码来说明这一点。这样就完全避免了函数调用开销——只需执行本地跳转。通过强制本地化(在交换机中)类型特定的功能,可能会对可维护性、重新编译依赖项等造成成本。

实施

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#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 << '
'
;

    // ----------------------------

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

    // ----------------------------

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

性能结果

在我的Linux系统上:

1
2
3
4
5
6
7
8
9
10
11
~/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)的函数。即使这样,您也可以提供一个单一的函数来同时获取和设置大量的内容,从而将成本降到最低。人们过于担心虚拟调度方式——在找到笨拙的替代方案之前,先进行分析。它们的主要问题是它们执行了一个离线函数调用,尽管它们也会使执行的代码去域化,从而改变缓存利用模式(更好或(更经常)更糟)。


在大多数情况下,额外的成本实际上是零。(请原谅这个双关语)。EJAC已经发布了合理的相关措施。

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

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

根据经验,我不会为了避免虚拟功能而不择手段,除非有明确而具体的迹象表明它是一个瓶颈。一个干净的设计总是第一位的——但只有一个利益相关者不应该过度伤害其他人。

人为的例子:一百万个小元素数组上的一个空的虚拟析构函数可能会穿透至少4MB的数据,从而破坏您的缓存。如果这个析构函数可以被内联掉,那么数据就不会被访问。

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


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>

class A
{
public:
    void Foo()
    {
        printf("A::Foo()
"
);
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()
"
);
    }
};

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

没什么奇怪的:

1
2
3
A::Foo()
B::Foo()
A::Foo()

因为没有什么是虚拟的。如果在A类和B类中都将虚关键字添加到foo的前面,那么我们将得到输出:

1
2
3
A::Foo()
B::Foo()
B::Foo()

几乎是每个人都期望的。

现在,您提到了存在错误,因为有人忘记添加虚拟关键字。所以考虑一下这段代码(其中虚拟关键字被添加到A类,而不是B类)。那么输出是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>

class A
{
public:
    virtual void Foo()
    {
        printf("A::Foo()
"
);
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()
"
);
    }
};

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的不是虚拟的情况。那么输出是什么呢?在这种情况下,输出为

1
2
3
A::Foo()
B::Foo()
A::Foo()

虚拟关键字在层次结构中向下工作,而不是向上工作。它从不使基类方法成为虚拟的。在层次结构中第一次遇到虚拟方法是在多态性开始时。以后的类无法使以前的类具有虚拟方法。

别忘了,虚拟方法意味着这个类为未来的类提供了覆盖/更改其某些行为的能力。

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

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


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

出于这个原因,我建议不要你的建议,但如果它有助于你防止错误,那么它可能值得权衡。不过,我忍不住想,一定有一些中间地带值得寻找。


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

但我不认为你担心fun(int a,int b)与fun()相比,它有一些额外的"push"指令。所以也不要担心虚拟机,除非您处于特殊情况下并看到它确实会导致问题。

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

回应"xtofl"和"tom"的评论。我做了3个功能的小测试:

  • 事实上的
  • 正常的
  • 带3个int参数的Normal
  • 我的测试是一个简单的迭代:

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

    结果如下:

  • 3913秒
  • 3873秒
  • 3970秒
  • 它是由VC++在调试模式下编译的。我每种方法只做了5次测试,并计算了平均值(所以结果可能非常不准确)。无论如何,假设有1亿次呼叫,这些值几乎相等。加3次推/弹的方法比较慢。

    要点是,如果您不喜欢与push/pop进行类比,可以在代码中考虑额外的if/else吗?当您添加额外的if/else;-)时,您是否考虑了CPU管道?此外,您永远不知道代码将在哪个CPU上运行…通常编译器可以为一个CPU生成更优化的代码,而对于另一个(英特尔C++编译器)则不太理想。