Dance with compiler - EP1

熟悉又陌生的 __buildin_expect

在 OceanBase 代码里,OB_LIKELYOB_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_useupdate函数反汇编:

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

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值