在amd64上执行“条件呼叫”

时间:2019-02-25 14:37:37

标签: assembly x86 x86-64 branch-prediction

在代码的关键部分考虑条件函数调用时,我发现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这样的条件调用指令怎么办?想象一下,是否有这样的指令“ ccall cc ”,其语义类似于cmov cc 。然后您可以执行以下操作:

.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

此方法有一些潜在的缺点:

  • 它引入了几条指令(但是它们总共比分支错误预测惩罚要少)
  • 这需要写入内存(但是堆栈可能已缓存?)
  • 即使未拨打电话,它始终执行两个leamov(但是我的理解是这并不重要,因为cmov cc 接受了例如,与mov相同的循环数)

要检查每种方法的属性,我在iaca中运行了关键部分。如果已安装(并在下面克隆了我的基准要点),则可以运行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个uop(方法上的分支有5个uop)。我想这是有道理的,因为在幕后,调用变成了push和jmp(而push变成了rsp math和memory mov)。这向我暗示了条件调用方法是大致等效的(尽管也许我的简化分析在这里有缺陷?)。

至少,我最主要的怀疑是通过在cmpjl之间引入几条指令,我有可能使cmp的结果在jl可以通过推测方式执行(因此完全避免了分支预测)。尽管也许管线比这更长?这涉足到我不太熟悉的领域(尽管已经阅读并保留了对Agner Fog's optimization manuals的中等深度的理解)。

我的假设是,对于(负数和正数)num(s分支预测无法预测call周围的分支)的均匀分布,我的“条件调用”这种方法将胜过整个呼叫分支。

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

这里是在1,048,576个数字(最小和最大{{1}之间均匀分布)的数组上,每种方法进行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 | ,然后写入堆栈),但这与执行类似。

我缺少(或误解)了哪些硬件细节?根据我的计算,这些额外的指令会增加大约6-7个周期,但是分支预测错误的成本为15。因此,平均而言,有一半的数字被预测为错误,因此每次迭代的成本为15/2个周期(对于方法分支而言),并且始终为6-有条件调用的7个周期。来自iaca的建议表明,在这方面方法甚至更接近。那么,性能不应该更接近吗?我的示例代码是否太人为/简短?我的基准测试技术不适用于这种低水平的关键部分测试吗?有没有一种方法可以对条件调用进行重新排序/更改,以使其更具性能(也许更好或更类似于分支方法)?

tl; dr 为什么我的条件调用代码(第4个代码段)的性能比gcc / clang产生的结果(条件跳过rsp)(第2个代码段)的性能差my benchmark上第一个代码段中的代码?

2 个答案:

答案 0 :(得分:3)

您可以准确确定为什么conditional_call方法比branch_over_call慢。您已经在两个KBL处理器上进行了实验,但是所提到的blog post并未讨论RAS如何在KBL上工作。因此,分析的第一步是确定ret函数中的negate是否被错误预测(就像在早期的微体系结构中会发生的一样)。第二步是确定错误地预测ret指令在总执行时间上的成本。我最接近KBL的是CFL,事实证明我的数字接近您。两者之间唯一相关的区别是LSD在CFL中启用,但在KBL中禁用。但是,由于循环中的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的{​​{1}}函数。 ret指令用于循环。第一列和第二列中显示的数字分别通过迭代总数和动态指令总数进行规范化。从程序的静态结构中我们知道jnecalljl的{​​{1}}和conditional_call在每个迭代中都执行一次。最里面的ret仅在采用jne分支时执行。使用性能事件,我们可以计算已执行的返回指令的总数,并从中减去迭代总数,以获取最里面的ret被执行的次数。因为输入是根据均匀分布随机分配的,所以最里面的jl会在一半的时间内执行,这不足为奇。

ret指令永远不会出错。 ret指令也永远不会被错误预测,除非最后执行该指令(退出循环)。因此,我们可以将条件错误预测的总数归因于call指令。可以从错误预测的总数中减去该值,以获得可以归因于两个或两个返回指令的返回错误预测的数量。当第一个jne错误预测或错误对齐RAS时,第二个jl可能会预测错误。确定第二个ret是否曾经被错误预测的一种方法是使用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错误预测的平均成本,可以用ret序列替换conditional_call指令,以便BTB而不是RAS用于进行预测。进行此更改后,唯一会被错误预测的指令是ret。执行时间的差异可以视为对lea/jmp错误预测的总成本的估计。在我的CFL处理器上,每个jl错误预测大约需要11.3个周期。另外,retret快了约3%。您在KBL上的数字表示conditional_call错误预测的平均成本约为13个周期。我不确定造成这种差异的原因是什么。它可能不是微体系结构。我使用的是gcc 7.3,但是您使用的是gcc 8,所以也许代码中的某些差异或不同代码段的对齐方式会导致结果之间出现差异。

答案 1 :(得分:1)

正如@fuz在评论中指出的那样,性能问题几乎可以肯定归因于Return Address Stack (RAS),它是函数返回的专门分支预测器。

作为具有独立于call的{​​{1}}和ret指令以及手动修改堆栈的一个优点,CPU可以根据正在运行的代码来进行选择。特别是,当我们jmp使用某个函数时,它很可能会转到call,而当我们执行该函数时,我们将跳回到在ret之前推送的rip。换句话说,call通常与call配对。 CPU通过保持固定长度的返回地址堆栈(称为返回地址堆栈(RAS))来利用这一点。 ret指令除了将返回地址推送到实际的内存堆栈之外,还将将其推送到RAS。这样,当遇到call时,CPU可以弹出RAS(这比实际堆栈的内存访问快得多)并以推测方式执行返回。如果事实证明从RAS弹出的地址是从堆栈弹出的地址,则CPU继续运行而不会受到任何惩罚。但是,如果RAS预测了错误的返回地址,则会发生流水线刷新,这代价很高。

我最初的直觉是条件指令会更好,因为条件指令会给时间,让比较结果在跳转之前到达。但是,无论ret / jmp不平衡(我的条件调用将ret替换为call可能带来的好处,但是被调用函数仍然使用{{1 }})导致RAS总是会预测错误的返回地址(因此,尽管我本来试图避免这种情况,但我的方法却导致了更多的流水线停顿)。 RAS的加速比我的“优化”更为重要,因此分支方法的性能优于条件调用方法。

根据some empirical resultsjmpret不匹配(特别是使用call + ret),比正确配对{ {1}}和jmp。一些餐巾纸数学认为,在3.1GHz处,对1,048,576个调用的惩罚为+21个周期,这将使总运行时间增加7.1ms。观察到的减速幅度小于该值。这可能是条件指令将跳转延迟到条件就绪之前进行的组合,以及跳转在内存中固定位置之间振荡的事实(其他分支预测变量可能擅长于预测)。