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

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

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

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

我问这个问题的原因是,我碰巧遇到了一个 bug,这个 bug 是由于一个程序员忘记定义一个虚方法造成的。我不是第一次看到这种错误了。我想: 为什么我们 的虚拟关键字需要时,而不是 removing的虚拟关键字,当我们绝对确信它是 没有的需要?如果性能成本较低,我想我会在我的团队中简单地推荐以下方法: 在每个类中默认使 每个方法为虚方法,包括析构函数,只有在需要时才删除它。你觉得这听起来很疯狂吗?

41268 次浏览

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

然而,如果你不需要的话,让自己负担开销可能有点太过了。而且大多数类并不是设计来继承的——要创建一个好的基类,需要的不仅仅是使其函数虚拟化。

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

出于这个原因,我建议不要采纳你的建议,但是如果它有助于防止 bug,那么这个交易可能是值得的。尽管如此,我还是忍不住想,一定有一些中间立场值得我们去寻找。

I 计算了一下时间 on a 3ghz in-order PowerPC processor. On that architecture, a virtual function call costs 7 nanoseconds longer than a direct (non-virtual) function call.

因此,除非函数是类似于微不足道的 Get ()/Set ()访问器的东西,否则真的没有必要担心成本,因为在这个函数中,除了内联以外的任何东西都是有点浪费的。一个内联到0.5 ns 的函数的7ns 开销是严重的; 一个需要500ms 执行的函数的7ns 开销是没有意义的。

虚函数的巨大成本并不是在 vtable 中查找一个函数指针(通常只是一个循环) ,而是间接跳转通常不能被分支预测。由于处理器在间接跳转(通过函数指针的调用)退出并计算出一个新的指令指针之前不能获取任何指令,这可能会导致大量的管道气泡。因此,虚函数调用的成本比看上去要大得多,但仍然只有7纳秒。

编辑: 安德鲁,不确定,和其他人也提出了一个非常好的观点,即虚函数调用可能导致指令缓存错过: 如果你跳转到一个不在缓存中的代码地址,那么整个程序进入死胡同,而指令是从主存取。这是 一直都是一个重要的失速: 在氙气上,大约650个循环(根据我的测试)。

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

我的计时控制了 icache 错误对执行的影响(这是故意的,因为我试图单独检查 CPU 管道) ,所以它们降低了成本。

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

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

然而,在我看来,默认情况下所有东西都是虚拟的,对于一个可以用其他方法解决的问题来说,这是一个全面的解决方案。

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

我还想说的是,将每个函数都声明为虚函数可能会导致更多的 bug,而不仅仅是忘记将某些东西标记为虚函数。如果所有的函数都是虚拟的,那么所有的东西都可以被基类替换——公共的、受保护的、私有的——所有的东西都变成了公平的游戏。然后,偶然地或意向性的子类可以更改函数的行为,这些行为在基实现中使用时会导致问题。

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

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

另外,如果你有一个虚方法,确保你有一个虚析构函数,这样你就可以避免可能的问题


为了回应“ xtofl”和“ Tom”的评论,我做了3个小测试:

  1. 虚拟的
  2. Normal
  3. Normal with 3 int parameters

My test was a simple iteration:

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

结果如下:

  1. 3913秒
  2. 3873秒
  3. 3970秒

在调试模式下用 VC + + 编译。每个方法我只做了5次测试,并计算了平均值(因此结果可能非常不准确) ... ... 不管怎样,假设有1亿个调用,这些值几乎是相等的。而使用3次额外推送/弹出的方法则要慢一些。

主要的一点是,如果您不喜欢将其类比为 push/pop,那么可以在代码中考虑额外的 if/else?当你添加额外的 if/else 时,你会考虑 CPU 管道吗? 还有,你永远不会知道代码将运行在哪个 CPU 上... ... 通常的编译器可以为一个 CPU 生成更优的代码,而为另一个 CPU 生成的代码则不那么优(Intel C++ Compiler)

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

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


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

根据经验,我不会特意去避免虚拟函数,除非有明确和具体的迹象表明它是一个瓶颈。一个干净的设计总是第一位的——但是只有一个利益相关者不应该伤害其他人。


人为的例子: 在一个包含100万个小元素的数组中,一个空的虚拟析构函数可能会占用至少4MB 的数据,从而损坏您的缓存。如果析构函数可以内联,那么数据就不会被触及。

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

这要看情况。 :)(你还期望别的什么吗?)

一旦一个类获得了一个虚函数,它就不能再是一个 POD 数据类型,(它以前可能也不是一个,在这种情况下,这不会有什么不同) ,这使得一系列的优化变得不可能。

在普通 POD 类型上使用 std: : copy ()可以使用一个简单的 memcpy 例程,但是必须更仔细地处理非 POD 类型。

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

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

Of course, in most cases, you're unlikely to see any measurable performance difference, this is simply to point out that in 一些 border cases, it can be costly.

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

Allowing everything to be overridden in derived classes makes it much harder to maintain class invariants. How does a class guarantee that it stays in a consistent state when any one of its methods could be redefined at any time?

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

虽然其他人对虚拟方法的性能之类的看法都是正确的,但我认为真正的问题在于团队是否知道 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 类中将 Virtual 关键字添加到 Foo 的前面,我们将得到以下输出:

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

和大家想的差不多。

现在,您提到有 bug,因为有人忘记添加虚拟关键字。因此,考虑这段代码(其中虚拟关键字被添加到 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;
}

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

现在考虑 B’s Foo 是虚拟的而 A’s 不是虚拟的情况。那么输出是什么?在本例中,输出为

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

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

不要忘记,虚方法意味着这个类赋予未来的类重写/更改其某些行为的能力。

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

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

虚拟分派比一些替代数量级要慢一些——与其说是由于间接,不如说是由于防止内联。下面,我将通过比较虚拟分派与在对象中嵌入“ type (- Identiding) number”的实现并使用 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';
}
}

表现结果

On my Linux system:

~/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)。即使这样,您也可以提供一个单独的函数来同时获取和设置许多内容,从而将成本降至最低。人们过于担心虚拟调度——在找到令人尴尬的替代方案之前进行分析也是如此。它们的主要问题在于它们执行了一个离线函数调用,尽管它们也对执行的代码进行了去局部化,从而改变了缓存使用模式(更好或(更经常)更差)。