4、分支预测技术:静态预测、动态预测、BTB、分支历史表的工作原理

流水线最怕什么?怕分支。一遇到条件跳转,流水线就得猜——猜对了,指令哗哗往下走;猜错了,整条流水线得冲刷干净重来。我早年调一个网络处理器,分支预测失误率高了3%,吞吐量直接掉了15%。所以这一章,咱们把分支预测的底裤扒干净。

4.1 为什么需要分支预测?

ARM流水线里,指令是一条接一条取的。碰到BEQ或B指令,处理器不知道下一条该取哪。等执行阶段算出目标地址?流水线已经空转好几个周期了。

核心矛盾:取指阶段需要下一条PC,但分支结果在执行阶段才出来。这中间的延迟,就是性能杀手。

分支预测就是提前猜一个方向,让流水线继续跑。猜对了,零惩罚;猜错了,流水线冲刷,代价通常是10~20个周期。你想想看,一个循环跑1000次,每次预测错一次,性能就崩了。

4.2 静态预测:最简单的猜法

静态预测不依赖历史信息,编译器或硬件直接定死规则。ARM早期处理器用得比较多。

预测策略 规则 典型场景
预测不跳转 默认顺序执行下一条 ARM7、ARM9
预测跳转 默认跳转到目标地址 极少用,容易反效果
向后跳转预测跳 循环末尾向后跳,猜跳转 循环优化
向前跳转预测不跳 异常处理等前向跳,猜不跳 if-else结构

我个人习惯在裸机启动代码里用静态预测——因为启动时分支历史表还没建立,静态规则反而稳定。但到了复杂应用,静态预测的准确率只有60%~70%,不够看。

避坑:我曾经在ARM9上写一个状态机,用了大量前向跳转。静态预测默认不跳,结果每次状态切换都冲刷流水线。后来改成查表+间接跳转,才把性能救回来。

4.3 动态预测:让硬件学起来

动态预测的核心是记录历史。硬件维护一张表,存着每条分支指令最近的行为。下次遇到同一条指令,查表猜。

4.3.1 1位饱和计数器

最简单的动态预测器。每个分支对应1 bit:0表示不跳,1表示跳。每次执行后更新。

// 伪代码:1位饱和计数器
if (prediction_bit == 1)
    predict_taken();
else
    predict_not_taken();

// 执行后更新
if (actually_taken)
    prediction_bit = 1;
else
    prediction_bit = 0;

问题很明显:连续两次跳转,中间一次不跳,预测就翻一次。循环末尾特别容易错——循环最后一次不跳,但下一次又跳了。

4.3.2 2位饱和计数器

加一个状态位,变成4个状态:强不跳、弱不跳、弱跳、强跳。需要连续两次错误才翻转。

状态机:00(强不跳) → 01(弱不跳) → 10(弱跳) → 11(强跳)。每次预测取高位,每次错误向反方向移动一格。

我调过的一个网络转发芯片,把1位换成2位,分支预测准确率从82%提到94%。代价只是多1 bit存储,性价比极高。

4.4 分支历史表(BHT)

BHT就是一张表,用分支指令地址的低位做索引,存对应的预测器状态。ARM Cortex-A系列普遍用2位饱和计数器+BHT的组合。

参数 典型值 说明
表项数 512 ~ 4096 越大冲突越少,但延迟增加
每项宽度 2 bit 2位饱和计数器
索引方式 PC低n位 直接映射,可能有别名冲突
更新策略 执行后写回 推测执行期间不更新

BHT的局限是:它只看分支指令的地址,不看上下文。同一个地址在不同调用路径下行为可能完全不同。这就引出了更高级的技术——两级自适应预测器。

4.5 分支目标缓冲器(BTB)

预测跳不跳只是第一步。如果预测跳,还得知道跳到哪里。BTB就是干这个的——它缓存分支指令的PC和目标地址的映射。

// BTB查找逻辑(简化)
if (btb_lookup(current_pc)) {
    // BTB命中
    predicted_target = btb_get_target(current_pc);
    if (prediction == TAKEN)
        fetch_from(predicted_target);
    else
        fetch_from(current_pc + 4);
} else {
    // BTB未命中,用静态预测或BHT
    fetch_from(current_pc + 4);  // 默认不跳
}

注意:BTB和BHT是两回事。BHT只管跳不跳,BTB只管跳去哪。现代ARM处理器两者都有,协同工作。

BTB的容量直接影响性能。我见过一个项目,BTB只有64项,一个大的switch-case就把BTB打爆了,每次都要重新计算目标地址。后来换成256项BTB,命中率从60%升到95%。

4.6 整体架构与数据流

下面这张图展示了分支预测的完整流程。我建议你盯着看两分钟,把每个节点的关系理清楚。

分支预测整体架构图 分支预测整体架构与数据流 取指阶段 当前PC BHT BTB 2位饱和计数器 预测方向 + 目标地址 执行阶段 实际跳转/不跳 更新BHT/BTB 关键流程说明: 1. 取指阶段用当前PC同时查询BHT和BTB 2. BHT给出预测方向(跳/不跳),BTB给出目标地址 3. 执行阶段得到实际结果,反馈更新BHT和BTB 4. 虚线表示更新路径,发生在执行阶段之后

4.7 实战中的分支预测优化

说了这么多原理,落地才是关键。我总结几条实战经验:

  • 减少分支密度:能用查表代替if-else的,尽量用查表。分支越少,预测压力越小。
  • 对齐热点分支:把高频执行的分支指令放在BTB能覆盖的地址范围。我习惯用__attribute__((aligned(64)))对齐关键函数。
  • 避免过于复杂的条件:多层嵌套的条件表达式,会让BHT的上下文信息丢失。拆成简单分支+早期返回。
  • 利用likely/unlikely:GCC的__builtin_expect可以给编译器提示,让它生成更有利于静态预测的代码。
// 示例:使用__builtin_expect优化分支
if (__builtin_expect(ptr != NULL, 1)) {
    // 大概率执行路径
    process_data(ptr);
} else {
    // 小概率异常路径
    handle_error();
}

我的习惯:每次写完关键循环,我都会用perf stat看分支预测miss率。如果超过5%,就值得花时间优化。低于2%,基本不用管。

4.8 小结

分支预测不是玄学,是硬件和软件配合的艺术。静态预测简单但准确率低,动态预测靠历史信息提升准确率,BHT管方向,BTB管目标地址。理解这些,你就能在写代码时有意识地避开分支预测的坑。