现有大部分的cpu的指令都是流水线的,比较标准的如五级流水。即一条指令还没有执行完毕,把下一条指令加载进来,这样可以充分提高cpu的性能。

流水线的效率很高,但是对分支现象非常敏感。因为流水是顺序执行的,碰到分支的时候,不知道如何选取下一条指令加载进来,必须等上一条分支指令执行完毕才能确定下一条指令到底是哪一条。

所以分支预测非常重要;如果分支预测成功,就可以保证流水继续执行,不被中断;实际上,cpu本身有了大量的工作就是提高分支预测的准确率。这里不做详细叙述。

今天看masstree的代码的时候,发现还可以做到软件层面的分支预测。跟钊神交流了一下,他们在平时工作的时候加入了这一特性,还是有一定的效果的。

编译器优化

软件层面的分支预测主要是在编译器上。主要流程就是程序员告诉编译器,我这段分支判断的结果是真还是假的概率大。我们定义如下两个宏:

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

其中likely意思为x为真概率较大,unlikely意为x为假的概率较大。为啥取两次非,这个问题我没想明白

举一个很简单的例子,

int s = rand()%100;
if (unlikely(s > 96)){
    s = s+1;
}else{
    s = s-1;
}

0-100的随机数肯定是大于96的概率较小。下面是likely的用法。

int s = rand()%100;
if (likely(s < 96)){
    s = s+1;
}else{
    s = s-1;
}

接下来我们看看编译器做了什么事情,首先看原始代码的情况:(这里我们为了说明问题,原始代码用-O0的优化,
因为加了-O2优化的时候,编译器会用一条cmovl指令把这个分支消掉。)

.L1:
    movl    %eax, -4(%rbp)
    cmpl    $96, -4(%rbp)    // 没做任何优化,直接判断是否小于96;小于的话跳到L2, 
    jle    .L2
    addl    $12, -4(%rbp)    // s = s+1 ,然后跳到L3
    jmp    .L3
.L2:
    subl    $12, -4(%rbp)
.L3:
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave

然后看看在加了likely之后的效果,

L1:
    subl    %edx, %ecx  // 这里的%ecx就是获取的随机数
    cmpl    $95, %ecx
    movl    %ecx, %edx
    /* 这里编译器调整了一下顺序,如果%ecx > 96, 即判断失败,跳到 L2;
        否则直接执行,保证流水线的成功
    */
    jg    .L2
    addl    $1, %edx   // s = s+1
.L3:
    movl    $.LC0, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    call    __printf_chk
    xorl    %eax, %eax
    addq    $8, %rsp
    .cfi_remember_state
    .cfi_def_cfa_offset 8
    ret
    /* 有意思的是,编译器把else代码段里的东西扔到了最后,实际上L3才是if-else之后的代码,这样的好处是,
    一旦预测成功,执行if代码段里的东西,可以继续往下执行了,不用再进行跳转 */
.L2:
    .cfi_restore_state
    subl    $1, %edx
    jmp    .L3
    .cfi_endproc

最后看看加入unlikely的效果,

L1:
    subl    %edx, %ecx
    cmpl    $96, %ecx     
    movl    %ecx, %edx   
    jg    .L6                    // 这里直接进行比较,明显不信任s会大于96,这样如果预测成功,一路执行到底。
    subl    $1, %edx        // s = s-1
.L3:                        // if-else结构体之后的代码
    movl    $.LC0, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    call    __printf_chk
    xorl    %eax, %eax
    addq    $8, %rsp
    .cfi_remember_state
    .cfi_def_cfa_offset 8
    ret
.L6:                    //  这里是if代码段里的东西,只有预测失败才会跳到这里,之后再跳到L3执行后面指令
    .cfi_restore_state
    addl    $1, %edx
    jmp    .L3
    .cfi_endproc

总结

上面看了好多汇编代码,其实我们大概知道了编译器在分支预测方面到底做了些什么事情:

  • likely: 把if{}代码段里的内容和if-else后面的代码段连起来,保证这样一路没有任何跳转
  • unlikely: 把else{}代码段的内容和后面的连起来。

其实总共就做了这两件事,是不是很简单?

还有一个有趣的事情,就是我本来想写一个没有分支预测的代码例子,然而O2的编译器,直接把分支给去掉了。看了很长时间后发现,其实人家用一个cmovl的指令就解决了(该指令是如果cmpl为真就mov)。因为本例太简单了,s只有两种值,s+1或者s-1。提前算好这两个数,然后调用cmovl指令即可。是不是很机智。

所以我们还是要相信编译器的智商。如果一个代码段不复杂,不建议使用分支预测。

性能测试

听品爷说实际系统里的性能能提高大概10% -20% 。我试了几个例子都太简单了,所以编译器已经把我打败了。
所以挖一个坑在此,之后有机会在复杂系统测试一下。