在考虑在代码的关键部分进行条件函数调用时,我发现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个
lea
和mov
指令(但我的理解是这不重要,因为例如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)。这也意味着条件调用方法大致相当(尽管也许我的简单分析有缺陷?)。
至少,我总体怀疑的是引入了几个指令,在cmp
和jl
之间,我可以使得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个代码片段中的代码)?
call
+ret
与jmp
+ret
)为+7.7ms。显然这比观察到的要多得多,但也许分支预测器会变得更好,因为返回总是到同一位置。 - Bailey Parkerrandom_nums
中的表达式uniform_int_distribution
所需的。使用g++ 7.3时,错误信息在benchmark.cpp
文件中的TEST_CASE
处报告为expected constructor, destructor, or type conversion before ( token
。 - Hadi Brais