amd64平台上的“条件调用”性能

11

在考虑在代码的关键部分进行条件函数调用时,我发现gcc和clang都会绕过该调用。例如,对于以下(虽然是琐碎的)代码:

int32_t __attribute__((noinline)) negate(int32_t num) {
    return -num;
}

int32_t f(int32_t num) {
    int32_t x = num < 0 ? negate(num) : num;
    return 2*x + 1;
}

无论是GCC还是clang,它们编译的本质相同:

.global _f
_f:
    cmp     edi, 0
    jg      after_call
    call    _negate
after_call:
    lea     rax, [rax*2+1]
    ret

这让我想到了:如果x86有一个像ARM一样的条件调用指令会怎样呢?想象一下,如果有这样的指令"ccallcc",其语义与cmovcc类似。那么你可以这样做:

.global _f
_f:
    cmp     edi, 0
    ccalll  _negate
    lea     rax, [rax*2+1]
    ret

虽然我们无法避免分支预测,但我们可以消除一个分支。实际上,在GCC/clang的输出中,我们不得不无论num < 0是否成立都必须执行分支。而如果num < 0,我们必须执行两次分支。这似乎是浪费。

现在,在amd64中不存在这样的指令,但我想出了一种模拟这样的指令的方法。我将call func分解为其组成部分:push rip(技术上是[rip+label_after_call_instruction]),然后jmp func。我们可以使jmp条件化,但没有条件化的push。我们可以通过计算[rip+label_after_call_instruction]并将其写入堆栈上的适当位置来模拟此操作,然后在打算调用函数时有条件地更新rsp(这实际上是“推送”[rip+label_after_call_instruction])。大致如下:

.global _f
_f:
    cmp     edi, 0

    # ccalll _negate
    lea     rax, [rip+after_ccall]  # Compute return address
    mov     [rsp-8], rax            # Prepare to "push" return address
    lea     rax, [rsp-8]            # Compute rsp (after push)
    cmovl   rsp, rax                # Conditionally push (by actually changing rsp)
    jl      _negate                 # "Conditional call"
after_ccall:

    lea     rax, [rax*2+1]
    ret

这种方法存在一些潜在的缺点:

  • 引入了几个指令(但它们的总周期数少于分支预测错误的惩罚)
  • 需要对内存进行写操作(但堆栈可能已被缓存?)
  • 即使没有进行调用,也总是执行2个leamov指令(但我的理解是这不重要,因为例如cmovcc与mov具有相同的周期数)

为了检查每种方法的性能,我通过iaca运行了关键部分。如果您已经安装了iaca(并且克隆了下面的基准测试gist),您可以运行make iaca自行查看。传递IACAFLAGS='-arch=...'以指定不同的架构。

使用分支跳转的方法的输出结果:

Intel(R) Architecture Code Analyzer Version -  v3.0-28-g1ba2cbb build date: 2017-10-30;16:57:45
Analyzed File -  ./branch_over_call_iaca.o
Binary Format - 64Bit
Architecture  -  SKL
Analysis Type - Throughput

Throughput Analysis Report
--------------------------
Block Throughput: 0.82 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  36
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  0.5     0.0  |  0.0  |  0.3     0.0  |  0.3     0.0  |  1.0  |  0.0  |  0.5  |  0.3  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1      | 0.5         |      |             |             |      |      | 0.5  |      | jnle 0x6
|   4^#    |             |      | 0.3         | 0.3         | 1.0  |      |      | 0.3  | call 0x5
Total Num Of Uops: 5

使用条件调用方法的输出结果:

Intel(R) Architecture Code Analyzer Version -  v3.0-28-g1ba2cbb build date: 2017-10-30;16:57:45
Analyzed File -  ./conditional_call_iaca.o
Binary Format - 64Bit
Architecture  -  SKL
Analysis Type - Throughput

Throughput Analysis Report
--------------------------
Block Throughput: 1.94 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  35
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.0     0.0  |  1.0  |  0.5     0.0  |  0.5     0.0  |  1.0  |  1.0  |  1.0  |  0.0  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1      |             | 1.0  |             |             |      |      |      |      | lea rax, ptr [rip]
|   2^     |             |      | 0.5         | 0.5         | 1.0  |      |      |      | mov qword ptr [rsp-0x8], rax
|   1      |             |      |             |             |      | 1.0  |      |      | lea rax, ptr [rsp-0x8]
|   1      | 1.0         |      |             |             |      |      |      |      | cmovl rsp, rax
|   1      |             |      |             |             |      |      | 1.0  |      | jl 0x6
Total Num Of Uops: 6

看起来条件调用方法似乎会使用更多的硬件资源。但我发现有趣的是,条件方法只多使用了1个微操作(跳转方式则使用了5个微操作)。我猜这是有道理的,因为在底层,调用将变成一个push和jmp(push将变成rsp数学运算和内存mov)。这也意味着条件调用方法大致相当(尽管也许我的简单分析有缺陷?)。

至少,我总体怀疑的是引入了几个指令,在cmpjl之间,我可以使得cmp的结果在jl可以被猜测执行之前可用(从而完全防止分支预测)。虽然管线长度可能比这更长?这涉及到我并不非常熟悉的领域,尽管我已经阅读并保留了Agner Fog的优化手册的中度深入了解。

我的假设是,对于负数和正数均匀分布的num(其中分支预测无法预测call周围的分支),我的“条件调用”方法将优于绕过调用的分支。

我编写了一个测试工具来对这两种方法的性能进行基准测试。你可以git clone https://gist.github.com/baileyparker/8a13c22d0e26396921f501fe87f166a9并运行make在你的机器上运行基准测试。

以下是在包含1,048,576个数字(均匀分布在int32_t最小值和最大值之间的数组)的情况下,每种方法100次迭代的运行时间。

|                    CPU                    | Conditional Call | Branch Over |
|-------------------------------------------|-----------------:|------------:|
| Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz |       10.9872 ms |   8.4602 ms |
| Intel(R) Xeon(R) CPU E3-1240 v6 @ 3.70GHz |        8.8132 ms |   7.0704 ms |

这些结果在多次运行时保持一致,尽管随着数组大小(或迭代次数)的增加而被放大,但总的来说分支始终优于条件调用。

我还尝试重新排列条件调用步骤(首先计算和有条件更新rsp,然后写入堆栈),但效果相似。

是哪些硬件细节我忽略了(或者误解了),这可以解释这一点?从我的计算结果来看,额外的指令增加了大约6-7个周期的时间,但是分支错误的代价是15个周期。因此,平均每半个数字被预测错误,因此每次迭代耗费15/2个周期(对于分支优于条件调用的方法),而条件调用总是需要6-7个周期。 iaca中的uops表明这两种方法在这方面甚至更接近。那么,性能不应该更接近吗?我的示例代码是否过于牵强/简短?我的基准测试技术是否适用于这种低级别临界区测试?是否有一种重新排序/更改条件调用的方法,使其更具性能(可以比较或与分支优于方法相媲美)?

tl;dr为什么我的条件调用代码(第4个代码片段)在我的基准测试中的表现不如gcc / clang生成的(跳转到call上方的条件跳转)(第2个代码片段)(对于第1个代码片段中的代码)?


4
通过使用“push”和“jump”进行函数调用,您不会在返回预测器栈中留下记录,从而破坏了返回预测。这会导致从您有条件地调用的函数返回以及所有后续返回时出现巨大的延迟峰值。分支预测器工作得很好,额外的跳转成本比您调用的函数成本低,因此我不太明白您尝试做什么的意义所在。 - fuz
3
阅读此文了解回报预测的一些信息。文章链接:http://blog.stuffedcow.net/2018/04/ras-microbenchmarks/。 - fuz
1
@fuz 哇,那几乎肯定就是了。从链接中表1的数字可以看出确切的情况。粗略计算,在3.1 GHz下进行1,048,576次调用所需的23个周期(对于call + retjmp + ret)为+7.7ms。显然这比观察到的要多得多,但也许分支预测器会变得更好,因为返回总是到同一位置。 - Bailey Parker
好酷!编写一篇详细的回答以阐明您的发现,这样您就可以获得所有赞了。 - fuz
我正在尝试编译你的代码,但是使用g++ 5.4和g++ 7.3都无法成功构建。使用g++ 5.4时,我认为它失败是因为它不支持模板参数检测,而这是random_nums中的表达式uniform_int_distribution所需的。使用g++ 7.3时,错误信息在benchmark.cpp文件中的TEST_CASE处报告为expected constructor, destructor, or type conversion before ( token - Hadi Brais
显示剩余4条评论
2个回答

4
你可以精确地确定为什么`conditional_call`方法比`branch_over_call`方法慢。你在两个KBL处理器上做了实验,但是你所参考的博客文章没有讨论在KBL上RAS的工作方式。因此,分析的第一步是确定`negate`函数中的`ret`是否被错误地预测了(就像在早期的微架构中会发生的那样)。第二步是确定错误预测该`ret`指令对总执行时间的代价是多少。我拥有与KBL最接近的CFL,我的数字结果与你的数字结果接近。两者之间唯一相关的区别是CFL启用了LSD,而KBL则禁用了LSD。然而,在这种情况下,由于循环中的`call`指令阻止了LSD检测到任何循环,因此LSD是无关紧要的。你也可以轻松地在KBL上重复相同的分析。
有几种方法可以分析分支指令的行为。但在这种特定情况下,代码足够简单,使得事件计数方法可以揭示我们需要了解每个静态分支指令的所有信息。 BR_INST_RETIRED_*性能事件可用于计算已退役的动态分支指令总数以及特定类型的已退役分支指令总数,包括条件分支、调用和返回。 BR_MISP_RETIRED_*事件可用于计算总误判数、总条件误判数和总调用误判数。 conditional_call的完整控制流图如下:
           total   misp
call         1      0
    jl       1     0.5
       ret  0.5     1
    ret      1      0
jne          1      0

第一个call指令调用conditional_call函数,其中包含jlretjl指令有条件地跳转到negate函数,其中包含retjne指令用于循环。第一列和第二列显示的数字分别通过迭代次数和动态指令总数进行了归一化。我们从程序的静态结构中知道,在每次迭代中,calljlconditional_callretjne都会被执行一次。只有在执行jl分支时才会执行最内层的ret。使用性能事件,我们可以计算执行的返回指令总数,并从中减去迭代总数,以获取执行最内层ret的次数。由于输入是根据均匀分布随机生成的,因此不足为奇的是最内层的ret将被执行一半的时间。 call指令从未被错误预测过。除了最后一次执行指令(退出循环)时,jne指令也从未被错误预测过。因此,我们可以将条件错误预测的总数归因于jl指令。这可以从总错误预测数中减去,以得到可以归因于任一或两个返回指令的返回错误预测数。当第一个ret的错误预测破坏或错位RAS时,第二个ret可能会错误预测。确定第二个ret是否被错误预测的一种方法是使用精确采样BR_MISP_RETIRED.ALL_BRANCHES。另一种方法是使用您引用的博客文章中描述的方法。实际上,只有最内层的ret被错误预测。jl被错误预测一半的时间表明该指令被预测为始终被接受或始终不被接受。 branch_over_call的完整控制流程图如下:
           total   misp
call         1      0
    jg       1     0.5
    call    0.5     0
        ret 0.5     0
    ret      1      0
jne          1      0

唯一一个被错误预测的指令是 jg,其中一半的时间被错误预测。
为了衡量 conditional_call 方法中单个 ret 错误预测的平均代价,可以将 ret 指令替换为 lea/jmp 序列,以便使用 BTB 而不是 RAS 进行预测。通过这种改变,唯一一个被错误预测的指令是 jl。执行时间的差异可以被认为是 ret 错误预测的总代价的估计值。在我的 CFL 处理器上,每个 ret 错误预测的代价约为 11.3 个时钟周期。此外,conditional_callbranch_over_call 快约 3%。你在 KBL 上得出的数据表明,ret 错误预测的平均代价约为 13 个时钟周期。我不确定这种差异的原因是什么。可能不是微架构的问题。我使用的是 gcc 7.3,但你使用的是 gcc 8,所以也许代码或不同代码片段的对齐方式存在差异,从而导致我们的结果不同。

这是一份很棒的分析!非常感谢!我会再读一遍,研究我不熟悉的东西(比如LSD)。只是要明确一下,条件语句在CFL上比较快3%是通过用lea+jmp替换negate中的ret实现的,对吧?我想那可能不够显著,但绝对有趣。你提到的gcc版本和框架的观点已经被注意到了。我有些懒,从我的以前的一个项目中复制并粘贴了一些高级基准测试代码。我应该直接用汇编写出来。 - Bailey Parker
@BaileyParker 是的,没错。请注意,加速比取决于周围的代码,可能会更高或更低。 - Hadi Brais
1
@BaileyParker 你可以在这里和这里以及这里了解更多关于LSD的信息。请注意,LSD也被称为回路缓冲区。 - Hadi Brais

2
正如@fuz在评论中指出的那样,性能问题几乎肯定是由返回地址栈(RAS)引起的,这是一个专门用于函数返回的分支预测器。
作为拥有单独的callret指令以及手动堆栈修改的优势,CPU能够了解正在运行的代码的意图。特别是,当我们调用一个函数时,它可能会返回并且当它返回时,我们将跳回到call之前推送的rip。换句话说,call通常与ret成对出现。CPU通过保持一个仅包含返回地址的固定长度栈(称为返回地址栈RAS)来利用这一点。除了将返回地址推送到实际内存堆栈中,call指令还会将其推送到RAS中。这样,当遇到ret时,CPU可以从RAS中弹出(比实际堆栈的内存访问快得多)并推测执行返回操作。如果弹出的地址正好是从堆栈中弹出的地址,CPU将继续执行没有任何惩罚。但是,如果RAS预测的返回地址错误,则会发生流水线刷新,这是代价高昂的。
我的最初直觉是,条件指令会更好,因为它们会在跳转之前给结果比较留出时间。然而,无论这可能带来了什么好处,有一个不平衡的jmp/ret(我的条件调用用jmp替换了call,但被调用的函数仍然使用ret)导致 RAS 很可能总是预测错误的返回地址(因此,尽管我最初试图避免这种情况,但我的方法会导致更多的流水线停顿)。RAS 的加速比我的“优化”更重要,因此分支方法优于条件调用方法。
根据一些实证结果,不匹配的callret(特别是使用jmp+ret)比正确配对的callret需要5-6倍的周期。一些草稿估算表明,在3.1GHz下,1048576个调用的+21个周期的惩罚会增加约7.1毫秒的总运行时间。观察到的减速比这还要小。这很可能是条件指令延迟跳转直到条件准备好以及跳转在内存中固定位置之间振荡(其他分支预测器可能已经擅长预测)的组合结果。

1
有趣的事实:在适当的情况下,您可以安全地执行条件尾调用,例如 jg _negate。 (在x86-64上,rel32跳转范围与直接近距离call rel32相同,并且可以在32位模式下覆盖整个地址空间)。当前的C编译器无法进行此优化(例如https://gcc.gnu.org/bugzilla/show_bug.cgi?id=69576),但在适当时应该自己执行它(而不是通过条件跳过`jmp`尾调用)。 - Peter Cordes
有趣!当我注意到gcc和clang都会将无条件尾调用优化为“jmp”,但对于有条件的情况则不会时,这将是我的后续问题之一。我引入了“lea rax,[rax*2+1]”来使基准测试不可能实现这一点。 - Bailey Parker
1
@BaileyParker - 你可以尝试通过在函数末尾将ret指令替换为跳转到在调用方填充的寄存器的jmp来修复你的方法。也就是说,完全放弃堆栈,并在寄存器中传递返回地址。当然,这会阻止您调用任何正常函数,因为这实际上是一种自定义调用约定,但值得看看这个版本是否能击败call/ret(我怀疑它可以,在某些基准测试中)。 - BeeOnRope

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接