3. 分支预测技术:静态预测与动态预测、2位饱和计数器、BTB、Gshare预测器

分支预测,说白了就是猜CPU下一条指令往哪走。你想想看,流水线里指令哗哗地流,突然遇到一个条件分支,得等结果出来才知道下一步去哪。这一等,流水线就 stall 了,性能损失很大。我早年做低功耗处理器时,觉得分支预测不重要,结果性能直接腰斩。嗯,从那以后我再也不敢小看它了。

3.1 静态预测:最简单的办法

静态预测,就是编译器或者硬件根据指令本身的特点,直接猜一个方向。最常见的做法是:向后跳转预测为“跳”,向前跳转预测为“不跳”。为什么?因为循环体通常向后跳,if-else 通常向前跳。

我在项目中遇到过一种情况:某个算法里全是向前跳转的异常处理分支,静态预测几乎全错。后来我改成动态预测,性能提升了 15%。所以静态预测虽然简单,但只适合那些分支行为很规律的场景。

静态预测的优缺点:

  • 优点:硬件开销几乎为零,不耗电,实现简单
  • 缺点:准确率低,通常只有 60%-70%
  • 适用场景:低功耗嵌入式、早期处理器、作为动态预测的 fallback

3.2 动态预测:2位饱和计数器

动态预测的核心思想是:根据历史行为来猜。最简单的动态预测器就是 1 位计数器——上次跳了,这次就猜跳;上次没跳,这次就猜不跳。但有个问题:如果分支在“跳”和“不跳”之间来回切换,1 位计数器会连续猜错两次。

所以业界普遍用 2 位饱和计数器。它有 4 个状态:强不跳、弱不跳、弱跳、强跳。只有连续猜错两次,才会改变预测方向。这玩意儿我当年在课堂上学的时候觉得太简单了,直到自己写 RTL 实现时才发现,状态机的转移逻辑稍微画错一点,性能就崩了。

// 2位饱和计数器状态转移示例
// 状态:00=强不跳, 01=弱不跳, 10=弱跳, 11=强跳
// 输入:taken=1 表示实际跳转, taken=0 表示不跳

state_transition(current_state, taken) {
    case (current_state) {
        00: next_state = taken ? 01 : 00;  // 强不跳 -> 弱不跳 或 保持
        01: next_state = taken ? 10 : 00;  // 弱不跳 -> 弱跳 或 回强不跳
        10: next_state = taken ? 11 : 01;  // 弱跳 -> 强跳 或 回弱不跳
        11: next_state = taken ? 11 : 10;  // 强跳 -> 保持 或 回弱跳
    }
    return next_state;
}

我的经验:2位饱和计数器适合大多数分支,但遇到“交替模式”(如 T, NT, T, NT)时准确率只有 50%。我曾经在视频解码器里遇到过这种模式,后来用 Gshare 才搞定。

3.3 BTB:分支目标缓冲器

预测方向只是第一步。你猜对了要跳,但跳到哪里去?如果每次都要等译码阶段算目标地址,流水线还是得停。所以我们需要 BTB(Branch Target Buffer)——一个缓存,存着之前见过的分支指令的 PC 和对应的目标地址。

BTB 的工作原理很简单:

  1. 取指阶段,用当前 PC 查 BTB
  2. 如果命中,直接读出目标地址,下一条指令就从那里取
  3. 如果没命中,说明这是个新分支,等译码阶段算地址,然后写入 BTB

我记得有一次调试一个性能问题,发现 BTB 的命中率只有 70%。查了半天,原来是 BTB 的条目数太少,频繁被覆盖。后来我把 BTB 从 64 条增加到 256 条,命中率直接升到 95%。

注意:BTB 和分支预测器是两回事。BTB 管“跳到哪里”,分支预测器管“跳不跳”。两者配合使用,缺一不可。

3.4 Gshare 预测器:全局历史的力量

2位饱和计数器只看了当前分支自己的历史,但很多分支的行为是受其他分支影响的。比如一个 if-else 嵌套结构,外层分支的结果会影响内层分支的行为。这时候就需要 Gshare 预测器

Gshare 的核心思想是:用全局历史(Global History)和当前 PC 做异或(XOR),得到一个索引,再去查 2 位饱和计数器表。这样,不同分支的历史信息就能互相影响,预测更准。

// Gshare 预测器伪代码
// GHR: 全局历史寄存器,记录最近 N 个分支的结果
// PHT: 模式历史表,存储 2 位饱和计数器

function gshare_predict(PC, GHR) {
    index = (PC XOR GHR) & (PHT_SIZE - 1);
    counter = PHT[index];
    prediction = (counter >= 2) ? TAKEN : NOT_TAKEN;
    return prediction;
}

function gshare_update(PC, GHR, actual_taken) {
    index = (PC XOR GHR) & (PHT_SIZE - 1);
    // 更新饱和计数器
    if (actual_taken && PHT[index] < 3) PHT[index]++;
    if (!actual_taken && PHT[index] > 0) PHT[index]--;
    // 更新全局历史
    GHR = ((GHR << 1) | actual_taken) & GHR_MASK;
}

我当年在做一个乱序执行处理器时,Gshare 的全局历史长度选了 12 位,PHT 有 4096 个条目。实测下来,SPEC CPU 2006 里的分支预测准确率从 92% 提升到了 96%。别小看这 4%,在 3GHz 的处理器上,相当于每秒钟少了几千万次流水线冲刷。

Gshare 的关键参数:

参数 典型值 影响
全局历史长度 8~16 位 越长,能捕捉的相关性越多,但训练时间也越长
PHT 条目数 512~4096 越多,别名冲突越少,但面积和功耗越大
饱和计数器位数 2 位 2 位是性价比最优,1 位太敏感,3 位以上收益递减

3.5 三种预测器的对比

我把这三种预测器放在一起对比一下,方便你选型:

预测器类型 准确率 硬件开销 适用场景
静态预测 60%~70% 几乎为零 低功耗、简单处理器
2位饱和计数器 85%~92% 低(每个分支只需 2 位) 通用处理器、嵌入式
Gshare 93%~97% 中等(PHT + GHR) 高性能乱序处理器

你可能会问:为什么不用更复杂的预测器?比如 Tournament 预测器或者 TAGE?嗯,那些确实更准,但面积和功耗也更大。我个人的习惯是:先评估分支行为的复杂度,再选预测器。如果大部分分支是循环和简单 if-else,2 位饱和计数器加 BTB 就够了。如果遇到复杂的间接分支或者嵌套条件,再上 Gshare。

避坑指南:我曾经在一个项目里直接上了 Gshare,结果发现大部分分支的准确率和 2 位计数器差不多,但功耗高了 20%。后来我加了分支类型检测,只有复杂分支才走 Gshare,简单分支走 2 位计数器。这样既保性能又省电。

3.6 本章知识体系

下面这张图展示了分支预测技术的核心逻辑和演进关系:

分支预测技术知识体系 分支预测 静态预测 动态预测 向后跳转预测为跳 向前跳转预测为不跳 2位饱和计数器 BTB Gshare Gshare 核心 PC XOR GHR → 索引 查 PHT 表 → 预测 静态预测 → 动态预测 → Gshare:准确率逐步提升,硬件开销也逐步增加

从这张图可以看得很清楚:分支预测从最简单的静态规则,到基于局部历史的 2 位计数器,再到结合全局历史的 Gshare,每一步都是在用更多的信息换取更高的准确率。但代价也很明显——面积、功耗、延迟都在涨。所以选型时一定要权衡,别盲目追求最复杂的方案。