【问题标题】:Are there any rules of thumb when `virtual` is a considerable overhead?当“虚拟”开销很大时,是否有任何经验法则?
【发布时间】:2013-07-06 02:46:00
【问题描述】:

我的问题基本上都在标题中,但让我详细说明一下。

问题: 也许值得改写一下,virtual 方法必须有多复杂/简单,才能使该机制成为相当大的开销?这有什么经验法则吗?例如。如果需要 10 分钟,使用 I/O、复杂的 if 语句、内存操作等,这不是问题。或者,如果你写virtual get_r() { return sqrt( x*x + y*y); };并循环调用它,你会遇到麻烦。

我希望这个问题不是太笼统,因为我寻求一些笼统但具体的技术答案。要么很难/不可能说出来,要么虚拟调用需要太多时间/周期资源,而数学需要这个,I/O 这个。

也许一些技术人员知道一些一般数字可以比较或进行一些分析,并可以分享一般性结论。尴尬的是我不知道如何让那些花哨的asm分析=/。

我还想给出一些背后的理由,以及我的用例。

我想我看到很多问题,人们为了性能而避免在干旱期间在森林中使用诸如明火之类的虚拟设备,以及许多人问他们“您是否绝对确定虚拟开销确实是一个问题你的情况呢?”。

我相信,在我最近的工作中,我遇到了一个可以放在河流两侧的问题。

还请记住,我不问如何改进接口的实现。我相信我知道该怎么做。我在问是否有可能告诉你什么时候做,或者选择哪一个。

用例:

我运行了一些模拟。我有一个基本上提供运行环境的类。有一个基类和多个定义一些不同工作流的派生类。 Base 收集东西作为通用逻辑并分配 I/O 源和接收器。衍生品或多或少通过实现RunEnv::run() 来定义特定的工作流程。我认为这是一个有效的设计。现在让我们想象作为工作流主题的对象可以放在 2D 或 3D 平面中。工作流在这两种情况下都是通用的/可互换的,因此我们正在处理的对象可以具有通用接口,尽管对于像Object::get_r() 这样的非常简单的方法。最重要的是,让我们为环境定义一些统计记录器。

最初我想提供一些代码 sn-ps,但它最终得到了 5 个类和 2-4 个方法,即code 的墙。我可以根据要求发布它,但它会将问题延长到当前大小的两倍。

关键点是:RunEnv::run()是主循环。通常很长(5mins-5h)。它提供基本的时间检测,调用RunEnv::process_iteration()RunEnv::log_stats()。都是虚拟的。理由是。我可以推导出RunEnv,例如针对不同的停止条件重新设计run()。我可以重新设计process_iteration(),例如,如果我必须处理对象池,则使用多线程,以各种方式处理它们。不同的工作流程也需要记录不同的统计数据。 RunEnv::log_stats() 只是一个调用,将已经计算出的有趣统计数据输出到 std::ostream 中。我使用虚拟并没有真正的影响。

现在假设迭代通过计算对象到原点的距离来工作。所以我们有接口double Obj::get_r();Obj 是 2D 和 3D 案例的实现。在这两种情况下,getter 都是一个简单的数学运算,包含 2-3 次乘法和加法。

我还尝试了不同的内存处理。例如。有时坐标数据存储在私有变量中,有时存储在共享池中,因此即使get_x() 也可以通过实现get_x(){return x;};get_x(){ return pool[my_num*dim+x_offset]; }; 进行虚拟化。想象一下用get_r(){ sqrt(get_x()*get_x() + get_y()*get_y()) ;}; 计算一些东西。我怀疑这里的虚拟性会影响性能。

【问题讨论】:

  • sqrt() 已经足够贵了,您可能感觉不到虚拟通话。
  • 当一个讨厌虚拟调用的人正在审查代码时。
  • @Mysticial 这正是我正在寻找的。我从来没有看到一个答案说 vtable 查找与典型任务(如 sqrt()、O(1) int/float math、short for-loop、简单 I/O 等)相比实际开销有多大。跨度>
  • 既然您追求性能,是否可以不需要平方根?如果您要比较距离,或者将它们放入可以再次平方(即引力/电磁)的方程中,那么您可以完全避免平方根。
  • @Mysticial 我知道。我不想就根本问题及其实施提出问题。我刚刚研究过类似的东西,所以用它来做一个例子更容易。

标签: c++ performance


【解决方案1】:

正如其他人所说,您应该在特定应用程序和环境中分析的绝对最基本的建议是避免virtual 在紧密循环中。

请注意,如果您确实需要多态行为,那么虚成员函数的行为可能会比大多数替代方法更好。当您有一个多态但同质类型的集合时可能是例外(该集合可以是任何多态类型,但它们都是相同的类型,无论它们碰巧是哪种类型)。那么你最好将多态行为移到循环之外。

以使用形状的经典愚蠢 bad-OO 示例为例,您最好:

// "fast" way
struct Shape {
  virtual void DrawAll(Collection) = 0;
};

struct Rectangle : public Shape {
  virtual void DrawAll(Collection collection) {
    for (const auto& rect : collection)
      do_rectangle_draw();
  }
};

struct Circle : public Shape {
  virtual void DrawAll(Collection collection) {
    for (const auto& circle : collection)
      do_circle_draw();
  }
};

比更天真的版本可能是这样的:

// "slow" way
struct Shape {
  virtual void DrawSelf() = 0;

  void DrawAll(Collection collection) {
    for (const auto& shape : collection)
      shape.DrawSelf(); // virtual invocation for every item in the collection!
  }
};

同样,这仅适用于集合中的同类类型。如果您的Collection 可以同时包含Rectangles 和Circles,那么您将需要在迭代期间区分每个实例以使用哪种绘图方法。虚函数可能会比函数指针或 switch 语句更快(但可以肯定的是配置文件)。

上述代码的目标是将多态行为移出循环。这并不总是可能的,但当它是时,它通常相当于某种程度的性能胜利。对于大量对象(例如,粒子模拟器),性能差异可能非常明显。

如果一个函数在循环中没有被调用数千次,您可能不会注意到虚拟函数和非虚拟函数之间有任何可测量的差异。但对其进行分析以进行测试并确定您是否认为这很重要。

【讨论】:

    【解决方案2】:

    首先,当然,任何差异都取决于编译器, 架构等。在某些机器上,两者的区别 虚拟通话和非虚拟通话几乎无法衡量, 至少在另一个上,它会(或会---我的经验 这台机器相当古老)完全清除管道 (间接跳转没有分支预测)。

    在大多数处理器上,虚函数的真正代价是内联能力的损失, 导致其他优化可能性的损失。换句话说,成本 实际上将取决于调用函数的上下文。

    更重要的是:虚函数和非虚函数 函数有不同的语义。所以你不能选择:如果你 需要虚拟语义,就必须使用虚拟;如果你不 需要虚拟语义,不能使用虚拟。所以这个问题 真的不会出现。

    【讨论】:

    • 能否请您详细说明virtual/non-virtual one will hardly be measurable 并提供更多技术细节,并且不要改写第一个答案中已经说过的内容?
    • 我看到了用 ABC 和模板实现的接口,只是为了避免虚拟。我相信你可以设计你的代码来选择是使用还是避免使用虚拟,至少在某些地方是这样。
    • 对于非 x86 这可能是个问题。无论如何,分析都是必需的。
    • 你的意思是什么其他细节?从概念上讲,甚至可能存在没有区别的硬件。在 Sun Sparc 上,根据具体情况,通常会有一个时钟差:小于设置本地堆栈帧所需的时间。考虑到在 Intel 上传递参数和设置本地堆栈帧的成本,我怀疑即使函数为空,您也可以测量差异。
    【解决方案3】:

    在 x86 上 C++ 中的虚方法调用产生类似于(单继承)的代码:

        mov ecx,[esp+4]
        mov eax,[ecx]       // pointer to vtable
        jmp [eax]           
    

    与非虚拟成员函数相比,如果没有虚拟,您将节省一条 mov 指令。因此,在单继承的情况下,性能损失可以忽略不计。

    如果您有多重继承,或者更糟糕的是虚拟继承,则虚拟调用可能要复杂得多。但这更多是类层次结构和架构的问题。

    经验法则:

    如果方法的主体比单个 mov 指令慢很多倍 (>100x) - 只需使用 virtual 即可,不要打扰。否则 - 分析您的瓶颈并进行优化。

    更新:

    对于多重/虚拟继承案例,请查看此页面:http://www.lrdev.com/lr/c/virtual.html

    【讨论】:

    • 好的,请您详细说明一下。假设我为 2/3D 接口创建了 get_r() 虚拟,然后我实现了其他内存布局。所以虚拟get_r() 调用虚拟get_x()。我认为这仍然是至少半现实世界的例子。
    • 如果你有带有get_r() 方法的类层次结构,你最终将使它成为多态的。那么,何苦呢?如果它只是一个类或 get_r() 不是多态的 - 使它成为 inline
    • 你说多重继承会让事情变得复杂。我试图组成一个具有 2 级继承的示例。我还想知道一些数字,例如 cout
    • 在超标量架构上,问题不是mov,而是jmp 到一个非固定地址,如果分支预测器还没有任何线索,这会导致相当大的减速或者如果猜错了。
    • 无论如何,在性能很重要的情况下(通常是内部循环),如果对象的类型不是每时每刻都在改变,那么分支预测器应该快速启动,从而消除分支预测错误的惩罚;此外,编译器通常足够聪明,可以在许多情况下避免虚拟分派,因为它可以在编译时毫无疑问地确定对象的类型(=只有在实际需要virtual 分派时才支付virtual 的惩罚)。
    【解决方案4】:

    这有什么经验法则吗?

    此类问题的最佳、最通用的经验法则是:

    在优化之前测量您的代码

    试图在不进行测量的情况下使您的代码表现良好,这是通往在所有错误地方进行优化的不必要的复杂代码的可靠途径。

    所以,在你有一些确凿的证据证明virtual 是问题所在之前,不要担心虚函数的开销。如果您确实有这样的证据,那么您可以在这种情况下删除virtual。但是,更有可能的是,您会发现找到加快计算速度或避免在不需要的地方进行计算的方法会产生更大的性能改进。但同样,不要只是猜测——先测量。

    【讨论】:

    • 哈哈,我当然知道“在测量之前不要打扰”。问题是我对测量这些间接费用不是很熟悉。当然,这是其他问题的问题。我只是想知道是否有规则可以使决策过程更简单。我想提出更多关于设计的问题,而不是优化已经编写的代码,而且我无法测量我尚未编写的代码!我在询问撰写和衡量的专家是否有一些一般性结论。尽管如此,我理解先写虚拟然后测量方法。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-11-14
    • 2012-03-12
    • 1970-01-01
    • 2013-01-26
    • 2011-03-19
    • 2011-07-30
    相关资源
    最近更新 更多