CPU 分支预测探索与利用

CPU 的分支预测是个有趣的性能优化点,在写代码层面、编译层面和汇编指令层面我们都可以介入指导分支预测做得更好,本文也将从这些角度来探索 CPU 分支预测,以达到更好的利用。

CPU 流水线

【参考维基百科 - Instruction pipelining:https://en.wikipedia.org/wiki/Instruction_pipelining

CPU 流水线执行是一种在单处理器上实现多指令并行的技术。

CPU 一般的执行操作包含:获取指令、翻译指令、执行指令、内存访问、寄存器写入等,这些分别用到了 CPU 的不同处理单元。

 

 

如果每个 CPU 时钟周期只执行一个操作,那对于其他 CPU 上的处理单元是一种浪费,所以将各种不冲突的指令放到了同一个时钟周期内,达到更高的 CPU 利用率,提升执行指令的效率。

 

 

将流水线引入 CPU,可以提高 CPU 的效率。更简单的说,让 CPU 可以预先取出下一条指令,可以提供 CPU 的效率。如果存在跳转指令,那么预先取出的指令就无用了。CPU 在执行当前指令时,从内存中取出了当前指令的下一条指令。执行完当前指令后,CPU 发现不是要执行下一条指令,而是执行 offset 偏移处的指令。CPU 只能重新从内存中取出 offset 偏移处的指令。因此,跳转指令会降低流水线的效率,也就是降低 CPU 的效率。

【参考博文: https://blog.csdn.net/shuimuniao/article/details/8017971

分支预测

分支预测一般发生在有状态跳转(conditional jump)的指令中。

在 stackoverflow 上对此的回答中,有个人举了一个很贴切的比喻和相关解释

问题:Why is processing a sorted array faster than processing an unsorted array?
回答链接: https://stackoverflow.com/a/11227902/13005964

比喻

 

 

假设你是控制火车轨道的扳道员,火车笨重冗长且与你没有提前协商轨道方向,如果让火车在变轨轨道前停下来,然后和你进行沟通,那会让火车旅程用时变长,且还需要重新启动。

这个时候,更好的方法是你直接猜测火车的运行轨迹,给他设置一个方向:

  • 如果猜对了,火车完全不需要停下来
  • 如果猜错了,火车就需要停下来,让你进行变轨的操作

如果你经常猜对,火车完全不需要停下来;如果你经常猜错,就需要火车花费大量的时间停下来且让你变轨。

指令层面

转换到指令层面:

 

 

由于 if 语句导致了分支跳转的指令存在,这个时候 CPU 同样面对了变轨的难题。

现代 CPU 有复杂的流水线支持,把获取指令、翻译指令、执行指令分摊到流水线上,通常 CPU 就会提前预先获取下一条指令。

这个时候,CPU 提前做出了某个分支的判断:

  • 如果猜对了,可以继续执行
  • 如果猜错了,那就需要刷新流水线并且回退到正确的分支

如果经常猜对,CPU 执行流完全不需要停下来;如果经常猜错,就需要花费 CPU 时间回退并调整。

一般情况下,CPU 预测失败后需要 10~20 个时钟周期来进行回退与调整。

如何更好帮助 CPU 准确地预测?

数据排序

如果 99% 的情况下都是往某个分支去执行,那直接预测为这个分支即可。现代 CPU 基本上能达到 90% 以上的命中率,但面对无法预测的分支而言,就显得鸡肋了。

分支预测的罪魁祸首就是 if 判断了。

if (data[c] >= 128)
    sum += data[c];

如果数据平均分布在 0 到 255 之间,当数据是排好序的,大约有一半的数据是不会进入上面代码的 if 里,另一半会进入。

这种情况对于分支预测是非常友好的,因为它会有大部分时间进入同样的分支里。

profile-guided optimization

PGO 生成插桩代码,运行进程,利用运行时采集生成一个 profile 的运行时信息,指导编译器生成更好的分支代码。

__builtin_expect

可以使用 gcc 的 __builtin_expect(expression, value) 来进行优化,这个函数的作用是允许程序员将最有可能执行的分支告诉编译器。

编译器拿到这个信息(分支转移的信息)之后,在编译过程会将可能性更大的代码紧接在其后,从而减少指令跳转带来的性能损耗。

【参考博文:https://www.jianshu.com/p/2684613a300f

更好的方式是消除分支的存在

conditional move

【参考 stackoverflow 回答:https://stackoverflow.com/a/30150355/13005964

cmove 指令不比较源地址和目标地址,它使用上一次比较的结果(存在状态寄存器里的某个标识符),来决定是否进行操作。

cmp eax, ebx
cmove ecx, edx

上面指令表示:如果 eax == ebx,就将 edx 拷贝到 ecx。

效果等同于有分支跳转的下面的指令版本:

cmp eax, ebx
jne skip
  mov ecx, edx
skip:

缺点

但 cmov 的开销会比较大 【参考讨论贴:https://news.ycombinator.com/item?id=6042728

因为因特尔的 CPU 使用的基础架构只支持在一个时钟周期一条指令最多读取两个输入,但是 cmov 需要读取状态寄存器、源寄存器、目标寄存器的值,所有会有两个时钟周期的延时。

对于难以预测的分支,使用 cmov 是很好的方式。

但对于可以很好预测的分支,使用 cmov 对于性能而言反而是一种倒退。

而现代 CPU 还可能会存在乱序执行的情况,cmov 因为需要取好三个依赖的输入数据,才能执行,所以会导致乱序执行被阻塞。

【维基百科 - 乱序执行:CPU 处理器会根据输入数据的可用性来决定指令执行的顺序,而非程序的原始指令数据的执行顺序。这种方式,主要是为了避免因为获取下一条程序指令而引起的处理器等待,CPU 根据可用性先处理可以立即执行的指令。】

【gcc 早期版本 -O3 使用了 cmov 就导致了代码执行性能变差了,后来 -O3 使用 SSE 来优化循环了:https://stackoverflow.com/a/43941854/13005964

位运算

if (data[c] >= 128)
    sum += data[c];

将上面的代码可以替换为下面这种形式:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

通过位运算成功消除了分支的存在了。

分支预测的实现方式

【参考维基百科 - branch predictor:https://en.wikipedia.org/wiki/Branch_predictor

分支预测的实现方式非常多,建议直接看上面的链接,下面仅列出其中的一些。

Static branch prediction

在编译期间对分支进行预测,早期 CPU 会更偏好与有状态跳转不会发生,也即 jmp 的下一条指令会被预取,通过编译时的静态分析,把更有可能发生的分支衔接在 jmp 指令的后面。

Dynamic branch prediction

动态分支预测使用在运行时采集到的分支执行信息 (类似 PGO,采用计数器的方式) 来对分支的结果进行预测。

Random branch prediction

等概率地随机乱猜。

Next line prediction

【参考博客:https://docs.boom-core.org/en/latest/sections/branch-prediction/nl-predictor.html

取指 PC 会在 BTB (Branch Target Buffer) 表中寻找匹配项,如果匹配上了,就会去查询 BIM (Bi-Modal Table,存储着计数器) 和 RAS (Return Address Stack) 来作出判断。

 

 

如果匹配项是 ret,则目标地址来源于 RAS。

如果匹配项是 jmp (无状态跳转),则无需访问 BIM。

如果匹配项是 bidx (branch index),则要标记那条指令是控制流判断的主因。

One-level branch prediction

1-bit 的计数器通常只是记录一下上一次分支跳转的结果。

而采用 2-bit 的计数器可以记录状态机的 4 个状态:

  • Strongly not taken
  • Weakly not taken
  • Weakly taken
  • Strongly taken

 

 

每当一个分支被执行时,就会以图中的形式来更新其状态,准确率高达 93.5%。

Two-level predictor

把上面的 2-bit 计数器的状态记录到分支跳转的历史记录表中来做辅助判断。

通常会保存上两次的结果到寄存器中 (a two-bit shift register)。

 

 

Local branch prediction

每个有状态跳转指令都保存一个属于自己的跳转历史记录表(history buffer)。

Global branch prediction

所有有状态跳转指令共享一个跳转历史记录表。

Alloyed branch prediction

结合了 local 和 global 的方式,还使用了一些 PC 中的 bit。

Loop predictor

基本上一个循环的有状态跳转,如果在循环的最后一条指令,则会重复执行 N-1 次;如果在循环的开头一条指令,则通常只会执行 1 次。

用一个简单的计数器就能很好地做到循环的分支预测。

Indirect branch predictor

不直接跳转指令的目标地址可以指定为两个或以上,通常采用 Two-level predictor 来进行预测。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值