﻿# 分支预测（Branch Prediction）—— CPU 为什么要"赌"下一条指令，赌错有多贵

> [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md) 讲乱序执行时提了一句"遇到分支还没算出方向，CPU 会猜一条路先执行"。这一句背后是整个**分支预测器**——现代 CPU 里最精巧的部件之一。它猜得准，流水线满速飞；猜错一次，几十条在飞的指令全作废重来。


> 本篇详细论述：**为什么非猜不可（流水线的宿命）、预测器怎么工作（BTB / 两位饱和计数器 / 历史 / TAGE）、猜错的代价有多大、代码怎么写才对预测器友好、以及它如何催生了 Spectre 这类安全漏洞。** 前置最好先读 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md) 建立"流水线 + 投机执行"的框架。


> 它是微架构演进链的"⑤分支预测+投机"环节（解决④乱序执行遇到分支就卡的控制冒险）——整条链的总纲见 [cpu-microarch-overview.md](/concepts/microarch/cpu-microarch-overview.md)。

## 前置知识：术语速查

后文大量出现这些词，在此集中解释，省得读到一半来回翻。

### 0.1 jcc 是什么

`jcc` = **Jump if Condition is met**，x86 条件跳转指令族的统称。不是一条指令，是一组：

| 指令 | 全称 | C 等价 |
|------|------|--------|
| `je` / `jne` | Jump if Equal / Not Equal | `if (a == b)` / `if (a != b)` |
| `jg` / `jl` | Jump if Greater / Less | `if (a > b)` / `if (a < b)` |
| `jge` / `jle` | Jump if Greater or Equal / Less or Equal | `if (a >= b)` / `if (a <= b)` |
| `ja` / `jb` | Jump if Above / Below | `if (a > b)` / `if (a < b)`（无符号） |

**你写的每一条 if/for/while，最终都编译成 `cmp + jcc` 指令对**：

```asm
; C: if (x > 0) foo(); else bar();
    cmp    $0, %eax          ; 比较 x 和 0
    jle    .else_label       ; x <= 0 → 跳到 else 分支（jcc 指令）
    call   foo               ; x > 0 路径（预测"不跳"走这里）
    jmp    .end
.else_label:
    call   bar               ; x <= 0 路径（预测"跳"走这里）
.end:
```

**jcc 是分支预测器盯着的物理对象**。预测器不关心"这段 C 代码有没有 if"——它只在 fetch 阶段看到 jcc 指令时工作：猜方向（跳 / 不跳）+ 猜目标（跳去哪）。后文所有"分支预测"的讨论，本质上都是"CPU 拿到一条 jcc 时怎么决策"。

### 0.2 µop（微操作）：流水线里的真正"指令"

x86 指令是 CISC 风格——一条 `add (%rsi), %rax` 同时做了读内存和加法。现代 CPU 内部会把每条 x86 指令**译码成 1~N 条 µop**（micro-operation），µop 是 RISC 风格的简单操作（一次读、一次算、一次写）。在流水线里真正被调度、发射、执行的是 **µop，不是你写的 x86 指令**。

| 概念 | 谁产生的 | 流水线里跑的是它吗 | 举例 |
|------|---------|:---:|------|
| x86 指令 | 编译器输出 | 否 | `add (%rsi), %rax`（1 条） |
| µop | Decoder 译码 | **是** | load µop + add µop（2 条） |

> **记住**：后文说"一条指令在流水线里…"时，绝大多数时候指的是 µop。`cmp + jcc` 被译码成 1 条 CMP+JCC 融合 µop，就是因为它被 macro-fusion 从 2 条变 1 条了。

### 0.3 macro-fusion（宏融合）：为什么 cmp+jcc 是一条 µop

现代 Intel CPU（Core 2 以来）和 AMD Zen 会把连续的 `cmp + jcc` **熔成一条 µop**（"比较并跳转"融合 µop），省一条 µop 的译码带宽。但它仍然是分支 µop：仍然查 BTB、仍然走方向预测、仍然在分支端口执行——**融合省的是 µop 槽位，不省预测逻辑**。

```plantuml
@startuml
skinparam shadowing false
rectangle "cmp $0, %eax" as CMP
rectangle "jle .else" as JCC
CMP -right-> JCC : 两条 x86 指令
rectangle "CMP+JCC 融合 µop ×1" as FUSED #C8E6C9
CMP -down-> FUSED
JCC -down-> FUSED
note right of FUSED : 省一条 µop 带宽\n但分支预测流程不省
@enduml
```

### 0.4 分支预测相关的核心硬件

| 缩写 | 全称 | 一句话 | 在哪级 |
|------|------|--------|:--:|
| **BTB** | Branch Target Buffer | 缓存"指令地址 → 上次跳到的目标地址"，专门猜**跳去哪** | 取指 |
| **BHT** | Branch History Table | 记录分支的"历史行为"（最近几次跳没跳），为方向预测器提供输入 | 取指 |
| **RSB** | Return Stack Buffer | 专用硬件栈：`call` 时压入返回地址，`ret` 时弹出预测——专门优化函数返回 | 取指 |
| **taken** | — | 分支"**跳**"——下一条指令在跳转目标地址 | — |
| **not-taken** | — | 分支"**不跳**"——下一条指令顺序往下走（fall-through） | — |
| **IP** | Instruction Pointer | 指令指针（x86 里叫 `RIP`/`EIP`），指向**当前正在被 fetch 的指令地址**。预测器用 IP 直接索引 BTB/BHT | — |

### 0.5 乱序执行核心结构

| 缩写 | 全称 | 一句话 | 在哪级 |
|------|------|--------|:--:|
| **ROB** | Reorder Buffer | 环形队列，记录所有"正在飞"的 µop，保证乱序执行后**顺序退休**（见 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md)） | 重命名→退休 |
| **RS** | Reservation Station（发射队列） | 存放已译码、待发射的 µop——操作数一就绪就发到执行端口 | 发射 |

### 0.6 性能观测缩写

| 缩写 | 全称 | 一句话 |
|------|------|--------|
| **IPC** | Instructions Per Cycle | 每周期退休的指令数，<1 说明流水线在空转 |
| **PMU** | Performance Monitoring Unit | CPU 内部的硬件计数器，`perf stat` 读的就是它 |
| **I-cache** | Instruction Cache (L1i) | L1 指令缓存，存的是**要执行的指令**，与存数据的 D-cache 分开 |
| **iTLB** | Instruction TLB | 指令侧的 TLB，缓存虚拟地址→物理地址翻译，I-cache miss 可能连带 iTLB miss |
| **D-cache** | Data Cache (L1d) | L1 数据缓存，存的是 load/store 的数据

## 一、为什么非猜不可：流水线的宿命

现代 CPU 是**深流水线**：一条指令要经过取指→译码→重命名→发射→执行→退休十几到二十几级(stage)，每级一拍。这样才能让多条指令重叠、把吞吐顶上去(见 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md))。

问题出在**条件分支**(`if`、循环判断、`switch`)：

```c
if (x > 0)   // ← 这一步的比较结果,要好几拍后才算出来
    foo();   // 该取这里的指令?
else
    bar();   // 还是这里?
```

- CPU 的**前端(取指)** 必须**每拍都喂新指令**进流水线，否则后面十几级全空转(叫 pipeline bubble，气泡)。
- 但 `x > 0` 的结果要等到**执行级**才算出来——那是好几拍甚至几十拍之后(若 `x` 还要从内存 load，更晚)。
- **前端等不起**：如果每遇到分支就停下来等结果，深流水线的收益全没了，退化成挤牙膏。

### 1.0 取指不能停：下一拍就要下一条指令

CPU 前端（fetch）的节奏是**每拍取一批指令**（典型 16B）。当它取到一条 `jcc` 时：

- 下一拍取指时钟照常打到——**必须立刻决定从哪个地址取**，没有"等我看看 jcc 方向"的选项。
- 等 jcc 方向算出？那要 5~10 拍之后——这段等待期内取指停掉，流水线就断了。

```plantuml
@startuml
skinparam shadowing false
title 取指不能停：下一拍就要下一条指令

participant "取指\n(fetch)" as F
participant "译码\n(decode)" as D
participant "执行\n(execute)" as E

== 拍 0：取到 jcc ==
F -> F : 取到 jcc 指令
note right of F #FFF9C4 : 下一拍必须立刻决定从哪取

== 拍 1：沿预测方向取指 ==
F -> F #C8E6C9 : 按预测方向取 foo() 第一条
F -> D : 送 jcc 进译码
note over F, D #C8E6C9 : ★ 取指没等 jcc 方向

== 拍 2~3：投机执行 ==
F -> F #C8E6C9 : 取 foo 下一条、bar 下一条……
D -> D #C8E6C9 : 译 foo 的指令们
D -> E #FFF9C4 : 译完的指令在等发射
note over F, E #C8E6C9 : 这些全是**投机**的——方向还没确认

== 拍 4~5：jcc 真正算出方向 ==
E -> E #FFCDD2 : cmp 算出真实方向，对比 vs 预测

alt 猜错
  E -> F #FFCDD2 : 冲刷 + 重定向
  F -> F #FFCDD2 : 清空所有投机指令
  note over F, E #FFCDD2 : 约 15~20 拍空洞期
else 猜对
  note over F, E #C8E6C9 : 投机指令正常继续退休
end

@enduml
```

**这张图的核心**：

| 时刻 | 发生的事 | 为什么必须"猜" |
|------|---------|-------------|
| 拍 0 | 前端取到 `jcc` | — |
| **拍 1** | 前端**立刻**按预测方向取了下一条（`foo()` 的第一条指令） | **取指每拍都要产出 16B**——不等 jcc 方向，也等不起 |
| 拍 1~3 | 预测路径上的指令源源不断进入译码、发射——这些全是**投机**的（方向还没确认） | 如果等确认再取指，这段时间取指全空转（pipeline bubble） |
| 拍 3~5 | jcc 方向终于算出 → 与预测对比 | 此时已经有 ~3 条投机指令在飞 |
| 拍 5~7+ | 若猜错：flush 投机指令 + 重定向取指 → 15~20 拍空洞 | 惩罚 ≈ 流水线深度 |

> **核心动机**：深流水线要求前端**永不停顿**地喂指令，但分支方向要很晚才知道。于是 CPU **赌**——预测一个方向，立刻沿着它投机执行。**赌对了，分支几乎零成本；赌错了，把猜错这条路上做的一切回滚重来。** 分支预测器的全部工作，就是让这个"赌"尽可能准（现代能到 **95%~99%**）。

### 1.1 流水线视角：跟踪一条 jcc 的完整旅程

把上面两张图串联起来——下面跟踪 `cmp $0, %eax; jle .else` 这组 `cmp + jcc` 在 Intel Skylake 风格流水线里的**逐级行为**，看分支预测在哪一级介入、在哪一级兑现。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
}
title 一条 jcc 在流水线中的完整旅程（Skylake 风格）

participant "L1I\n(指令缓存)" as L1I
participant "预译码\n(pre-decode)" as PRE
participant "译码\n(decode)" as DEC
participant "重命名\n(rename/alloc)" as REN
participant "发射队列\n(scheduler)" as SCH
participant "执行\n(execute)" as EXE
participant "退休\n(retire)" as RET

== 阶段 1：取指（前端） ==
L1I -> PRE : 取 16B 指令块，内含 cmp+jcc
PRE -> PRE : 标记指令边界\n(decoder 需要知道每条指令的起止)
note over PRE #FFF9C4 : **★ 分支预测在此介入**
PRE -> PRE : ① BTB 查表：这个 IP 见过吗？\n记录的目标地址是什么？
PRE -> PRE : ② 方向预测器（BHT/TAGE）：\n猜 taken 还是 not-taken
PRE -> DEC : 若预测 taken → 立刻**重定向 fetch 到目标地址**\n若预测 not-taken → 继续顺序取指

== 阶段 2：译码 ==
DEC -> DEC : cmp+jcc 被 macro-fusion 熔成\n**一条 CMP+JCC 融合 µop**
note right of DEC : 融合省一条 µop 槽位\n但不省预测/执行逻辑

== 阶段 3：重命名 + 分配 ==
REN -> REN : 融合 µop 进入 ROB(分配槽位)\n目的寄存器被重命名
note over REN #FFCDD2 : 后续指令沿预测方向\n**投机**进入 ROB/RS

== 阶段 4：发射 ==
SCH -> EXE : 融合 µop 发射到**分支执行端口**\n(Skylake: Port 6)
note right of SCH : 与此同时，预测路径上的\n独立指令也在各端口执行

== 阶段 5：执行 + 分支解析 ==
EXE -> EXE : 计算 cmp 真实结果\n比较实际方向 vs 预测方向

alt 预测正确
  EXE -> RET : 投机指令正常退休\n（分支几乎零额外成本）
else 预测错误
  EXE -> PRE : **冲刷流水线（flush）**
  note over PRE, SCH #FFCDD2 : ROB 中该 jcc 之后的所有 µop 全部作废\n前端重定向到正确地址重新取指\n→ 15~20 拍空转
end
@enduml
```

**逐级详解**：

| 流水级 | 对 jcc 做了什么 | 与普通指令的区别 |
|--------|----------------|-----------------|
| **取指 (fetch)** | BTB 查目标地址 + 方向预测器猜 taken/not-taken → **决定下一条取指的地址** | 普通指令：顺序取指，不查 BTB |
| **预译码 (pre-decode)** | 标记指令边界，为 decoder 准备 | 无区别 |
| **译码 (decode)** | `cmp + jcc` → **macro-fusion 熔成一条 CMP+JCC µop**（省一个 µop 槽） | 普通指令：一对一或一对多译码 |
| **重命名 (rename)** | 融合 µop 分配 ROB 槽位；**预测路径上的后续指令投机进入 ROB** | 普通指令：只在确认路径上进 ROB |
| **发射 (dispatch)** | µop 被发到**分支端口**（Skylake Port 6）等待执行 | 普通 ALU 指令走 Port 0/1/5 |
| **执行 (execute)** | 计算 cmp 结果，**对比真实方向 vs 预测方向** | 普通指令：只算结果，不对比 |
| **退休 (retire)** | 预测正确 → 正常退休；**预测错误 → 触发 nuke/flush，清空后续所有投机 µop** | 普通指令：正常退休，不会触发 nuke |

**几个关键细节**：

1. **预测发生在取指阶段，不是执行阶段**——jcc 还没译码，方向就已经猜完了。预测器和 jcc 之间没有"先译码再猜"的因果关系——**用 IP（指令地址）直接索引预测表**。

2. **macro-fusion 让 `cmp+jcc` 变成一条 µop 在流水线里跑**，但它仍然是分支 µop：仍然查 BTB、仍然走方向预测、仍然在分支端口执行。融合省的是**译码带宽**（原本两个 µop 槽现在一个），不省分支预测本身。

3. **"投机"的本质**：CPU 在还不知道 jcc 真实方向的情况下，就已经让预测路径上的几十条指令进了 ROB、占了 RS、甚至执行完了。这些指令在退休前都带着"待确认"标记——一旦预测错误，全部作废。

4. **为什么惩罚是 15~20 拍**：从执行级发现错误 → 发 flush 信号 → 前端重新取指 → 新指令灌满流水线到退休，这段"空洞期"的长度 ≈ 流水线深度。这 15~20 拍里，执行端口全在空转。

> **一句话**：jcc 在流水线里的特殊之处不是"它本身跑得慢"，而是**它控制着前端往哪取指**——这个决策必须提前做出（取指阶段等不起），所以 CPU 在没看清方向时就猜、猜错就全盘推倒重来。

## 二、要猜两件事：跳不跳、跳去哪

一条分支其实要预测**两个**独立的问题：

| 预测什么 | 问题 | 负责的部件 |
|---------|------|-----------|
| **方向(direction)** | 这个条件分支**跳还是不跳**(taken / not-taken) | 方向预测器(BHT / 两位计数器 / TAGE) |
| **目标(target)** | 如果跳,**跳到哪个地址** | **BTB**(Branch Target Buffer,分支目标缓冲) |

- 对 `if/for/while` 这种条件分支，主要难在**方向**。
- 对 `switch`、函数指针、虚函数调用(**间接分支** indirect branch)，目标地址本身就不定——难在**目标**，由 BTB(以及更专门的 indirect branch predictor)负责。
- 函数**返回**有个专门的 **RSB**(Return Stack Buffer)：调用时压入返回地址、返回时弹出预测，专门优化 `call/ret` 配对。

下面重点讲最经典、也最能建立直觉的**方向预测**。

## 三、方向预测器怎么工作:从"上次怎样"到 TAGE

### 3.1 最朴素:一位预测(上次跳这次就跳)

给每个分支记**一位**：上次 taken 就预测 taken。问题:循环边界会**连错两次**。一个跑 100 次的 `for`：

- 最后一次(第 100 次)退出循环 → not-taken，但预测器记的是"上次 taken" → **猜错**；
- 下次再进这个循环，第一次判断，预测器记的是"上次(退出那次) not-taken" → 又**猜错**。

每轮循环错两次，太蠢。

### 3.2 经典:两位饱和计数器(要连错两次才改主意)

给每个分支一个 **2 位状态机**(0~3)，taken 就 +1、not-taken 就 -1(饱和不溢出)。**高位决定预测**：≥2 预测 taken，<2 预测 not-taken。

```plantuml
@startuml
skinparam shadowing false
state "强不跳 00" as SN
state "弱不跳 01" as WN
state "弱跳 10" as WT
state "强跳 11" as ST
[*] --> SN
SN --> WN : taken
WN --> SN : not-taken
WN --> WT : taken
WT --> WN : not-taken
WT --> ST : taken
ST --> WT : not-taken
ST --> ST : taken
SN --> SN : not-taken
note bottom of ST : 预测"跳";要连续\n错两次才退到"不跳"
note bottom of SN : 预测"不跳"
@enduml
```

**妙处**:一次反常不改主意，要**连错两次**才翻转。那个跑 100 次的循环：99 次 taken 让计数器牢牢停在"强跳"，最后一次退出错一次、但不翻主意 → **每轮只错 1 次**(退出那次)，比一位预测好一倍。这就是教科书级的 **2-bit saturating counter**。

### 3.3 进阶:带历史(同一个分支,看它前面几次的模式)

两位计数器只看"这个分支自己的多数票"，但很多分支是**有规律的模式**，比如 `TNTNTN...`(隔一次跳一次)——多数票是 50%，两位计数器束手无策。**历史预测**记录最近 N 次分支的 taken/not-taken 序列(**分支历史寄存器 BHR**)，用"历史模式"去索引不同的计数器：

- **局部历史**:看**这一个分支**自己最近几次的模式(能学会 `TNTN` 规律)。
- **全局历史**:看**最近所有分支**的综合模式(能学会分支间的关联，如 `if(a) ... if(a&&b)`，第二个 if 和第一个相关)。

### 3.4 现代:TAGE 等混合预测器

真实 CPU(Intel/AMD 近十年)用的是 **TAGE**(TAgged GEometric history length)之类的**多表混合预测器**：

- 同时维护**多张表**，各用**不同长度的历史**(几位到几百位)去索引；
- 对每个分支，**用能匹配上的最长历史那张表**的预测(长历史命中说明找到了强规律)；
- 配合**感知机(perceptron)** 预测器等，综合多个特征。

结果:现代分支预测准确率 **95%~99%+**，规律性强的代码几乎 100%。**你不用懂 TAGE 的细节，只需记住:预测器非常强,能学会相当复杂的模式——真正让它抓瞎的是"没有模式"(数据相关的随机分支)。**

> **一句话**：预测器的进化史 = "用越来越丰富的上下文(自己的多数票 → 自己的模式 → 全局关联 → 多长度历史混合)去猜方向"。它强到:**只要分支有规律，基本都能学会；治不了的只有真随机。**

## 四、猜错的代价:清空流水线,几十拍打水漂

预测对了几乎零成本。**预测错(branch misprediction)** 的代价是这样的：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "前端取指" as F
participant "流水线\n(十几级)" as P
participant "执行级\n(算出真方向)" as E
F -> P : 按**预测方向**取指,\n源源不断投机执行后续指令
P -> E : (几拍~几十拍后)\n分支条件终于算出
E -> E : 发现:**预测错了!**
note over F, E #FFCDD2
  猜错这条路上已进流水线的
  所有指令(可能几十条)全部**作废**
  → 清空流水线(flush)
  → 从**正确**方向重新取指、重新灌满流水线
end note
E -> F : 冲刷 + 重新取指
note over F : 这段时间前端在"重新装填",\n没有有效指令退休 → 白等
@enduml
```

**代价量化**:

- 误预测惩罚 ≈ **流水线深度**，现代 x86 约 **15~20 拍**(有的微架构更高)。
- 15~20 拍 ≈ 够执行几十条简单指令的时间，全打了水漂。
- 更糟的是它**打断了乱序引擎的节奏**：ROB 里投机的指令全清、执行端口空转。

**放到实际**:一个预测准确率 95% 的分支，5% 的误预测率，若这分支在热循环里每次都执行，那平均每 20 次就吃一次 ~18 拍的惩罚——**足以让一段看起来 O(n) 的循环慢一倍**。这就是"算法复杂度一样，性能差一截"的常见隐藏原因之一(另一个是 [tlb.md](/concepts/cache/tlb.md) 的 TLB miss、[mesi.md](/concepts/cache/mesi.md) 的伪共享)。

## 五、什么样的分支难猜:数据相关的"随机"分支

预测器怕的不是"分支多"，而是"**分支方向没有规律**"——尤其是**方向取决于数据、而数据是随机的**。经典例子:

```c
// 对一个数组求和,只加大于阈值的元素
for (int i = 0; i < N; i++)
    if (data[i] >= 128)   // ← 这个分支好不好猜,取决于 data 的分布
        sum += data[i];
```

- **data 已排序**:分支方向是"连续一大片 false，然后连续一大片 true"——**极有规律，预测器几乎 100% 命中**，飞快。
- **data 随机**:分支方向像抛硬币，**预测器无从学起，误预测率逼近 50%**——同样的代码、同样的数据量，能**慢好几倍**。

> 这就是 StackOverflow 上那个著名问题 *"Why is processing a sorted array faster than an unsorted array?"* 的答案:**不是 cache、不是别的，就是分支预测**。排序让分支变得可预测。

## 六、怎么写对预测器友好的代码

| 手段 | 原理 |
|------|------|
| **让分支可预测** | 数据能排序/分组就排(把 true 的聚一起)，让方向连续成片 |
| **用无分支代码(branchless)** | 把 `if` 改写成算术/位运算/`cmov`(条件移动指令)，**根本不产生分支**，也就无从误预测。如 `sum += (data[i]>=128) * data[i]` 或 `max = a>b?a:b` 编译成 `cmov` |
| **`__builtin_expect` / `[[likely]]`** | 告诉编译器哪条路是热路径，让它把热路径排在 fall-through、优化布局(帮助的是**代码布局和 I-cache**，现代动态预测器其实不太靠这个静态提示) |
| **减少间接跳转** | 虚函数、函数指针、大 `switch` 是 BTB 的负担；热路径上能去虚化(devirtualize)就去 |
| **循环展开** | 减少循环边界分支的占比 |

**但别过度**:

- 分支预测准确率本就 95%+，**大多数分支不值得优化**——只有 profiler(`perf`)指出误预测热点时才动手。
- branchless 不总是更快:如果分支其实很好预测(可预测的 if)，改成 cmov 反而可能更慢(cmov 有数据依赖、且两条路都算)。**要测,别凭感觉。**

## 七、投机执行的阴暗面:Spectre

分支预测/投机执行本是纯性能优化、**架构层面透明**(猜错会回滚，不改变程序结果——见 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md) 的"顺序退休")。但 2018 年的 **Spectre** 揭示:投机执行虽然会**回滚架构状态(寄存器、内存)**，却**回滚不了微架构副作用**——尤其是**留在缓存里的痕迹**。

- 攻击者训练预测器猜某个方向 → CPU 投机执行了**本不该执行**的指令(如越界读) → 虽然结果被回滚，但被越界读的数据**已经把某条 cache line 加载进了缓存**；
- 攻击者再用**缓存计时侧信道**(测哪条 line 变快了)反推出那个"本不该被读到"的秘密值。

> **要点**:投机执行"回滚得了架构状态，回滚不了缓存痕迹"——这道缝隙就是 Spectre。缓解手段(retpoline、屏障指令、微码更新)大多以**牺牲部分投机/预测性能**为代价,这也是这些漏洞影响深远的原因。细节超出本仓库范围,这里只点出"投机执行 × 缓存侧信道"这个交叉点。

## 八、怎么观测:perf 看误预测及其连锁反应

### 8.1 直接指标：branch-misses

分支和误预测有专门的计数器(见 [../code/perf.md](/tools/code/perf.md)):

```bash
# 分支总数 vs 误预测数,直接算误预测率
perf stat -e branches,branch-misses ./app
#   branch-misses / branches 就是误预测率;热点代码里 >5% 就值得看
# 定位是哪段代码的分支在误预测
perf record -e branch-misses:ppu ./app && perf report
# 结合 IPC 看:误预测高通常伴随 IPC 偏低(流水线频繁清空)
perf stat -e cycles,instructions,branch-misses ./app
```

判据:**branch-misses 占 branches 的比例高(如 >5%~10%)、且集中在某个热点函数,就是分支预测瓶颈**——考虑排序数据、branchless 改写、或去虚化。

### 8.2 连锁反应指标：误预测引发的次生灾害

但只看 `branch-misses` 不够——**一次分支误预测会触发链式次生性能损失**，这些损失由其他 PMU 事件反映：

```bash
一次分支误预测 → 流水线 flush
                    ↓
              CPU 重定向到正确地址取指
                    ↓
          ┌─ 那个地址的指令在 I-cache 里吗？
          │   ↓ 不在 → L1-icache-load-misses ↑
          │               ↓
          │         从 L2/L3/DRAM 重取指令
          │
          └─ 那个地址的页表翻译在 iTLB 里吗？
              ↓ 不在 → iTLB-load-misses ↑
                         ↓
                    page walk（4 次内存访问）
```

**也就是说，`branch-misses` 高的时候，以下三个指标应该同时抬头**：

```bash
# 一次命令看全局：分支误预测 + 它的连锁反应
perf stat -e cycles,instructions,\
branch-instructions,branch-misses,\
L1-icache-load-misses,\
iTLB-load-misses,\
stalled-cycles-frontend,stalled-cycles-backend \
./app
```

| 指标 | 含义 | 与分支误预测的关系 | 正常值 |
|------|------|-------------------|:--:|
| `branch-misses / branch-instructions` | 分支预测失败率 | **直接指标** | <5% |
| `L1-icache-load-misses` | 指令缓存未命中 | 误预测后跳到新地址，该地址大概率不在 I-cache → 触发 I-cache miss | 越低越好 |
| `iTLB-load-misses` | 指令 TLB 未命中 | 跳到一个新页，iTLB 没有对应条目 → page walk | 越低越好 |
| `stalled-cycles-frontend` | 前端停顿周期 | **综合指标**：I-cache miss + iTLB miss + 分支误预测 的总和 | <30% |
| `stalled-cycles-backend` | 后端停顿周期 | 数据侧瓶颈，与分支预测**间接相关**（误预测导致执行了本不该执行的 load，可能污染 cache） | <30% |
| IPC (`instructions / cycles`) | 每周期指令数 | 上述所有停顿的最终后果——前端喂不饱、后端等数据，都拉低 IPC | >1 |

### 8.3 诊断流程：从现象到根因

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}

start
:IPC 偏低（<1）;

if (stalled-cycles-frontend > 30%?) then (是：瓶颈在前端)
  :取指喂不饱流水线;
  if (branch-misses > 5%?) then (是)
    :**根因：分支预测失败过多**\n→ perf record -e branch-misses:ppu 定位函数;
    if (数据能排序?) then (是)
      :排序数据，让分支可预测;
    else (否)
      :考虑 branchless 改写;
    endif
  else (否：分支预测还行)
    if (L1-icache-load-misses 高?) then (是)
      :**根因：I-cache miss**\n→ 代码膨胀/布局差，见 i-cache.md;
    else (否)
      if (iTLB-load-misses 高?) then (是)
        :**根因：iTLB miss**\n→ 大二进制/代码页碎片化，见 i-cache.md;
      else (否)
        :其他前端问题（decoder 吞吐等）;
      endif
    endif
  endif
else (否：瓶颈在后端)
  :数据等不来（D-cache miss / 内存延迟 / 依赖链）;
  :见 cache-organization.md / perf.md;
endif

stop
@enduml
```

### 8.4 定位热点函数的三层 drill-down

从宏观到微观，层层收敛：

```bash
# 第一层：stat 看全局有没有问题
perf stat -e cycles,instructions,branch-misses,stalled-cycles-frontend ./app
# → IPC < 1 + frontend stall > 30% + branch-misses > 5% → 进入第二层

# 第二层：record + report 看哪个函数在 miss
perf record -e branch-misses:ppu -g ./app
perf report --stdio
# → 找到 branch-misses 占比最高的函数（通常 90%+ 集中在一个函数）

# 第三层：annotate 看哪条 jcc 在 miss
perf annotate
# → 定位到具体的 cmp + jle / cmp + jne 指令，看 Percent 列
```

### 8.5 间接分支：虚函数和函数指针

```bash
# 间接分支的预测（虚函数调用、函数指针、switch 跳转表）
perf stat -e branch-loads,branch-load-misses ./app
```

`branch-loads` / `branch-load-misses` 专门统计**间接分支**（`jmp *%rax` / `call *%rax`）。C++ 虚函数调用、函数指针、大 switch 跳转表都算间接分支。**间接分支预测失败率比条件分支（jcc）更难优化**——jcc 只需要猜方向，间接分支还要猜目标地址。

> **深入阅读**：[indirect-branch-prediction.md](/concepts/microarch/indirect-branch-prediction.md)——间接分支预测完整专题：ITTAGE 预测器原理、单态/双态/巨态虚函数对比、switch 跳转表、去虚化手段、Spectre v2 (BTI) 与 retpoline。
>
> **实验代码**：[indirect-branch demo](/demos/indirect-branch/README.md)——对比直接调用 vs 单态/双态/巨态虚函数、函数指针、switch 跳转表六种场景。`make perf-branch-loads` 直接看 `branch-loads` / `branch-load-misses`。

### 8.6 完整指标速查

| 我想知道... | 用这个 |
|------------|--------|
| 分支预测失败率 | `perf stat -e branches,branch-misses ./app` |
| 哪个函数在 miss | `perf record -e branch-misses:ppu -g ./app && perf report` |
| 哪条 jcc 在 miss | `perf annotate`（在 report 中进入热点函数后） |
| 间接分支预测 | `perf stat -e branch-loads,branch-load-misses ./app` |
| 是取指瓶颈还是数据瓶颈 | `perf stat -e stalled-cycles-frontend,stalled-cycles-backend ./app` |
| I-cache 是否被牵连 | `perf stat -e L1-icache-load-misses ./app` |
| iTLB 是否被牵连 | `perf stat -e iTLB-load-misses ./app` |
| 全局综合诊断 | `perf stat -e cycles,instructions,branch-misses,L1-icache-load-misses,iTLB-load-misses,stalled-cycles-frontend ./app` |

> **核心思路**：`branch-misses` 是直接指标，但它会触发 I-cache miss 和 iTLB miss 的链式反应——这三个指标叠加才等于 `stalled-cycles-frontend`。所以诊断时不要孤立看 branch-misses，要看 **branch-misses + I-cache + iTLB → frontend stall** 这条完整的因果链。

## 九、和本仓库其他文档的关系

- **前置/同层**:[cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md)——分支预测是**投机执行**的方向盘,误预测触发的正是那里讲的"ROB 清空重放"。间接分支的深入讨论见 [indirect-branch-prediction.md](/concepts/microarch/indirect-branch-prediction.md)。两篇都属核内微架构(单核性能引擎),与多核内存序正交(见 [reordering-overview.md](/concepts/memory-ordering/reordering-overview.md))。
- **同类性能陷阱**:[tlb.md](/concepts/cache/tlb.md)(TLB miss)、[mesi.md](/concepts/cache/mesi.md)(伪共享)、[cache-organization.md](/concepts/cache/cache-organization.md)(cache miss)——都是"算法复杂度看不出、profiler 才现形"的隐藏成本;分支误预测是其中之一。
- **观测**:[../code/perf.md](/tools/code/perf.md)(branch-misses、branch-load-misses 等事件)。

## 十、一句话总结

> **深流水线要求前端永不停顿地喂指令,但条件分支的方向要很晚才算出来——于是 CPU"赌"一个方向、立刻投机执行,靠分支预测器(BTB 猜目标、两位计数器/历史/TAGE 猜方向)把准确率做到 95%~99%。赌对几乎零成本,赌错要清空流水线、几十条投机指令作废、付 ~15~20 拍惩罚。预测器怕的不是分支多,而是"方向随机没规律"(数据相关的分支,排序数组比乱序快就是这个原因)。优化靠让分支可预测或 branchless 改写,但先用 perf 的 branch-misses 定位、别凭感觉。它纯属性能优化、架构透明——唯一的例外是投机回滚不了缓存痕迹,那道缝隙催生了 Spectre。**

