store buffer / load buffer 的生命周期:队列型部件的完整故事
这是 uop-pipeline-walkthrough(µop 级流水线走读总纲)的拆分篇之一,专门讲"三条案例之外的队列型部件:store buffer / load buffer 怎么运转、线程切换时流水线里的半成品怎么办"。配套拆分篇:µop 类型与切分 · 关键机制 · 三条指令逐拍走读 · 三案对比。
从三个案例可以提炼出 store buffer 和 load buffer 的完整生命周期。
Store Buffer 槽位生命周期
![Store Buffer 槽位 #7 生命周期 —— mov [rbx], eax 的 STA + STD 异步填充 Store Buffer 槽位 #7 生命周期 —— mov [rbx], eax 的 STA + STD 异步填充](/plantuml/ed24a10295973811c8c63be0fb5c50f36926d9a3.png)
Load Buffer 槽位生命周期
![Load Buffer 槽位生命周期 —— add eax, [rsi+8] 的 LD µop 全路径(第 2 张) Load Buffer 槽位生命周期 —— add eax, [rsi+8] 的 LD µop 全路径(第 2 张)](/plantuml/8a64019e37bae0144649f7696c3a5d0384fe0f39.png)
两种 buffer 的本质差异
两个序列图揭示了 store buffer 与 load buffer 的本质差异——虽然都叫 "buffer",生命周期形态却截然不同。
Store Buffer:为"解耦退休与提交"而设计
store 指令的核心矛盾:x86 的 TSO 内存模型要求 store 对本核立即可见(store→load forwarding 让本核后续 load 读到刚写的值),对其他核却要等退休后才全局可见。若退休后直接写 L1,L1 miss 时整个流水线就会卡在退休阶段——对吞吐是灾难性的。
Store Buffer 用"退休标记可提交 → LSU 异步排空"的两阶段设计解耦了这个矛盾:
| 阶段 | 触发条件 | 做什么 | 对流水线的影响 |
|---|---|---|---|
| 退休标记 | STA + STD 都退休 | 槽位地址+数据齐备,标记"可提交" | 同拍完成,不阻塞退休 |
| LSU 仲裁 & 提交 | LSU 选中该槽位,L1 可写 | 数据写入 L1 D-Cache,槽位释放 | 退休之后异步进行,流水线完全透明 |
关键设计:STA 和 STD 独立发射——地址先就绪就先写地址域,数据后到后填数据域,同一槽位的两个字段互不阻塞。案例一的
mov [rbx], eax因此零额外延迟:两条 µop 同拍发射,退休后由 LSU 挑时间点把数据刷进 L1。
但异步排空也有代价——SB 槽位是物理硬件资源:
- 槽位满了:前端停止取指译码(store µop 在 REN 阶段分不到槽位 → stall)
- 合并写:LSU 仲裁优先写同一 cache line 的数据,减少 L1 的 RFO 开销
- WC 批量提交:写合并缓冲区是 SB 的兄弟结构——突发写入攒够 64 字节一口气写(PCIe 映射内存的关键优化)
Load Buffer:短寿命的流水暂存器
load buffer 没有"退休→仲裁→提交"的异步尾巴——结果在 WB 阶段写入 PRF,槽位当场释放。它存在的意义,是以下三个"时间窗口"需要暂存请求状态:
| 窗口 | 暂存内容 | 用途 |
|---|---|---|
| TLB→SB→L1 流水期间 | VA + 请求状态 | 记录"正在处理中",一拍的串行查询需要占位 |
| L1 miss→L2/L3 期间 | PA + LFB/MSHR 索引 | L2 返回后找到对应的 PRF 目标写回 |
| 内存序违规检测 | VA + 年龄(ROB 位置) | younger load 先于 older store 执行时留痕,等 store 地址出来做冲突检测 |
Store Buffer vs Load Buffer:生命周期差异的本质原因
| 维度 | Store Buffer | Load Buffer | 根源 |
|---|---|---|---|
| 分配时机 | REN 阶段(前端) | EX 阶段(AGU 算完地址) | store 地址早分、数据晚到需提前占位;load 先算地址后占位 |
| 释放时机 | LSU 提交到 L1 后 | WB 写 PRF 后立即 | store 等退休+仲裁;load 进 PRF 即完成 |
| 生命周期 | 6~N 拍(异步尾巴不定) | 3 拍(固定) | store 提交与流水线异步;load 全在流水线内 |
| 满的后果 | REN 阶段 stall(前端停摆) | EX 阶段 stall(RS 不再发射 load) | 分配阶段不同 |
| 硬件容量 | ~40—60 条 | ~10—12 条(含 LFB/MSHR) | SB 需覆盖 L1 miss 延迟;LB 寿命短不需大容量 |
一句话:Store Buffer 是"退休后的异步队列",Load Buffer 是"执行中的流水暂存器"——一个连接退休层与 L1,一个连接执行层与 L1。
LSU、Store Buffer、Load Buffer 的层级关系
这三个概念容易搞混——有的说"LSU 管理 store buffer",有的说"store buffer 是 LSU 的一部分",其实它们是容器—子模块—职责的嵌套结构:

LSU 是容器,Store Buffer 和 Load Buffer 是它的两个核心子模块,AGU 是它的地址生成前端,LFB/MSHR 是它的 miss 跟踪后端。
三者的职责边界如下:
| 组件 | 在 LSU 中的角色 | 核心职责 | 不负责什么 |
|---|---|---|---|
| LSU | 整体容器 + 控制逻辑 | 协调所有访存 µop 的执行、排队、转发;管理 store buffer 的仲裁排空、load buffer 的 miss 跟踪、store→load forwarding 的地址比对 | 不执行非访存 µop(ALU/FMA 不走 LSU) |
| Store Buffer | 写入侧缓冲 | 暂存 store 的地址和数据;退休后等 LSU 仲裁排空到 L1;为 load 提供 forwarding 数据 | 不主动写 L1(LSU 仲裁决定时机);不处理 load miss |
| Load Buffer | 读取侧暂存 + miss 跟踪 | 暂存进行中的 load 请求状态;记录 L1 miss 的 LFB 索引;参与内存序违规检测 | 不缓存数据(数据直接进 PRF);不参与 store 提交 |
| AGU | 地址生成 | 算 VA(base + index×scale + offset),送给 TLB 翻译 | 不访问内存(AGU 只算地址,LSU 拿地址去访问) |
| LFB/MSHR | miss 状态跟踪 | 记录每条 L1 miss 的 PA + 目标 PRF tag;L2 返回后路由到正确的 PRF | 不缓存数据本身 |
数据流角度:store 和 load 穿过 LSU 的路径完全不同:
- store 路径:
AGU 算地址 → SB 槽位(地址域+数据域)→ ROB 退休标记可提交 → LSU 仲裁 → L1 - load 路径:
AGU 算地址 → TLB 翻译 → SB forwarding 检查 → L1 查询 → 命中则 PRF 写回;miss 则由 LFB 跟踪,L2 返回后再写回 PRF
LSU 是内存 µop 的"交通调度中心":AGU 是入口,Store Buffer 是出城暂存区(等退休后正式出城),Load Buffer + LFB 是进城快递追踪号,控制逻辑负责仲裁排空顺序、store→load forwarding 地址比对、内存序违规检测(memory disambiguation)。
线程切换时,流水线里的"半成品"怎么办
线程切换(时钟中断触发抢占)时,CPU 正投机执行线程 A 的指令,突然要停下来去跑线程 B。关键不是"能不能停",而是哪些状态算线程 A 的、哪些可以直接扔掉。
答案藏在 ROB 的一个核心设计目标里:精确中断(precise interrupt)。中断发生时,CPU 必须能确定一个"中断发生点"(interrupt point),该点之前的所有指令已完成(架构状态已更新),该点之后的所有指令似乎从未开始(无架构状态残留)。
这对应的硬件动作是——中断信号到达后,CPU 做如下清理:
中断到达时的流水线快照(假设 ROB 里有 120 条 µop,head 在 #50,tail 在 #170):
ROB: [已退休 1..49] [① #50 当前head,待退休] [#51..#170 投机态] ← ① 处的指令对应的 x86 指令边界被选为"中断点"
↑──────────────────┬────────────────────↑
架构边界(精确中断点) 投机域(全部丢弃)各部件被清理的内容:
| 部件 | 中断时的动作 | 丢弃的工作量 | 切回来要不要重做 |
|---|---|---|---|
| ROB | head 之前全部退休 → 中断点标记为退休边界 → head 之后的 µop 全部冲刷(flush) | 几十~两百条 µop | ✅ 全部重做 |
| 保留站(RS) | 所有槽位清空(未发射的、正在等的、已发射等 WB 的) | 几十条 µop | ✅ 全部重做 |
| Load Buffer | 所有条目释放(未完成的 load 全部作废) | ~10 条 | ✅ 全部重做 |
| LFB/MSHR | 所有进行中的 L2 miss 请求继续完成(数据回来后发现 load 已被冲刷→丢弃) | 已经在飞行的 miss 请求无法撤回 | ❌ 白飞了 |
| 物理寄存器堆(PRF) | 中断点之后分配的 PR 全部回收(归还空闲列表) | 几十~上百个 PR | ✅ 重新分配 |
| RAT | 恢复到中断点退休时刻的映射状态(ROB 保存了 checkpoint) | — | — |
| Store Buffer | ⚠️ 分两种情况,见下文 | 视情况 | 视情况 |
| 分支预测器 | 历史状态保留(不随中断清空) | — | — |
| L1 I/D-Cache | 内容保留(线程 A 的 cache 热数据对线程 B 可能有用) | — | — |
一句话:ROB 的 head 指针就是"精确中断"的分界线——head 之前是已退休的(不可撤销),head 之后是投机态的(可以干净利落地一笔勾销)。中断处理程序只看到 head 之前那些指令的结果。
Store Buffer 的特殊性——这是唯一"退休了但还没完"的状态:
Store Buffer 和上面其他部件有一个根本区别:store µop 退休后槽位还在 SB 里等 LSU 排空。其他部件(RS、LB、未退休 ROB 条目)在退休前都是投机态,可以随意丢弃。但已经退休的 SB 条目——线程 A 从程序视角已经认为"写完了"——如果 CPU 不做特殊处理就切到线程 B,线程 B 的 load 应该能看到线程 A 写的那些值吗?
x86 的做法:中断前排空 Store Buffer。x86 要求 store 在退休时对本核后续 load 可见(store→load forwarding),但中断/异常属于序列化事件(serializing event)。序列化事件发生前,CPU 必须:
- ROB 排空到中断点(所有中断点前的指令退休)
- Store Buffer 全部排空到 L1 D-Cache(所有已退休的 store 全局可见)
- 冲刷中断点之后的所有投机状态
所以 store buffer 在中断时不会丢弃已退休的内容——反而要加速排空。这是中断惩罚的一部分:
中断到达
├── 中断点之前的 store µop:全部退休 → SB 槽位标记"可提交"
│ → LSU 立即仲裁排空(不等自然排空时机)
│ → 写入 L1 D-Cache
│
├── 中断点之后的 store µop:SB 槽位释放(未退休 → 丢弃)
│ → 切回来重新执行
│
└── 中断点之后的所有其他 µop:ROB/RS/LB/LFB 全部冲刷量化:一次线程切换丢失了多少工作
假设线程 A 在 3 GHz CPU 上跑了 10 ms 后被抢占:当时 ROB 里约 150 条 µop 在飞(Skylake ROB = 224 条目),RS 里约 60 条在等/在执行:
| 丢弃的 | 数量 | 等价于 | 切回来重做的代价 |
|---|---|---|---|
| ROB 中未退休 µop | ~150 条 | ~40—50 条 x86 指令 | ~15—20 ns |
| RS 中等待/执行中 µop | ~60 条 | ~20 条 x86 指令 | ~7—10 ns |
| Load Buffer + LFB 飞行中 | ~5—10 条 miss | 已在 L2/L3 飞行的 miss 不可撤回 | 数据白取,浪费带宽 |
| Store Buffer 排空 | ~20—40 条 | 积压的 store 逐条写 L1 | ~10—30 ns |
| 合计浪费 | — | — | ~50—100 ns |
10 ms 时间片里线程 A 执行约 3000 万条指令,丢掉的 50 条仅占 0.00017%。上下文切换真正的开销不在"重做",而在 TLB/cache 污染:页表切换导致 TLB flush(PCID 可保护为 partial flush),线程 B 代码和数据冷启动带来大量 cache miss——比丢几十条 µop 大几个数量级。
Store Buffer 排空是上下文切换的一个隐性延迟来源
x86 的序列化事件强制排空 store buffer。写入密集型程序(如 memset 循环)可能积压 40+ 条已退休的 store;中断时 LSU 必须逐条写入 L1——每条要 write port 空闲 + cache line 处于 M/E 态,若 line 不在 L1 还得先发 RFO 取回,排空可能长达几百拍。
这也是为什么实时系统要避免在写入密集的关键路径上被抢占——不是怕"重做",是怕 store buffer 排空引入不可预测的延迟抖动。
四个拆分篇至此全部到位。回到总纲看"为什么要走读"和全篇关系图——uop-pipeline-walkthrough。