【问题标题】:Using final to reduce virtual method overhead使用 final 减少虚方法开销
【发布时间】:2019-05-05 07:39:27
【问题描述】:

我遇到了这个关于如何使用“final”关键字来减少虚拟方法开销的 SO 问题(Virtual function efficiency and the 'final' keyword)。基于这个答案,期望派生类指针调用标记为 final 的覆盖方法将不会面临动态调度的开销。

为了衡量这种方法的好处,我设置了一些示例类并在 Quick-Bench - Here is the link 上运行它。这里有 3 种情况:
情况 1:没有最终说明符的派生类指针:

Derived* f = new DerivedWithoutFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

案例 2:带有 final 说明符的基类指针:

Base* f = new DerivedWithFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

案例 3:带有 final 说明符的派生类指针:

Derived* f = new DerivedWithFinalSpecifier();
f->run_multiple(100); // calls an overriden method 100 times

这里的函数run_multiple如下所示:

int run_multiple(int times) specifiers {
    int sum = 0;
    for(int i = 0; i < times; i++) {
        sum += run_once();
    }
    return sum;
}

我观察到的结果是:
按速度:案例 2 == 案例 3 > 案例 1

但案例 3 不应该比案例 2 快得多吗?我的实验设计或我对预期结果的假设有问题吗?

编辑: Peter Cordes 指出了一些与此主题相关的非常有用的文章供进一步阅读:
Is final used for optimization in C++?
Why can't gcc devirtualize this function call?
LTO, Devirtualization, and Virtual Tables

【问题讨论】:

  • 运行100 迭代对我来说似乎不是很多。生成随机数的速度也很慢,这很容易掩盖虚拟调度,即使它比静态调度慢,它也应该非常快。
  • Quickbench 多次运行 for 循环 AFAIK。尽管如此,这里有一个可调参数设置为 1000 迭代的 Quickbench 链接。结果还是一样:quick-bench.com/zCgn3B18JlSxBZGisEdsxUNjD7U
  • 我有时会在开始测试之前生成所有随机数,然后从数组中一次取出一个(当我用完时回绕)。
  • @Galik:你想要的词是“包装”。您使用的另一个词具有非常不同的含义(和发音)。
  • :) 很公平 - 让我试试。

标签: c++ oop benchmarking


【解决方案1】:

您正确理解了final 的影响(可能除了案例 2 的内部循环),但您的成本估算还差得很远。我们不应该期望在任何地方产生大的影响,因为 mt19937 只是很慢,所有 3 个版本都花费了大部分时间。


唯一没有丢失/埋在噪音/开销中的是将int run_once() override final 内联到FooPlus::run_multipleinner 循环中的效果,案例 2 和案例 3 都运行.

但案例 1 不能将 Foo::run_once() 内联到 Foo::run_multiple(),因此与其他 2 个案例不同,内部循环内部存在函数调用开销。

案例 2 必须反复调用 run_multiple,但这只是每 100 次 run_once 运行一次,并且没有可衡量的效果。


对于所有 3 种情况,大部分时间都花在了 dist(rng); 上,因为与不内联函数调用的额外开销相比,std::mt19937 相当慢。乱序执行也可能隐藏很多开销。但不是全部,所以还有一些东西需要衡量。

案例 3 能够将所有内容内联到这个 asm 循环(来自您的 quickbench 链接):

 # percentages are *self* time, not including time spent in the PRNG
 # These are from QuickBench's perf report tab,
 #  presumably sample for core clock cycle perf events.
 # Take them with a grain of salt: superscalar + out-of-order exec
 #  makes it hard to blame one instruction for a clock cycle

   VirtualWithFinalCase2(benchmark::State&):   # case 3 from QuickBench link
     ... setup before the loop
     .p2align 3
    .Louter:                # do{
       xor    %ebp,%ebp          # sum = 0
       mov    $0x64,%ebx         # inner = 100
     .p2align 3  #  nopw   0x0(%rax,%rax,1)
     .Linner:                    # do {
51.82% mov    %r13,%rdi
       mov    %r15,%rsi
       mov    %r13,%rdx           # copy args from call-preserved regs
       callq  404d60              # mt PRNG for unsigned long
47.27% add    %eax,%ebp           # sum += run_once()
       add    $0xffffffff,%ebx    # --inner
       jne    .Linner            # }while(inner);
       mov    %ebp,0x4(%rsp)     # store to volatile local:  benchmark::DoNotOptimize(x);
0.91%  add    $0xffffffffffffffff,%r12   # --outer
       jne                    # } while(outer)

案例 2 仍然可以将 run_once 内联到 run_multiple,因为 class FooPlus 使用 int run_once() override final。外循环中存在虚拟调度开销(仅),但每次外循环迭代的这一小额额外成本与内循环的成本相比完全相形见绌(在案例 2 和案例 3 之间相同)。

所以 inner 循环本质上是相同的,间接调用开销仅在外部循环中。这在 Quickbench 上无法测量或至少消失在噪音中也就不足为奇了。


案例 1 不能将 Foo::run_once() 内联到 Foo::run_multiple(),因此那里也存在函数调用开销。 (它是间接函数调用的事实相对较小;在紧密循环中,分支预测将完成近乎完美的工作。)


如果您查看 Quick-Bench 链接上的拆卸,则案例 1 和案例 2 的外环具有相同的 asm。

两者都不能去虚拟化和内联run_multiple。案例 1 因为它是虚拟的非最终的,案例 2 因为它只是基类,而不是带有 final 覆盖的派生类。

        # case 2 and case 1 *outer* loops
      .loop:                 # do {
       mov    (%r15),%rax     # load vtable pointer
       mov    $0x64,%esi      # first C++ arg
       mov    %r15,%rdi       # this pointer = hidden first arg
       callq  *0x8(%rax)      # memory-indirect call through a vtable entry
       mov    %eax,0x4(%rsp)  # store the return value to a `volatile` local
       add    $0xffffffffffffffff,%rbx      
       jne    4049f0 .loop   #  } while(--i != 0);

这可能是一个错过的优化:编译器可以证明Base *f 来自new FooPlus(),因此静态已知为FooPlus 类型operator new 可以被覆盖,但编译器仍然会发出对 FooPlus::FooPlus() 的单独调用(将指向来自 new 的存储的指针传递给它)。所以这似乎只是在案例 2 和案例 1 中没有利用的铿锵声。

【讨论】:

  • "但案例 1 不能将 Foo::run_once() 内联到 Foo::run_multiple() 中,因此与其他 2 个案例不同,内循环内部存在函数调用开销。什么时候内联虚函数调用是可行的?添加“final”限定词是否对此有强烈暗示?
  • @tangy:编译器必须能够证明没有派生类型可以覆盖它。所以要么它必须是final,要么在该调用站点的上下文中,它必须能够证明它有一个实际的FooPlus,并且绝对不是从它派生的可以有自己覆盖的东西。 (或者一些编译器会在他们认为他们知道类型的情况下推测性地去虚拟化,方法是创建一个内联循环,如果虚函数指针与其内联的独立定义匹配,则该循环运行,否则使用另一个正常使用 vtable 的 asm 块。 )
  • @Peter Cordes:" ...编译器可以证明 Base f 来自 ..." ,编译器无法证明 base 来自特定派生* ,因为基类可能会也可能不会在其他 TU 中使用。还是我错了。
  • @engf-010:是的,对于只获取指针的函数就是这种情况,例如 Foo::run_multiple() 中的 this 指针。但是如果你做Foo obj;Foo *ptr = &amp;obj;,编译器100%确定它有一个Foo,而不是从Foo派生的东西。它在这个函数中创建了被指向的对象,所以它知道关于它的一切。去虚拟化它很简单,所以ptr-&gt;run_multiple(100) 应该编译成与obj.run_multiple(100) 相同的代码。
  • 相关:Is final used for optimization in C++?Why can't gcc devirtualize this function call?,其中一位 gcc 开发人员 (Jan Hubicka) 在 LTO, Devirtualization, and Virtual Tables 上发布并回答了一篇博客文章的链接。
猜你喜欢
  • 1970-01-01
  • 2014-04-10
  • 1970-01-01
  • 2022-12-14
  • 2012-02-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多