当前位置:  开发笔记 > 编程语言 > 正文

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

如何解决《在C++类中使用虚方法的性能成本是多少?》经验,为你挑选了5个好方法。

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

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

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

我问的原因是我遇到了一个错误,这个错误恰好是由于程序员忘记定义一个虚拟方法.这不是我第一次看到这种错误.我想:我们为什么要添加虚拟关键字,而不是需要时取出时,我们绝对相信这是它的虚拟关键字没有必要?如果性能成本很低,我想我会在我的团队中推荐以下内容:只需在默认情况下将每个方法设置为虚拟,包括每个类中的析构函数,并且只在需要时将其删除.这对你来说听起来很疯狂吗?



1> Crashworks..:

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

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

虚函数的巨大成本实际上并不是在vtable中查找函数指针(通常只是一个循环),而是间接跳转通常不能进行分支预测.这可能导致大的管道泡沫,因为处理器无法获取任何指令,直到间接跳转(通过函数指针的调用)已经退出并且计算了新的指令指针.因此,虚拟函数调用的成本比查看程序集时看起来要大得多......但仍然只有7纳秒.

编辑:安德鲁,不确定,其他人也提出了一个非常好的观点,即虚函数调用可能导致指令缓存未命中:如果你跳转到不在缓存中的代码地址,那么整个程序就会停止运行指令从主存储器中取出.这总是一个重要的失误:在氙气上,大约650次循环(通过我的测试).

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

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


周期成本大致等于fetch和branch-retire结束之间的管道阶段数.这不是一个微不足道的成本,它可以加起来,但除非你试图写一个紧凑的高性能循环,可能有更大的性能供你炒.
更像600次循环,但这是一个好点.我把它排除在时间之外,因为我对管道泡沫和prolog/epilog的开销很感兴趣.对于直接函数调用,icache miss很容易发生(Xenon没有icache分支预测器).
细微的细节,但是关于"然而这不是特定于...的问题",对于虚拟调度来说,情况会更糟,因为有一个*额外的*页面(或两个,如果碰巧落在页面边界上)必须是在缓存中 - 用于类的虚拟调度表.

2> Andrew Grant..:

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

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

在我看来,默认情况下将所有内容都设置为虚拟可以解决您可以通过其他方式解决的问题.

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

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


仅仅因为写入是私有的并不会阻止它被覆盖.这是默认情况下不使虚拟变为虚拟的另一个参数.在任何情况下,我都在考虑相反的情况 - 一个通用且编写良好的实现被具有特定和不兼容行为的东西所取代.

3> jalf..:

这取决于.:)(你还有什么期待?)

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

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

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

在最糟糕的情况下,你可能会看到执行速度慢5倍(这个数字来自我最近做的一个大学项目,重新实现了一些标准的库类.我们的容器大约需要5倍的时间来构建它存储的数据类型得到了虚函数表)

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

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

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

使一切虚拟可以消除一些潜在的错误,但它也引入了新的.



4> 小智..:

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

但是,如果你不需要它,那么你就会花费很少的开销,这可能会有点太过分了.并且大多数类都不是为了继承而设计的 - 创建一个好的基类需要的不仅仅是使其功能虚拟化.



5> Tony Delroy..:

虚拟调度比一些替代方案慢一个数量级 - 不是因为间接而是防止内联.下面,我通过将虚拟调度与在对象中嵌入"类型(识别)数字"并使用switch语句选择特定于类型的代码的实现进行对比来说明.这完全避免了函数调用开销 - 只是进行局部跳转.通过类型特定功能的强制本地化(在交换机中),可维护性,重新编译依赖性等可能存在成本.


实施

#include 
#include 

// 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 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 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).即便如此,您也许可以提供单一功能来同时获取和设置大量内容,从而最大限度地降低成本.人们担心虚拟调度方式太多 - 所以在找到尴尬的替代方案之前进行分析.它们的主要问题是它们执行一个外联函数调用,尽管它们也会对执行的代码进行非本地化,从而改变缓存利用率模式(更好或更常见).

推荐阅读
Gbom2402851125
这个屌丝很懒,什么也没留下!
DevBox开发工具箱 | 专业的在线开发工具网站    京公网安备 11010802040832号  |  京ICP备19059560号-6
Copyright © 1998 - 2020 DevBox.CN. All Rights Reserved devBox.cn 开发工具箱 版权所有