内联虚拟功能(Clang与GCC)

JCx*_*JCx 4 optimization x86 g++ clang

举个愚蠢的例子:

class Base
{
  public:
    virtual void ant() { i++; };
    virtual void dec() { i--; };
  int i;
};

void function(Base * base) 
{ 
  base->ant(); 
  base->dec(); 
}
Run Code Online (Sandbox Code Playgroud)

我想象这将由编译器实现的方式是两个虚函数调用。Clang就是这样做的(对dec()的调用使用尾部调用):

function(Base*):                      # @function(Base*)
        push    rbx
        mov     rbx, rdi
        mov     rax, qword ptr [rbx]
        call    qword ptr [rax]
        mov     rax, qword ptr [rbx]
        mov     rdi, rbx
        pop     rbx
        jmp     qword ptr [rax + 8]     # TAILCALL
Run Code Online (Sandbox Code Playgroud)

另一方面,GCC在部分内联函数调用方面还有很长的路要走:

Base::ant():
        add     DWORD PTR [rdi+8], 1      # this_2(D)->i,
        ret
Base::dec():
        sub     DWORD PTR [rdi+8], 1      # this_2(D)->i,
        ret
function(Base*):
        push    rbx     #
        mov     rax, QWORD PTR [rdi]      # _3, base_2(D)->_vptr.Base
        mov     rbx, rdi  # base, base
        mov     rdx, QWORD PTR [rax]      # _4, *_3
        cmp     rdx, OFFSET FLAT:Base::ant()      # _4,
        jne     .L4       #,
        mov     rax, QWORD PTR [rax+8]    # _7, MEM[(int (*__vtbl_ptr_type) () *)prephitmp_8 + 8B]
        add     DWORD PTR [rdi+8], 1      # base_2(D)->i,
        cmp     rax, OFFSET FLAT:Base::dec()      # _7,
        jne     .L6       #,
.L12:
        sub     DWORD PTR [rbx+8], 1      # base_2(D)->i,
        pop     rbx       #
        ret
.L4:
        call    rdx     # _4
        mov     rax, QWORD PTR [rbx]      # _3, base_2(D)->_vptr.Base
        mov     rax, QWORD PTR [rax+8]    # _7, MEM[(int (*__vtbl_ptr_type) () *)prephitmp_8 + 8B]
        cmp     rax, OFFSET FLAT:Base::dec()      # _7,
        je      .L12        #,
.L6:
        mov     rdi, rbx  #, base
        pop     rbx       #
        jmp     rax       # _7
Run Code Online (Sandbox Code Playgroud)

这真的有利吗?为什么?它看起来像是相同数量的内存查询,但是在混合中抛出了一个额外的比较。也许我的例子太琐碎了。

有关可以使用的版本,请参见Godbolt

Bee*_*ope 5

正如您已经知道的那样,gcc试图以乐观的方式内联函数。也就是说,在检查函数实际上没有被覆盖之后,内联它们的主体:它通过比较vtable 1中的函数指针的值来做到这一点。这种类型的优化可以称为推测性去虚拟化,并且广泛应用于JIT编译的语言(例如C#和Java)中,同时难度更大,收益更低,并且很少用于编译后的语言(例如C ++)中。在这里,我们使用推测性将其与变体区分开来,在变体中编译器可以证明可以实现非虚拟化。在乐观的情况下,我们希望 它是有效的,并在运行时仔细检查。

这整个主题可以填满一本书(至少要写一两篇论文),因此,如果您想了解有关如何实施各种类型的虚拟化的所有细节,请查看Hanza的7部分关于虚拟化的系列文章。他帮助在gcc 4中实施,很多内容直接涉及到这个问题。在这里,我将重点介绍您的示例的一些细节。

总体而言,这是一个非常有趣的优化,在某些情况下可以带来丰厚的回报。但是,在您的特定情况下,这似乎有点可疑。首先,请注意属于概率优化的一类:其优劣取决于应用程序的运行时行为的优化:尤其是basetype 的参数是否实际覆盖ant()and dec()方法。优化是否有利可图,将取决于实际频率,从“严格来说是个坏主意”转变为“看起来还不错”。

例如,如果应用程序的实际行为是始终base使用默认值ant()dec()显示的方法传递a ,则好处是:

  1. 稍低的延迟代码路径。忽略常规工作(例如从vtable加载函数指针以及实际的增量),该内联方法基本上增加了两对cmp+ jne,但是节省了一个indirect call,一个indirect jmp和两个rets。仅计算可能很容易清洗的指令,但实际上,两cmp/jmp对宏融合的对将非常便宜,并且就增量操作而言也“脱节”,因此它们的等待时间成本可能被完全隐藏了。间接跳转和ret调用不太可能被隐藏:像Haswell这样的现代x86仍然擅长快速执行这些操作,但是存在硬限制,例如每个周期一个分支。

    就是说,在这种情况下,两条路径之间的差异可能很小。这两个RMW incdec操作可能要比跳动花费更长的时间。

  2. 分支预测行为可能会更好,尤其是在此代码很冷时。寒冷时,任何预测变量中都没有关于分支发生的可能性或其目标的信息(对于间接分支而言)。jne内联情况下的两个调用很可能默认默认未接受,并且正确预测2,并且将完全避免间接分支。但是,始终虚拟呼叫方法无法正确预测分支目标,因此您将遭受两个连续的分支错误预测。

另一方面,当ant()dec()方法始终被覆盖时,优化是一种损失。您添加了额外的比较并跳转到执行路径,然后无论如何都必须进行间接调用。而且,相对而言,您的代码大小膨胀得很大(gcc版本为47字节,而clang版本为13字节)。如果您的运行时代码占用量很大,那么确实会导致I $丢失。

Peter提到即使检查失败,至少优化也可以减少后续间接分支的目标数量(减少1)。但是,您仍然需要cmp/jne在分析中包括对该呼叫的错误预测。当您执行此操作时,检查基础优先方法似乎总是束手无策,甚至更糟-至少在预期的错误预测方面。

几个简单的例子:

ant()概率为p(x)和p(y)的两个目标(x,y)。

假设p(x)> p(y)不失一般性1

首先检查:

jne总是预测x总是如此,因此jne未命中:p(y)
间接调用始终预测y,预期未
命中为零预期未命中= p(y)

仅虚拟通话:

BTB预测x
预期未命中:p(y)

因此,这两种情况完全相同,但p(y)预期会丢失。

概率x,y,z的三个目标(我们略过p(x)表示法)案例x> y + z

为简单起见,假设y == z。您可以解决y!= z的情况。它不会改变定性结论。

情况x> y + z

首先检查:

jne预测采取(x)总是这样预期jne未命中:y + z = 2y
间接调用预测y,其中z(== y)预期未
命中== x *(0 + 0)+ y *(1 + 0)+ z *(1 +1)
= y + 2z = 3y

仅虚拟通话:

BTB预测x
未击中= x * 0 + y + z = 2y

因此,在这种情况下,检查先失误的机率仅由虚拟优先控制,错误率高50%(与两个不常见目标的概率成正比)。在边界处,当p(y)== p(z)== 0时,它将联系在一起(但这意味着实际上并没有三个目标),并且当p(x)== p(y)+ p(z ),每次调用预期会造成0.75个错误预测,而仅虚拟调用方法会造成0.5个错误预测。

让我们检查一下x < y + z情况。

首先检查:

jne预测始终不采取(y或z)如此预期的jne未命中:x
间接调用始终预测y,z预期未
命中= x *(1 + 0)+ y *(0 + 0)+ z *(0 + 1 )
= x + z

仅虚拟通话:

BTB预测x
预期未命中:y + z

因此,这里虚拟呼叫再次主导了“检查优先”方法。后者遭受p(x) - p(y)额外的遗漏。最糟糕的情况似乎是何时p(x) == ~0.49...p(y) == p(z) == ~0.25未命中,在这种情况下,虚拟方法每次调用又会遭受〜0.25个额外的未命中。其他边界条件再次是平局p(z) == 0(预期,因为这是两个目标的情况)。

现在,以上所有假设均假设,采用和未采用分支预测错误的成本与分支目标预测错误的成本相等。实际上,在现代x86 CPU上,成本似乎大致相同。每种类型的错误预测都会导致完全重定向,并导致15-20个周期的损失。仍然存在二阶效应-例如,如果跳转地址的计算时间比分支条件花费的时间长,则间接分支的解析可能比条件直接分支的解析晚。这里的情况似乎并非如此,因为分支决策和分支地址都取决于同一件事:ant()vtable中函数的地址。

上面还假设与条件分支相比,间接分支的预测同样好,并且此预测消耗的资源量相同。通常,这是不正确的。处理器通常具有更少的专用于间接BTB条目的资源,甚至在给定相等资源的情况下,要达到给定的预测速率,在IBTB场景中与在有条件分支场景中相比,需要更多的资源,因为IBTB状态(目标)大于单个位。未获取的信息。较小或较旧的CPU可能也没有任何间接分支预测功能,但是即使在现代x86 CPU中,这种差异也是真实的。很难量化,但是在极限情况下,当大量调用此函数时,差异消失了(因为有足够的资源来准确跟踪IBTB调用,至少是最热的调用)。

如果到此为止,总体而言,在这种特定情况下,这似乎是一个有问题的优化方法。潜在的上升空间很小,并且在代码膨胀方面的成本很高。此外,在中间情况下(其中,base.ant()有时等于Base::ant),命题是有问题的,因为增加的误预测吃进内联呼叫的益处。

从表面上看,我倾向于同意-但有几个缓解因素:

首先,gcc实际上在尝试应用此优化时要精打细算。仅当看不到所讨论的方法实际上已重载时,才应用此优化。看一下您的示例的这个小修改。该function是不变的,但我们添加一个(未使用该文件中)的子类Base,其不覆盖的方法。现在,gcc不再进行投机内联。它看到该方法已被覆盖,因此找不到值得的内联。

gcc可以看到的内容的整个概念非常重要。通常,gcc仅查看单个编译单元。这极大地限制了它的可见性。您可以提出一个合理的论点,即一般Base情况下,如果有的话,它会在一个单独的编译单元中被覆盖,因此上述行为(gcc根据是否存在覆盖来决定应用推测性内联)不太有用,因为相同文件的替代很少见。

另一方面,您可能会注意到goldbolt将类定义放入.cpp文件5中。有人将文件包含在另一个编译单元中是非常罕见且不好的做法.cpp。因此,在这种情况下,不能被忽略的猜测Base可能是一个很好的猜测。当然,我不是gcc实际使用该信息的-当实际的编译器看到该文件时,标头文件和.cpp文件之间的区别几乎已经消失了。

无论一类是有可能被覆盖,当你的范围是单个编译单元是远远不如一个技术一个一个哲学问题。这正是LTOand PGO(如Peter提到的)应该解决的问题。在LTO情况下,通过将优化推迟到链接时间,可以检查整个静态可用的6类和方法的过度设置。对于PGO,编译器可以使用有关哪些类实际上在每个调用站点上作为目标出现的信息,并根据观察到的替代进行优化。它甚至可以针对不同的呼叫站点做出不同的决定(假设function()本身可以内联)。PGO可以达到通常由JIT编译的语言保留的去虚拟化的质量。

实际上,这个主题非常重要,因此Jan 在其有关虚拟化的系列文章中提供了完整的条目。特别重要的是,他介绍了编译器可以确保没有子类/方法重写的情况,因此devirtualizaton不再具有推测性:匿名名称空间中的类,本地类以及最终方法和类。

最后的说明是为了捍卫gcc进行投机内联的决定。对于此优化,给出的示例或多或少地位于风险-回报范围的一端。事实证明,所涉及的功能仅需执行一条指令即可(除了要求的指令之外ret,甚至还可以通过尾调用来优化其中的一条指令)。因此,内联的好处非常小,因为内联的好处主要是作为“祖父”优化,它可以使许多其他优化工作,并且通常消除了内联函数的大部分成本。因为函数是如此之小,所以序言和尾数为零,并且由于无法轻松地在ant()和之间进行优化dec(),因为它们是在不同的基本块中调用的,所以内联没有太大帮助。

这是另一个使gcc有更多优化机会的示例:

class Base
{
  public:
    virtual int add5(int x) { return 5 + x; };
};

int function(Base * base, int x) 
{ 
  return base->add5(x) - base->add5(x);
}

int add5_static(int x) {
  return 5 + x;
}

int function2(int x) {
  return add5_static(x) - add5_static(x);
}

int function3(int x) {
  Base b = Base();
  return b.add5(x) - b.add5(x);
}
Run Code Online (Sandbox Code Playgroud)

在这里,您多次调用同一函数。这可以让GCC优化函数指针检查(你只需要一个add5而不是两个对antdec). This could allow gcc to optimize between the two function calls, replacing(5 + X) - (5 + x)的with something as simple as0`。

让我们看看gcc 通过godbolt做了什么!最初看起来不错。调用的内联版本仅需要一个,cmp/jne因为同一函数被调用了两次。这是优化的版本,从函数指针检查开始:

    cmp     rax, OFFSET FLAT:Base::add5(int)  # _4,
    jne     .L3       #,
    add     esi, 5    # _11,
    mov     ebp, esi  # _7, _11
    mov     eax, ebp  # _7, _7
    pop     rbx       #
    sub     eax, esi  # _7, _11
    pop     rbp       #
    pop     r12       #
    ret
Run Code Online (Sandbox Code Playgroud)

这是一个混合袋。跳转后有7条指令,并且有很多冗余。首先,请注意GCC 能够做到随叫随到间优化。特别是,的单个调用add esi, 5显示5 +两个(相同的调用)的公共子表达式部分已针对单个调用进行了优化。然后,您得到eax = ebp = esi。到的分配ebp是多余的-此功能不再使用。然后你得到sub eax,esi。这是完全多余的,因为eax == esi,因此结果始终为零。

甚至pop指令都是多余的。我们只需push将这三个寄存器放在方法的顶部,然后pop在优化功能中关闭它们。在我们调用虚拟方法之前,将这些寄存器推入是合法的,但是所有这些都可以推迟到检查之后。因此,一共有六个push,并pop说明在最优路径是不必要的。

可以这么说,这7条指令的优化实现只是xor eax, eax一条几乎免费的指令(除了避免push未显示的前三个指令,这也可以避免7)。Gcc错过了所有简单的优化,而优化的功能可能要慢一个数量级。现实情况是,即使这些优化是“显而易见的”,但一切都是分阶段进行的。可以删除所有多余动作,减法和推动动作的阶段可能发生在非虚拟化阶段之前。稍后,要删除此冗余为时已晚。

为了说明gcc确实有能力以更好的方式优化它,请看一下功能2:

int add5_static(int x) {
  return 5 + x;
}

int function2(int x) {
  return add5_static(x) - add5_static(x);
}
Run Code Online (Sandbox Code Playgroud)

这与相同function,只是没有可能的虚拟函数调用复杂化。整个功能很简单:

function2(int):
    xor     eax, eax  #
    ret 
Run Code Online (Sandbox Code Playgroud)

所以一切都抵消了,变成了return 0;。编译器可以在经过推测的虚拟化版本的中执行相同的操作add5,但失败了。

奇怪的是,非投机去虚拟化确实很好:

int function3(int x) {
  Base b = Base();
  return b.add5(x) - b.add5(x);
}
Run Code Online (Sandbox Code Playgroud)

简化为完全相同的东西:

function3(int):
    xor     eax, eax  #
    ret
Run Code Online (Sandbox Code Playgroud)

因此,关于投机去虚拟化的某些事情似乎使它不易受到许多优化的影响,否则这些优化将导致代码的极大简化,并带来巨大的成功。即使没有这些版本,优化版本也可能比不进行虚拟化的版本明显更快。


1值得注意的是,与对JIT语言进行虚拟化处理相比,这是比常规检查更为精确的检查。例如,在Java中,检查仅针对对象类型(即vtable指针的值),而不是针对特定的函数实现(即针对vtable中的函数指针的值)。特定于函数的检查成本更高,因为它涉及取消引用vtable指针,但它在更多情况下“有效”:它适用于Base实际上没有覆盖inc()和/或的任何子类dec(),而类类型检查则可以完全失败。

这种差异可能下跌时,应用优化:因为JIT编译的代码知道在基于剖析调用现场最常见的类(ES),甚至是粗糙的(便宜)检查将是有效的,因为它使用了最常见的观察类型。C ++代码没有这个好处(除非使用LTO和PGO),所以更仔细的检查可能会有所回报。

2实际上,实际行为很复杂,并且具体取决于确切的处理器版本。在较旧的处理器中,由于预测机制与分支的IP紧密相关,因此分支预测器实际上更有可能对冷分支使用默认预测器(该预测器对正向分支不采用)。

在较新的预测变量(Haswell和更新的预测变量)中,该机制更加复杂,使用了多个因素的哈希,因此即使在寒冷的情况下,您也可能经常与其他现有状态“冲突”,因此行为是不可预测的更多。我认为您说自己通常不会获得超过50%的远期错误预测率是安全的,但是没有采取分支机构。你可以找到一些调查这个博客和讨论在这里

3好,你抓住了我。它不包括大小写p(x) == p(y),但是您也可以通过这种方式进行操作,并且不会改变任何内容。

4在Godbolt上的二进制搜索以及Hanza的博客都证实了gcc 4.9中已添加了该内容。在此之前的版本,它在相同的编译是为clangicc

5特别是,#pragma message显示所有源文件都被编译到一个名为的文件中example.cpp

6通过静态可我说的是一套可在LTO阶段发生与gcc启用LTO-文件。这不包括至少两个重要的代码类别:(1)静态或动态对象中的代码,这些代码链接到最终的二进制文件,但其LTO信息不可用(几乎所有链接的内容都不在编译过程中) (2)在运行时加载的其他代码(例如通过)dlopen,LTO无法解决。

7彼得·科德斯(Peter Cordes)指出,这种“ push事前准备”的行为-需要保存的所有内容(通过函数的任何可能路径)在输入时立即被推送,这似乎是gcc中的一种模式。对于快速路径非常短的函数,这似乎是一个很大的限制。

  • 非常好的答案,感谢您发布此信息。这使我的答案看起来像是快速/懒惰的帖子:P。 (2认同)