Dance with compiler
熟悉又陌生的 __buildin_expect
在 OceanBase 代码里,OB_LIKELY
、OB_UNLIKELY
随处可见,它们定义在 [ob_macro_utils.h](https://github.com/oceanbase/oceanbase/blob/master/deps/oblib/src/lib/utility/ob_macro_utils.h)
文件里:
#define OB_LIKELY(x) __builtin_expect(!!(x),!!1)
#define OB_UNLIKELY(x) __builtin_expect(!!(x),!!0)
OB_LIKELY
指示编译器 x 极可能为 true,请为此做编译优化。
这里所谓的“优化“包含两个层面的意思:
- 分支判断开销:尽可能让代码走到 LIKELY 分支的判断开销小,比如让指令布局对 CPU 预取友好。
- 对被 LIKELY 分支覆盖的内部逻辑做尽可能的指令优化,包括寄存器优化、编译展开优化等等。
但是,我们有没有想过,这个 LIKELY
表达的 “极可能“到底是多么地可能?如果是绝对、一定是,那就意味着走到这个分支的概率是 100%,也就意味着另一个分支的代码可以直接删除掉。我们的本意当然不是这样,当我们用 OB_LIKELY 的时候,只是希望编译器竭尽全力去优化 LIKELY
分支,不要话费任何心思优化 UNLIKELY
分支。
我原来以为,编译器内置的 “极可能“ 概率是 99.999999%。今天才知道,No!“极可能“ 对应的概率是 90%!这算什么极可能嘛!
For the purposes of branch prediction optimizations, the probability that a __builtin_expect expression is true is controlled by GCC’s builtin-expect-probability parameter, which defaults to 90%.
.
ref: https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html#index-_005f_005fbuiltin_005fexpect
在一些 tight loop 里,我们需要对 LIKELY
分支做极端编译优化时,就不能随意使用 LIKELY
了,而应该使用更可控的一个编译器内置方法:
__builtin_expect_with_probability(long exp, long c, double probability)
它的前两个参数和 __builtin_expect
是一样的,第三个参数允许用户手动指定一个概率。你此时有机会指定一个比 0.9
大并且无限接近于1
的数,比如 0.9999
。
If the built-in is used in a loop construct, the provided probability will influence the expected number of iterations made by loop optimizations.
但是,话说回来,如何用好 __builtin_expect_with_probability
也是非常考验人的,需要深刻地认识到调整分支概率后编译器可能的行为是什么,才能有的放矢,不然,__builtin_expect
完全足够。
在一些关键的路径里,可以通过反汇编来观察指令代码逻辑是否符合预期,当发现不符合预期时,就可以去思考下是不是可以通过调整 probability 来优化编译结果。
[TBD: 给一个更好的例子,说明什么时候使用 __builtin_expect_with_probability
会非常有效]
正确理解 __restrict__
关键字
先来看一段代码:
#include <stdio.h>
#ifdef USE_RESTRICT
int update(int * __restrict__ x, int * __restrict__ y) {
#else
int update(int * x, int *y) {
#endif
int v = *y;
*x = *x + 1;
v = v + *y;
return *x + *y + v;
}
int main()
{
int x = 2;
int* y = &x;
printf("%d\n", update(&x,y));
return 0;
}
分别使用 restrict 和不使用 restrict 编译,执行结果居然不同:
raywill$% g++ -O2 -DNO_USE_RESTRICT restrict.cpp -o restrict_no_use
raywill$% ./restrict_no_use
11
raywill$% g++ -O2 -DUSE_RESTRICT restrict.cpp -o restrict_use
raywill$% ./restrict_use
9
为什么结果会不同呢?为什么 restrict_use
结果错误呢?下面逐步分析。
先看 restrict_no_use
的 update
函数反汇编:
raywill$% objdump -D restrict_no_use
restrict_no_use: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000100003f40 <__Z6updatePiS_>:
100003f40: b9400028 ldr w8, [x1]
100003f44: b9400009 ldr w9, [x0]
100003f48: 11000529 add w9, w9, #1
100003f4c: b9000009 str w9, [x0]
100003f50: b940002a ldr w10, [x1]
100003f54: 0b080128 add w8, w9, w8
100003f58: 0b0a0500 add w0, w8, w10, lsl #1
100003f5c: d65f03c0 ret
上面的汇编代码中,[x0]
表示 x
,[x1]
表示 y
,指令 ldr w8, [x1]
是把 y
值读入寄存器 w8
。
可以发现,不使用 restrict 时,下面这个表达式访问 *y 时会使用 ldr w10, [x1]
,而不是直接访问 w8
的值。
v = v + *y;
我们从代码逻辑上分析,*y
在函数体内一直没有被修改过,直接使用w8
应该是安全的吧?
实际上并不安全。编译器认为,y 是以指针的形式传入函数体内,说明外部也有指针引用了 y,并且,这个指针有可能就是 x!而 x 正好在这个函数体内被修改了。所以,为了语义正确,编译器必须重新从内存里载入 y 值到寄存器中。
假如调用 update
函数的用户明确知道 x
, y
一定指向了不同的位置,那么它可以通过 restrict
关键字告诉编译器这个信息,编译器生成的代码就不会重新从内存里取 y
值到寄存器。如下所示:
raywill$% objdump -D restrict_use
restrict_use: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000100003f44 <__Z6updatePiS_>:
100003f44: b9400028 ldr w8, [x1]
100003f48: b9400009 ldr w9, [x0]
100003f4c: 11000529 add w9, w9, #1
100003f50: b9000009 str w9, [x0]
100003f54: 0b080508 add w8, w8, w8, lsl #1
100003f58: 0b090100 add w0, w8, w9
100003f5c: d65f03c0 ret
可以看到,生成的汇编代码会少一次内存访问( ldr w10, [x1]
)。不过,在我们的例子里,我们通过 restrict 关键字欺骗了编译器,实际上 x 和 y 指向了同一片内存,这也导致了最终的结果错误。
restrict 对代码优化非常重要,可以有效提示编译器,减少访存次数。可以想见,如果这个访存操作发生在一个向量循环中,它的代价是不言而喻的。简单一个 restrict 关键字,就可以瞬间消除访存,提升性能,太棒了!
因为 restrict 对代码优化非常重要,在实际代码中,建议定义一个宏,让代码更漂亮一点点:
#define restrict __restrict__
这样,代码里就可以去掉让人不舒服的下划线下划线了。
restrict 也有失效的时候
吗?
假设有两个参数,a 用了 restrict,b 没有使用。当 b 指向 a 的内存时,会怎样?此时,我个人理解是:编译器先假设 a 总是可以信任寄存器中的值,当 b 写着块内存时,a 可以读不到最新的修改,当 b 读这块内存时,a 也不需要做任何写回操作使得 b 能读到最新数据。相当于,编译器仿佛只看到了 b 的存在,而不需要关心 a 的存在。对 b 的所有操作,按照非 restrict 方式处理即可。
如果有失效的时候,那应该是编译器的 bug 吧。