"conditional call" 在 amd64 上的性能

Performance of "conditional call" on amd64

在代码的关键部分考虑条件函数调用时,我发现 gcc 和 clang 都会围绕调用进行 b运行ch。例如,对于以下(公认的微不足道的)代码:

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

虽然我们无法避免 b运行ch 预测,但我们确实消除了 b运行ch。也就是说,在实际的 GCC/clang 输出中,我们被迫 b运行ch 而不管 num < 0 与否。如果 num < 0 我们必须 b运行ch 两次。这看起来很浪费。

现在amd64中不存在这样的指令,但是我设计了一种方法来模拟这样的指令。我通过将 call func 分解成它的组成部分来做到这一点:push rip(从技术上讲 [rip+label_after_call_instruction])然后是 jmp func。我们可以使 jmp 成为条件,但没有条件 push。我们可以通过计算 [rip+label_after_call_instruction] 并将其写入堆栈中的适当位置来模拟这一点,然后如果我们计划调用该函数(实际上 "pushes" [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

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

为了检查这些方法中的每一种方法的属性,我 运行 到 iaca 的关键部分。如果你安装了它(并且你在下面克隆了我的基准测试要点),你可以 运行 make iaca 亲自看看。传递 IACAFLAGS='-arch=...' 以指定不同的架构。

b运行ch 方法的输出:

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 个微指令(b运行ch 方法有 5 个微指令)。我想这是有道理的,因为在后台调用变成了 push 和 jmp(并且 push 变成了 rsp math 和 memory mov)。这向我表明条件调用方法大致等效(尽管我的简单分析可能在这里有缺陷?)。

至少,我的首要怀疑是通过在 cmpjl 之间引入几条指令,我可以使 cmp 的结果成为可能在可以推测性地执行 jl 之前可用(从而完全阻止 b运行ch 预测)。虽然管道可能比这更长?这涉及我不太熟悉的领域(尽管已阅读并保持对 Agner Fog's optimization manuals 的中等深度理解)。

我的假设是,对于(负和正)nums 的均匀分布(其中 b运行ch 预测将无法预测 b运行ch在 call) 周围,我的 "conditional call" 方法将胜过 b运行ching around the call.

我写了一个harness to benchmark the performance of these two approaches。您可以 git clone https://gist.github.com/baileyparker/8a13c22d0e26396921f501fe87f166a9make 到 运行 机器上的基准测试。

这是每个方法在 1,048,576 个数字的数组上进行 100 次迭代的 运行时间(在 int32_t 最小值和最大值之间均匀分布)。

|                    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 |

这些结果在 运行 秒内是一致的,尽管通过增加数组大小(或迭代次数)进行了放大,但 b运行 总是赢。

我还尝试重新排序条件调用步骤(首先计算和有条件地更新 rsp,然后写入堆栈),但这执行类似。

我遗漏(或误解)了哪些硬件细节可以解释这一点?根据我的计算,额外的指令增加了大约 6-7 个周期,但 b运行ch 错误预测成本为 15。因此,平均一半的数字被预测错误,因此每次迭代成本为 15/2 个周期(对于 b运行ch over approach)并且条件调用总是 6-7 个周期。来自 iaca 的 uops 表明这些方法在这方面更加接近。那么,性能不应该更接近吗?我的示例代码也是contrived/short吗?我的基准测试技术不适合这种低级别的关键部分测试吗?有没有办法 reorder/change 条件调用使其性能更高(可能更好或与 b运行ch 方法相当)?

tl;dr 为什么我的条件调用代码(第 4 个代码片段)的性能比 gcc/clang 产生的更差(条件跳过 callmy benchmark?

(第 2 个代码片段)(第 1 个片段中的代码)

正如@fuz 在评论中指出的那样,性能问题几乎可以肯定是由于 Return Address Stack (RAS),它是函数 returns.

的专用分支预测器。

作为从 jmp 中分离出 callret 指令以及手动堆栈修改的优点,CPU 的意图包含在 运行代码。特别是,当我们 call 一个函数时,它可能会去 ret,当它这样做时,我们将跳回到 call 之前推送的 rip。换句话说,call 通常与 ret 配对。 CPU 通过保留仅包含 return 个地址的固定长度堆栈(称为 return 地址堆栈 (RAS))来利用这一点。 call 指令除了将 return 地址推送到实际的内存堆栈外,还会将其推送到 RAS。这样,当遇到 ret 时,CPU 可以从 RAS 弹出(这比实际堆栈的内存访问快得多)并推测执行 return。如果结果证明从 RAS 中弹出的地址是从堆栈中弹出的地址,则 CPU 继续执行而不会受到任何惩罚。但是,如果 RAS 预测了错误的 return 地址,则会发生管道刷新,这是代价高昂的。

我最初的直觉是条件指令会更好,因为它们会在跳转之前为比较结果到达提供时间。然而,无论可能提供什么好处,具有不平衡的 jmp/ret(我的条件调用将 call 替换为 jmp,但被调用的函数仍然使用 ret) 导致 RAS 可能总是预测错误的 return 地址(因此我的方法,尽管最初试图避免这种情况,但会导致更多的管道停顿)。 RAS 的加速比我的 "optimization" 更显着,因此分支方法优于条件调用方法。

根据 some empirical results 不匹配的 callret(特别是使用 jmp + ret)需要比正确的多 5-6 倍的周期配对 callret。一些餐巾纸数学表明,在 3.1GHz 下对 1,048,576 次调用的 +21 个周期的惩罚会使总运行时间增加约 7.1 毫秒。观察到的放缓幅度小于此。这可能是条件指令延迟跳转直到条件准备就绪的组合,以及跳转在内存中的固定位置之间振荡的事实(其他分支预测器可能擅长预测)。

您可以准确地确定为什么 conditional_call 方法比 branch_over_call 慢。您已经在两个 KBL 处理器上完成了实验,但您提到的 blog post 并未讨论 RAS 如何在 KBL 上工作。因此,分析的第一步是确定 negate 函数中的 ret 是否被错误预测(就像早期微体系结构中会发生的那样)。第二步是确定错误预测 ret 指令对总执行时间的成本是多少。我最接近 KBL 的是 CFL,结果我的数字和你的很接近。两者之间唯一的相关区别是 LSD 在 CFL 中启用,但在 KBL 中禁用。但是,在这种情况下,LSD 是无关紧要的,因为循环中的 call 指令会阻止 LSD 检测到任何循环。您也可以轻松地在 KBL 上重复相同的分析。

有几种方法可以分析分支指令的行为。但在这种特殊情况下,代码非常简单,事件计数方法可以揭示我们需要的关于每个静态分支指令的所有信息。

BR_INST_RETIRED_* 性能事件可用于计算失效的动态分支指令总数和失效分支指令的特定类型总数,包括条件、调用和 returns。 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在每次迭代中各执行一次。最里面的 ret 仅在 jl 分支被执行时执行。使用性能事件,我们可以计算执行的 return 指令总数,并从中减去迭代总数,得到最里面的 ret 被执行的次数。因为输入是根据均匀分布随机化的,所以最里面的 ret 被执行了一半的时间也就不足为奇了。

call指令永远不会被错误预测。 jne 指令也永远不会被错误预测,除了指令的最后一次执行(它退出循环的地方)。因此,我们可以将条件错误预测的总数归因于 jl 指令。这可以从错误预测总数中减去,以获得 return 错误预测的数量,这些错误预测可以归因于 return 指令中的一个或两个。当第一个 ret 的错误预测破坏或错位 RAS 时,第二个 ret 可能会错误预测。确定第二个 ret 是否曾被错误预测的一种方法是使用 BR_MISP_RETIRED.ALL_BRANCHES 的精确采样。另一种方法是使用您引用的博客 post 中描述的方法。事实上,只有最里面的 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,所以可能是代码或不同代码段的对齐方式存在一些差异,导致我们的结果之间存在差异。