从沙子到车辙(3.4):流水线——指令级并行的艺术

发布时间:2026/5/20 16:37:32

从沙子到车辙(3.4):流水线——指令级并行的艺术 3.4 流水线指令级并行的艺术本文内容摘自本人的开源书《从沙子到车辙 - 一个工程师的理解》 在线阅读/下载from-sand-to-rutsgitclone https://github.com/Lularible/from-sand-to-ruts⭐ 如果对您有帮助欢迎 Star 支持也欢迎通过 GitHub Issues 交流讨论。快餐店里的四重奏你在快餐店里做三明治。每个三明治四个步骤切面包2 秒→ 涂酱料3 秒→ 放馅料4 秒→ 包装2 秒。如果你一个人从头做到尾每个三明治 11 秒一小时 327 个。现在你雇了 4 个人排成一排。第一个人只切面包切完递给第二个人涂酱料同时第一个人开始切下一个面包。第二个人涂完递给第三个放馅料……以此类推。第一个三明治还是要等 11 秒。但从第二个开始——每个三明治只需 4 秒最慢那一步的时间。一小时 900 个——效率翻了近 3 倍。你没有让任何人切得更快没有多买一把刀。你只是把等待的时间利用了起来。这就是流水线Pipeline。经典五级流水线David Patterson 和 John Hennessy 在他们经典的 MIPS 处理器中确立了五级流水线后续绝大多数 RISC 处理器都效仿了这个设计IFInstruction FetchPC 驱动地址从指令存储器取指。IDInstruction Decode译码读出寄存器文件中的源操作数。EXExecuteALU 执行算术/逻辑/地址计算。MEMMemory访问数据存储器Load 或 Store。WBWrite Back把结果写回寄存器文件。时钟周期: 1 2 3 4 5 6 7 8 9 指令1: IF ID EX MEM WB 指令2: IF ID EX MEM WB 指令3: IF ID EX MEM WB 指令4: IF ID EX MEM WB 指令5: IF ID EX MEM WB在第 5 个时钟周期之后每个周期都有一条指令完成。理想情况下IPCInstructions Per Cycle 1。相比单周期处理器五级流水线的吞吐率提高了约 5 倍——不是靠让单条指令跑得更快而是让多条指令在时间上重叠。ARM Cortex-M4 采用三级流水线Fetch → Decode → Execute。所以理想情况下也在接近 IPC1但实际上达不到。因为——重叠会带来冲突。用 C 模拟一个五级流水线让我们通过手工追踪一条指令序列在流水线中的执行过程来直观理解流水线的运作。下面的追踪展示了三条核心机制流水线寄存器IF/ID、ID/EX、EX/MEM、MEM/WB保存每条指令在各个阶段的状态转发逻辑把 EX 或 MEM 阶段的 ALU 结果直接旁路回 ID 阶段的读操作数——不需要等 WB 写完寄存器文件气泡逻辑在 Load-Use 冒险时插入一个 NOP让使用 Load 结果的指令在 ID 阶段多等一拍。追踪一段指令序列用这段 ARM 汇编追踪流水线的精确行为ADD R1, R2, R3 ; (1) SUB R4, R1, R5 ; (2) ← RAW on R1 LDR R6, [R7, #4] ; (3) ADD R8, R6, R9 ; (4) ← Load-Use on R6第 1 周期指令(1) IF。流水线里只有一个人在工作。第 2 周期指令(1) ID指令(2) IF。第 3 周期指令(1) EXALU 正在算 R2R3指令(2) ID准备读 R1 和 R5。此时指令(2) 需要 R1——但 R1 的值正由指令(1) 在 EX 阶段算出在 EX/MEM 流水线寄存器中尚未写回。转发路径激活R1 的值直接从 EX/MEM.alu_result 拉回到指令(2) 的 EX 阶段操作数输入——绕过寄存器文件零气泡。第 4 周期指令(1) MEM空白指令(2) EX用转发的 R1 值做 SUB指令(3) ID。第 5 周期指令(1) WBR1 正式写入 reg_file指令(2) MEM指令(3) EX算 R74 得到有效地址指令(4) IF。第 6 周期指令(2) WB指令(3) MEM从 memory[addr] 读出数据指令(4) ID准备读 R6 和 R9。此时指令(4) 需要 R6——但它来自 Load 指令(3)数据要到 MEM 阶段结束才能拿到。而指令(4) 的 EX 阶段在第 7 周期就要开始了——只有一拍的间隔。转发逻辑无法拯救Load 的结果要到第 6 周期结束时才进入 MEM/WB 流水线寄存器而第 7 周期初指令(4) 的 EX 阶段就开始需要它。一个气泡插入第 7 周期指令(3) WB指令(4) ID重复——气泡流水线插入 NOP。指令(5) 或其他指令被延后。第 8 周期指令(3) WB 完成R6 可用。指令(4) EX用转发的 R6 值做 ADD。这段追踪让你看到4 条指令执行 5 条指令的事情因为有 1 个气泡IPC 不是 1.0而是 0.8。数据冒险——特别是 Load-Use 冒险——是实际 IPC 低于理想 IPC 的主因之一。重叠的代价三种冒险重叠不是免费的。当多条指令同时在流水线中飞行依赖关系会让事情变得复杂。假设CPU正在执行以下三条指令ADD R1, R2, R3 ; R1 R2 R3 SUB R4, R1, R5 ; R4 R1 - R5 ← RAW on R1 BEQ R1, R0, target ; if R1R0 goto target在五级流水线中追踪这三条指令第1周期ADD IF。流水线里只有一条指令在飞。第2周期ADD ID, SUB IF。第3周期ADD EXALU正在算R2R3SUB ID需要读R1和R5BEQ IF。此时SUB需要R1——但ADD的EX阶段还没算出R1的最终结果。这是数据冒险RAW——Read After Write。但ADD在EX阶段结束时R1就已经从ALU输出——不需要等WB写完寄存器文件。解决方案是转发Forwarding / BypassingADD在EX阶段一算出R1就立刻旁路给下一周期SUB的EX阶段输入——绕过寄存器文件零气泡。第4周期ADD MEM, SUB EX用转发的R1值做减法BEQ ID。第5周期ADD WB, SUB MEM, BEQ EX决定是否跳转。BEQ在EX阶段算出跳还是不跳如果跳——第6周期应该取target地址的指令。但第5周期已经取了下一条顺序指令进了IF/ID。这是控制冒险。如果BEQ跳转——IF/ID中的指令必须被flush清除流水线从target重新取指浪费一个周期——这就是分支惩罚。最后考虑另一种情况如果SUB是Store指令MEM阶段写内存而BEQ同时在IF阶段读指令存储器。如果它们共享同一组总线——这就是结构冒险。哈佛架构通过独立指令/数据总线解决了这个问题。ARM Cortex-M系列大多采用哈佛架构或修改型哈佛统一地址空间但独立总线接口所以结构冒险在Cortex-M上相对少见。三条指令——ADD、SUB、BEQ——只有三条却展示了流水线的全部三种冒险。下面逐一深入。数据冒险你还没算完我已经需要了RAWRead After Write是最常见的数据冒险。在五级流水线中第一条指令的WB要等到第5个周期才写完R1但第二条指令在第3个周期的EX阶段就需要R1的值——差了两个周期。其他两种——WAWWrite After Write和WARWrite After Read——在顺序流水线中不会发生因为没有乱序写回但在乱序执行中是主要问题。在物理上转发路径就是一根MUX前面加的一组导线——它把EX/MEM流水线寄存器的alu_result字段引出连到ALU输入MUX的一个额外输入端。控制信号forward_a和forward_b决定ALU的输入是来自寄存器文件还是来自转发总线。这个设计零时钟开销——转发操作和ALU运算在同一个周期完成。Cortex-M4有转发逻辑。但不是所有冒险都能转发。Load指令在MEM阶段才拿到数据而紧跟其后的使用者Load-Use冒险不能靠转发解决——必须插入一个气泡bubble让流水线空转一拍。控制冒险你猜对了方向吗分支指令在EX阶段才知道跳不跳。但在这之前已经有两条后续指令进入了IF和ID阶段。如果分支发生——这两条必须被清除flush。时钟周期: 1 2 3 4 5 6 B指令: IF ID EX MEM WB ← 第3周期才知道跳不跳 下一条: IF ID X X ← 已被fetch但在ID后被清空 目标: IF ID EXCortex-M4用静态分支预测来缓解总是假设向后跳转BEQ back会跳因为通常是循环向前跳转不跳因为通常是if-else。但这是静态预测——对所有向后跳转一视同仁。动态分支预测则不一样——它学习每条分支指令的过去行为2位饱和计数器的预测最简单的动态预测器是一个2位饱和计数器。每个分支指令在BTBBranch Target Buffer中有一个对应的2位状态强不跳 弱不跳 弱跳 强跳 [00] ──不跳──→ [01] ──不跳──→ [10] ──跳转──→ [11] ↑ │ ↑ │ └──跳转──────────┘ └──不跳──────────┘状态00和01预测不跳状态10和11预测跳。分支实际发生时状态向右侧移动不发生时向左侧移动。效果一个交替跳转/不跳转的分支比如循环的最后一次迭代2位计数器会犯错约50%但一个总是跳转的分支99%循环体预测准确率接近100%。锦标赛预测器和返回地址栈现代高性能CPUCortex-A系列用更复杂方案锦标赛预测器Tournament Predictor同时运行两个预测器——一个基于局部历史“这条分支自己最近几次怎样”一个基于全局历史“最近所有分支的整体模式怎样”——然后用一个第三方的选择器来动态学习哪个预测器更准。Intel在Pentium Pro时代引入至今仍是高性能分支预测的主流方案。返回地址栈Return Address Stack, RAS返回地址栈RAS是一个极小巧的硬件栈——通常只有8-16项深度。当CPU执行BL函数调用指令时硬件自动把返回地址当前PC4压入RAS。当执行BX LR函数返回时CPU不从分支预测器猜测目标地址——它直接从RAS栈顶弹出。因为函数调用和返回是严格配对的RAS的预测准确率在大多数代码中超过99%。这个8项的微型SRAM就嵌在取指单元旁边——离流水线只有几十微米的物理距离。你在C代码里的每一次函数调用和返回都在和这个比你头发丝直径小一千倍的硬件结构对话。结构冒险两条指令一个资源当两条指令同时需要同一个硬件资源时发生。如上所述哈佛架构天然解决了取指和Load/Store的总线冲突。但在高性能乱序执行CPU中结构冒险重返舞台——不是因为总线的冲突而是因为执行单元执行端口的冲突。如果CPU只有两个整数执行端口而第三周期同时有3条整数指令就绪——必须有一条等待。乱序执行让就绪的指令先走流水线的本质是时间重叠。但你如果只能顺序发射指令到流水线——那么一条指令的 stall 会堵死后面所有指令即使后面的指令不依赖被堵死的那条。这就像超市收银台一个人翻包找零钱后面所有人都得等。乱序执行Out-of-Order Execution打破了顺序限制。核心思想发射指令按程序顺序进入重排序缓冲区Reorder Buffer, ROB。就绪检测每条指令监测自己的源操作数是否就绪。一旦就绪进入执行单元。完成执行结果写入 ROB 条目但不写回寄存器文件。退休Retire指令到达 ROB 头部时它的结果才正式写入架构寄存器文件ROB 条目被释放。在 ROB 的中间指令是乱序执行的——哪条先就绪哪条先走。但在 ROB 的尾部退休端它们严格按程序顺序提交——保证了精确例外precise exception如果指令 3 出了 page fault指令 1 和指令 2 已经退休、结果可见而指令 4 和指令 5 即使已经执行完毕也不会退休——它们会被丢弃、程序状态回到指令 3 之前。寄存器重命名Register Renaming是乱序执行的前提。如果指令 1 写 R3 而指令 5 也写 R3——这是 WAW 冒险。解决方案是把 R3 映射到一个物理寄存器池中的不同物理寄存器。指令 1 实际写到 P23指令 5 实际写到 P41。指令 2-4 如果读 R3——在读的是 P23 还是 P41取决于它们在程序顺序中的位置。硬件重命名表跟踪当前哪个物理寄存器持有架构寄存器 R3 的最新值。乱序执行 寄存器重命名 转发逻辑 分支预测——这些是现代高性能 CPU 的四大支柱。流水线的物理极限流水线的级数受限于每一级的关键路径延迟。而关键路径延迟又受限于晶体管的驱动能力和互联线的 RC 延迟。把 5 级流水线拆成 15 级每一级的关键路径变短了——单级只要做原来 1/3 的工作时钟频率可以提高到原来的近 3 倍。这是 Pentium 4 的思路31 级流水线目标频率 10GHz。但过深的流水线有三个物理代价流水线寄存器的开销每加一级流水线就要插一组 D 触发器几十到几百个。时钟频率提高了但每个触发器都在翻转——动态功耗线性上升。Pentium 4 的 31 级流水线中流水线寄存器本身的功耗占了总功耗的近 15%。互联线 RC 延迟不随晶体管缩放从 90nm 到 28nm晶体管门延迟缩小了近 10 倍。但顶层金属的每微米电阻几乎没有变化铜的电阻率是物理常数而线间距缩小使电容耦合增加。到 7nm 节点互联线延迟已经超过门延迟成为关键路径的主导因素。你加再深的流水线数据传输本身的延迟无法被流水化——因为数据必须在两级寄存器之间物理上移动。分支预测失败惩罚31 级流水线意味着 31 个周期的 flush 惩罚。假设分支比例 20%、预测准确率 95%——有效 IPC 1 / (1 0.2 × 0.05 × 31) 1 / 1.31 ≈ 0.76。而 8 级流水线的同样分支惩罚是 8 个周期——有效 IPC 1 / (1 0.2 × 0.05 × 8) 1 / 1.08 ≈ 0.93。流水线越深分支预测失败的损失越大实际收益越被侵蚀。这就是为什么 Pentium 4 之后Intel 放弃深度流水线路线转向 Core 微架构14 级流水线。也是 ARM 的 Cortex-A 系列稳在 8-15 级之间、没有继续加深的根本物理原因。确定性 vs 性能实时系统里的流水线你的发动机控制器需要在固定时间窗内完成燃烧计算。曲轴位置传感器每转一个齿就触发一次中断。6000 RPM 下每个齿间隔只有几十微秒。你的 ISR 必须在几微秒内完成。现在考虑流水线的影响branch prediction 可能预测错数据转发可能引入不确定的气泡Flash 访问等待周期数可能在不同电压和温度下变化。对于硬实时系统确定性比峰值性能更重要。这就是为什么 ARM Cortex-R 系列用于功能安全实时控制做了这些特殊设计紧耦合存储器TCM和 Cache 不同TCM 的内容由软件显式管理不存在 cache miss 的不确定性。ISR 代码和数据直接锁在 TCM 里访问延迟是确定的单周期。MPU存储器保护单元不是 MMU——不涉及页表翻译的不确定延迟。用固定区域保护属性的方式做内存隔离。锁步双核Lockstep两个 Cortex-R5 核运行同一段代码硬件在 cycle 级别比较它们的输出。一个核出错另一个立即发现。还有一个关键点Cortex-R5 可以禁用分支预测。对于安全关键代码路径确定性高于一切。如果分支预测带来了不可预测的 flush 延迟那不如完全不用——让分支采用固定的静态策略always not-taken延迟可精确计算。WCET 分析的核心诉求就是每一段代码的执行时间有确定的上界——如果分支预测的失误概率不可忽略WCET 就必须假设它每次都失误。这样算出来的 WCET 比静态策略还要大——禁用分支预测反而更优。时间重叠从 CPU 到分布式系统流水线的本质很简单把一个大任务拆成小步骤让多条指令的步骤在时间上重叠。没有变出更多资源——只是把空闲的硬件利用了起来。这其实是所有高效系统的共同设计逻辑CPU 流水线一条指令在执行下一条在译码下下条在取指——时间重叠。DMACPU 在计算DMA 在搬数——时间重叠。CAN 控制器的硬件缓冲CPU 在发第 N 帧CAN 控制器在收第 N1 帧——时间重叠。FreeRTOS 任务切换一个任务被 I/O 挂起另一个就绪任务立即抢占——时间重叠。你设计的系统在多大程度上利用了时间重叠就在多大程度上发挥了硬件的能力。但重叠有代价——就是冒险hazards。冒险的本质是并行执行导致的信息不一致。你得设计转发逻辑、气泡清理、仲裁机制。这和分布式系统比如 CAN 网络中设计时间同步的逻辑是完全同构的各 ECU 高重叠度工作同时发报文→ 冲突CAN 的 ID 仲裁解决→ 时间漂移PTP 同步解决。从 CPU 流水线冒险到分布式系统的时间漂移——管理重叠是同一类问题在不同尺度上的投影。保证比最优更重要在设计中运行硬实时任务ABS、转向、发动机管理流水线是你的助手——但要永远记住它的最优情况不是它的保证情况。你写的是刹车控制代码。不是大多数情况下来得及——是每一毫秒都必须来得及。流水线的最坏执行时间WCET分析要求你假设所有cache miss、所有分支预测失败、所有load-use气泡在最坏路径上都会发生。这是汽车嵌入式软件和互联网软件最根本的分歧他们要的是峰值性能你要的是最坏保证。本篇小结今天我们做了一件事理解了流水线——把大任务拆成小步骤让多条指令在时间上重叠。关键结论五级流水线让IPC接近1IF→ID→EX→MEM→WB第5周期后每周期完成一条指令——不是让单条更快是让多条重叠。三种冒险是重叠的代价数据冒险靠转发解决控制冒险靠分支预测缓解Load-Use冒险只能插入气泡——实际IPC永远低于1。硬实时系统要的是确定性不是峰值性能WCET分析假设所有cache miss、所有分支预测失败——刹车代码每毫秒都必须来得及。下一节存储层次。为什么快和大不可兼得——以及我们是怎么骗过这个限制的。【下集预告】计算能力有了。但数据存在哪你的 CPU 寄存器只有几十个加起来不到 1KB。下一个能用的存储是几十 KB 的 SRAM再往下是几百 MB 的外部 DRAM再往下是 GB 级的 Flash。每一层之间速度差一个数量级。你是个图书管理员读者排队等你从地下仓库取书来回 10 分钟。你想了个办法——在借阅台后面放一个小书架只放最常用的 50 本书。这就是 Cache。下一节存储层次。为什么快和大不可兼得——以及我们是怎么骗过这个限制的。

相关新闻