118

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

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

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

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

4

9 回答 9

118

我在一个 3ghz 有序的 PowerPC 处理器上运行了一些计时。在该架构上,虚拟函数调用的成本比直接(非虚拟)函数调用长 7 纳秒。

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

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

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

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

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

于 2009-03-20T19:43:26.607 回答
20

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

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

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

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

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

于 2009-03-20T19:44:25.293 回答
11

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

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

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

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

在最坏的情况下,您可能会看到 5 倍的执行速度(这个数字取自我最近为重新实现一些标准库类所做的一个大学项目。一旦它存储的数据类型得到一个虚拟表)

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

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

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

将所有东西都虚拟化可能会消除一些潜在的错误,但也会引入新的错误。

于 2009-03-21T01:44:36.817 回答
8

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

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

于 2009-03-20T19:34:22.587 回答
6

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

于 2011-01-26T07:27:31.637 回答
4

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

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


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

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


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

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

于 2009-03-20T20:19:57.597 回答
2

虽然其他人对虚拟方法的性能等都是正确的,但我认为真正的问题是团队是否知道 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()

几乎是每个人所期望的。

现在,您提到存在错误,因为有人忘记添加虚拟关键字。所以考虑这段代码(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;
}

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

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

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

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

不要忘记,虚拟方法意味着这个类正在让未来的类能够覆盖/改变它的一些行为。

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

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

于 2009-03-22T17:08:27.150 回答
1

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

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

于 2009-03-20T19:38:21.847 回答
0

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

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

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


回应“xtofl”和“Tom”的评论。我做了 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 不太优化的代码(英特尔C++ 编译器

于 2009-03-20T19:55:53.707 回答