1. 第三讲为什么突然变难了:从“理想流水线”到“带伤的流水线”
计算机体系结构里的流水线技术,前两讲听起来是相当舒服的:把一条指令切成取指、译码、执行、访存、写回五段,让不同指令在不同段里并行往前推,时空图一画,吞吐率一算,加速比公式一套,好像整章内容已经拿到了七八成分数。可一到第三讲,节奏明显变了。定向、load-use 停顿、记分牌、Tomasulo、保留站、分支预测、延迟槽,一整套名词砸下来,原本整齐的平行四边形时空图突然变成参差不齐的锯齿。这不是教材故意为难人,而是因为前两讲描述的其实是一个“理想流水线”,而真实处理器从来不是在理想条件下跑。
我先说清楚这一讲在整个流水线技术里的位置。前两讲解决的是“流水线长什么样”和“它的理论性能上限是多少”,第三讲解决的是“当指令之间互相牵扯的时候,流水线怎么才能不卡死”。前者是静态结构,后者是动态行为。很多同学复习到这一讲会感觉公式还能套,但图形画不对、周期数算不准,根子就在于把“结构”和“行为”混在一起了。所以这一讲的核心关键词其实只有两个:相关(Hazard)和调度(Scheduling)。围绕这两个词展开的所有技术手段,本质都是在回答同一个问题——两条本可以并行的指令,因为某种资源或者数据的牵扯,被迫一前一后,那我们要么消除这种牵扯,要么把等待时间填上别的东西。
这一讲适合谁反复看几遍?我的经验是,第一遍学的时候能记住“RAW、WAR、WAW”这三个缩写、能默写 Tomasulo 的三张表,基本就算过关;但要真正在作业和考试里拿满分,必须能自己推演一条指令序列在某个调度策略下的逐周期状态。这中间差的就是“把书合上,拿张白纸自己画一遍”的功夫。下面我按真实学习路径,把这一讲拆成几个可以逐层攻克的模块,顺便把我在做课后习题时踩过的坑一并写出来。
1.1 前两讲留下的三个理想假设
要理解第三讲为什么必须存在,得先回头看看前两讲默认了什么。第一讲在定义流水线时,我们默认了“每段耗时相等,都等于一个时钟周期”,也就是假设最慢的那一段正好卡住整条流水线的节拍,所有段都不会互相等待。第二讲在算吞吐率的时候,默认了“指令之间彼此独立,可以随意重叠”,这才有了那个漂亮的公式:k 段流水线处理 n 个任务,总时间 T = (k + n − 1)Δt,理想吞吐率趋近于 1/Δt。
第三个假设更隐蔽,也最容易被忽略:我们默认了“每条指令都按程序顺序取指、按顺序执行、按顺序写回”。前两讲的时空图之所以整整齐齐,就是因为所有指令都乖乖排队,谁也不会插队,谁也不会回头改已经写好的结果。这三个假设一旦被打破,理想时空图就崩了。而第三讲要处理的,恰恰就是这三个假设被打破之后的现实。
我把这三个假设和它们对应的破坏者整理成一张表,放在这里对照着看会清楚很多:
| 理想假设 | 现实中的破坏者 | 对应的相关类型 |
|---|---|---|
| 每段耗时相等,段间不互等 | 访存单元只有一个、除法器要占多周期 | 结构相关 |
| 指令之间彼此独立 | 后一条指令要用前一条的结果 | 数据相关 |
| 全部按顺序执行 | 遇到分支,不知道下一条该取谁 | 控制相关 |
这张表的价值在于,它把后面所有的技术手段都归类了。定向、停顿、寄存器重命名,全都服务于数据相关;多端口寄存器堆、独立指令数据缓存、访存部件分离,全都服务于结构相关;延迟槽、分支预测、BTB,全都服务于控制相关。你做题时如果判断不出该用什么手段,先回来查这张表,大概率能找到方向。
1.2 相关(Hazard)的三副面孔
“相关”这个词在很多教材里的翻译不统一,有人叫“冲突”,有人叫“冒险”,其实说的是同一件事:两条指令之间存在某种依赖,导致它们不能在流水线里自由重叠。我个人习惯用“相关”这个词,因为它更中性——相关本身不是坏事,它是程序的固有属性,只有当我们试图并行执行时,它才变成麻烦。
三种相关的判断方法其实可以速记。结构相关看资源:两条指令是不是要用同一个硬件部件?比如都在 EX 段抢同一个 ALU,或者一条在 IF 段取指、另一条在 MEM 段访存,同时要访问存储器。数据相关看寄存器号:一条指令写的寄存器号,是不是另一条指令要读或要写的?控制相关看 PC:这条指令是不是一条分支或者跳转,导致下一条指令的地址不确定?
判断顺序上,我建议先看数据、再看结构、最后看控制。原因是数据相关在指令序列里最常见,也最容易被找出规律;结构相关要看具体的硬件配置,题目不给配置就默认不存在;控制相关只在遇到分支时才出现,位置很明确。按这个顺序排查,基本不会漏。
1.3 这一讲的正确打开方式
很多人学这一讲的方式是“背结论”:定向能省两个周期,load-use 要停一个周期,两位饱和计数器四个状态。背结论在简单题里能混过去,一旦题目换个数或者换个流水线深度,立刻露馅。我自己的做法是,把每一种技术都还原成“它到底改变了时空图的哪一部分”。
具体来说,定向改变的是数据可用的时间点——本来数据要等到写回段结束才进寄存器堆,现在在执行段末尾就能通过旁路送出去。停顿改变的是指令发射的时间点——本来该接着来的指令被推迟了,时空图上多出一个气泡。寄存器重命名改变的是寄存器号的含义——同一个架构寄存器在不同指令里被映射到不同的物理寄存器,WAW 和 WAR 就自然消失了。分支预测改变的是取指的方向——本来要等分支算完才知道下一条取谁,现在先猜一个方向继续取,猜错了再冲刷。
把这四句话记住,再去看后面的公式和表格,你会发现它们都是在描述“改动量有多大”。这也是我后面要反复用的分析框架。
2. 结构相关与数据相关:先把“看得见”的冲突拆开
结构相关和数据相关是这一讲里最“实”的部分,因为它们的处理手段都是能在时空图上直接画出来的。相比之下,控制相关和动态调度更抽象一些。所以我建议先把这两类吃透,再去啃 Tomasulo。
2.1 结构相关:三种典型的资源冲突场景
结构相关的本质是硬件资源不够用,所以它一定和具体的硬件配置绑定。抛开配置谈结构相关是没有意义的。常见的有三种场景,我按出现频率排序。
第一种是指令存储器与数据存储器的冲突。如果流水线的取指段和访存段共用同一个存储器端口,那么当一条指令在 IF 段取指、另一条指令同时在 MEM 段读数据时,两者就要抢端口。解决办法是分离指令缓存和数据缓存,这在现代处理器里几乎是标配。第二种是寄存器堆的读写端口冲突,这个在五级流水线里通常不是问题,因为读寄存器在 ID 段、写寄存器在 WB 段,天然错开了;但如果流水线加深,或者允许同一个周期里多条指令写回,端口数就要增加。第三种是功能部件冲突,最典型的是只有一个 ALU,或者除法器要占用多个周期。
这里有一个容易被忽略的点:结构相关的处理不一定非要加硬件。软件层面也能绕开,比如让编译器调整指令顺序,避免两条访存指令挨在一起。只是软件手段可移植性差,换个处理器就得重新调度,所以现代设计更倾向于用硬件解决。做题的时候要注意看题目问的是“硬件方案”还是“软件方案”,这两种答法完全不一样。
提示:判断结构相关时,如果题目没有明确给出“只有一个存储器端口”或“只有一个 ALU”这类条件,默认不做结构相关处理,不要把数据相关的停顿错记成结构相关。
2.2 数据相关的三种形式与它们在五级流水线中的位置
数据相关的三种形式——RAW、WAR、WAW——名字来自“读”和“写”的先后顺序,这个不用死记,画个时间轴就出来了。RAW 是 Read After Write,后一条指令要读的寄存器,正是前一条指令要写的,这是真相关,程序语义上无法消除,最多只能缩短等待时间。WAR 是 Write After Read,后一条指令要写的寄存器,是前一条指令要读的,这是反相关。WAW 是 Write After Write,两条指令写同一个寄存器,顺序不能颠倒,这是输出相关。
关键在于,在一个严格的按序五级流水线里,WAR 和 WAW 根本不会发生。因为所有指令都是按顺序流过各段的,读寄存器都发生在 ID 段,写寄存器都发生在 WB 段,先发射的指令一定先读、先写,顺序天然被保证。所以你在五级流水线的时空图上找 WAR 和 WAW,是找不到的。它们只会在两种情况下出现:一是流水线允许乱序执行(比如 Tomasulo),二是流水线有多个功能部件、允许指令乱序完成。这个结论非常实用,考试里如果题目明确说“顺序发射、顺序执行”,那你就只需要考虑 RAW。
我把三种相关在五级流水线里的具体表现整理如下:
| 相关类型 | 完整名称 | 五级顺序流水线是否出现 | 消除手段 |
|---|---|---|---|
| RAW | Read After Write | 是,最常见 | 定向、停顿、调度 |
| WAR | Write After Read | 否,除非乱序 | 寄存器重命名 |
| WAW | Write After Write | 否,除非乱序 | 寄存器重命名 |
表格里“消除手段”一栏,WAR 和 WAW 都写着寄存器重命名,这是 Tomasulo 算法的核心贡献之一。后面讲动态调度时会展开。
2.3 定向(Forwarding)的条件推演与旁路网络设计
定向也叫前递、旁路,说的都是同一件事:不等数据写回寄存器堆,直接从功能部件的输出端把结果送回到需要它的输入端。这个思路非常符合直觉——数据算出来了,为什么要绕一圈存进仓库再取出来?直接从传送带上递给下一个人不就完了。
但定向不是无条件的,它有一个严格的时序约束:数据的产生时间必须早于或等于数据的消费时间。在标准五级流水线里,ALU 的结果在 EX 段末尾就产生了,而需要它的下一条指令在 EX 段开头就要用,两者只差一个周期,正好可以用旁路送过去。这就是为什么 ALU 运算的结果可以零停顿前递,而 load 指令的结果不行。
load 指令的数据要到 MEM 段末尾才取出来,而紧跟其后要用这条数据的指令,它的 EX 段在什么时间?假设两条指令背靠背:
- 第一条
LW R1, 0(R2):IF 在第 1 周期,ID 第 2,EX 第 3,MEM 第 4,WB 第 5。 - 第二条
ADD R3, R1, R4:IF 第 2,ID 第 3,EX 第 4,MEM 第 5,WB 第 6。
第二条指令在第 4 周期进入 EX 段就要用 R1,而 R1 的数据在第 4 周期末尾才从 MEM 段出来。差了一个周期,前递赶不上,必须停一拍。这就是著名的 load-use 相关。我后面会用代码把这个时序算出来。
旁路网络的设计细节也值得说一句。真实处理器里不是只有一条旁路,而是好几条:EX/MEM 流水寄存器的输出可以旁路回 EX 的输入端,MEM/WB 流水寄存器的输出也可以。因为如果中间隔了两条指令,数据可能已经流到了更后面的流水寄存器里。所以旁路网络本质上是一个多路选择器网络,输入的候选包括寄存器堆的读结果、EX/MEM 段的结果、MEM/WB 段的结果。谁最新、谁有效,就选谁。这个“选最新有效结果”的逻辑,后面在记分牌和 Tomasulo 里会以“读状态表”的形式再出现一次,思想是一脉相承的。
2.4 load-use 相关的停顿周期数怎么来的
这里我要强调一个很多人会忽略的前提:load-use 要停几个周期,取决于数据从哪一端前递、以及寄存器堆的读写时序假设。经典教材里说“停一个周期”,是有前提的——数据在 MEM 段末尾可用,可以从 MEM/WB 流水寄存器前递到 EX 的输入端,同时寄存器堆的写发生在前半周期、读发生在后半周期。如果寄存器堆假设变成“写完才能读”,那停的周期数就会变。
所以做题的时候千万别背结论,要看题目给的时序假设。我整理了一个判断流程:第一步,找出生产者指令的结果在哪一段末尾产生;第二步,找出消费者指令的源操作数在哪一段开头被使用;第三步,两者相减,如果是正数,说明赶不上,要停这么多拍;如果是零或负数,可以前递,不用停。这个流程对任何流水线结构都适用,不管它是五段还是七段。
把它代回经典五级流水线:生产者在 MEM 末尾,消费者在 EX 开头,从 MEM 末尾到 EX 开头之间隔着“下一条指令的 EX 段”这半拍,所以差一拍。这个推导过程比直接背“停一拍”可靠得多,换一道题也不会错。
3. 静态调度到动态调度:把复杂度从编译器搬到硬件
数据相关处理到这一步,读者应该已经能算出固定序列的停顿数了。但真实的程序序列很长,如果每条相关都硬停,性能损失会很大。于是就有了两条路线:一条是让编译器在编译期把指令重排、把空隙填上,这叫静态调度;另一条是让硬件在运行期动态判断哪条指令可以先走,这叫动态调度。这两条路线的取舍,是第三讲里最能体现体系结构设计思维的部分。
3.1 编译期调度与循环展开的手工推演
静态调度的核心思想是:编译器比硬件更了解程序的全局结构,让它来填空白。最简单的办法是循环展开。把循环体复制几份,这样原本跨越迭代的独立指令就能被拉到同一个基本块里,编译器再重新排序,把导致停顿的指令隔开。
举个经典例子,把数组的每个元素加一个常数:
for (i = 1; i <= 1000; i++) x[i] = x[i] + s;翻译成 MIPS 风格的代码大致是:
Loop: L.D F0, 0(R1) ADD.D F4, F0, F2 S.D F4, 0(R1) DADDUI R1, R1, -8 BNE R1, R2, Loop不展开的时候,L.D和ADD.D之间有 load-use 停顿,ADD.D和S.D之间如果没定向也要停。把这些停顿加进去,一个迭代至少多花两三个周期。展开四次之后,循环体变成四条独立的 load、四条 add、四条 store,再加上循环控制。编译器就可以重新排列成:
Loop: L.D F0, 0(R1) L.D F6, -8(R1) L.D F10, -16(R1) L.D F14, -24(R1) ADD.D F4, F0, F2 ADD.D F8, F6, F2 ADD.D F12, F10, F2 ADD.D F16, F14, F2 S.D F4, 0(R1) S.D F8, -8(R1) DADDUI R1, R1, -32 S.D F12, -16(R1) S.D F16, -24(R1) BNE R1, R2, Loop这里的手工推演关键在三点:把四条 load 提到最前面,让它们在数据被用之前有足够的时间;把 store 穿插在 add 中间,避免访存端口冲突;把DADDUI从末尾提前,让分支的地址计算尽早开始。展开之后,原来每次迭代里的小停顿基本上被吸收掉了,代价是代码体积变大、寄存器压力上升。这就是静态调度的典型取舍。
注意:循环展开不是越多越好。展开次数增加会让寄存器需求量线性增长,一旦超出可用寄存器数,就会出现溢出,反而把前面省下的周期全吐回去。一般展开 2 到 4 次是比较稳妥的选择。
3.2 记分牌:三张表、四个阶段
静态调度的局限在于它只能在编译期决策,遇到运行期才确定的行为(比如缓存缺失导致的延迟变化)就无能为力了。动态调度把决策权交给硬件,代价是硬件复杂度上升。最早成体系的动态调度方案是记分牌(Scoreboard)。它的思路很清晰:用三张表记录指令和功能部件的状态,让指令在不违反数据相关的前提下尽量乱序执行。
三张表分别是:
- 指令状态表:记录当前每条指令处于哪个阶段——发射、读操作数、执行、写结果。
- 功能部件状态表:记录每个功能部件是否忙、正在做哪条指令、源操作数来自哪个部件、目的寄存器是哪个。
- 寄存器结果状态表:记录每个寄存器正在被哪个功能部件写,后面的指令要读这个寄存器,就得等这个功能部件。
四个阶段是:Issue(发射,检查功能部件空闲且无 WAW)、Read Operands(读操作数,检查是否有 RAW,没有就一起读进来)、Execute(执行)、Write Result(写结果,检查是否有 WAR)。
这里我特别想点一句:记分牌解决 WAW 的方式是在这个阶段检测并暂停发射,也就是说看到要写同一个寄存器的指令还没写完,后面的指令就在发射口等着。这其实是“检测冲突然后等待”,而不是“消除冲突”。真正的消除要靠 Tomasulo 的寄存器重命名。很多同学把这两者搞混,答题时写“记分牌用寄存器重命名解决 WAW”,这是错的。
3.3 Tomasulo:保留站和寄存器重命名的真正作用
Tomasulo 算法在第三讲里是个绕不过去的坎,因为它涉及的概念最多:保留站、公共数据总线、寄存器状态、load/store 缓冲。但它的核心思想其实只有一句话:把“寄存器”这个逻辑名字,和“值从哪里来”这个物理来源分离开。
怎么分离?关键在保留站。每个功能部件前面挂几个保留站条目,指令发射的时候,如果能拿到操作数就直接取,拿不到就把“我要等哪个功能部件的结果”这条信息记下来。后面那个功能部件算完了,会通过公共数据总线广播一个消息,所有保留站同时监听,谁等的结果出现了就顺手抓走。这样一来,一条指令要用的数据可以从公共数据总线直接抓,完全不需要经过寄存器堆,也就不需要在寄存器堆那里排队。
寄存器重命名体现在哪?体现在寄存器结果状态表(Qi)。当一条写 F0 的指令发射后,表里 F0 这一项就被填成“等 Mult1”。后面那条也要读 F0 的指令,看到的就是“等 Mult1”。如果还有第二条也要写 F0 的指令发射进来,它不会去动寄存器堆里的 F0,而是把表里的 F0 改成指向自己所在的保留站。这样一来,读 F0 的指令拿到的是最新的那个来源,前面那条老指令写什么,它根本不关心。WAW 就这么被绕开了——两条指令写的是不同的物理位置,没有任何先后约束。
WAR 也是同理。一条指令读 F0,它读的是当时的来源;后面一条指令要写 F0,它写的是表里的新来源。两者互不影响,不需要等。
3.4 一段指令序列在 Tomasulo 下的逐步推演
光讲原理容易飘,我们用一段具体序列走一遍。假设有两个加法保留站 Add1、Add2、Add3,两个乘法保留站 Mult1、Mult2,加法延迟 2 周期,乘法延迟 4 周期。
1. MUL.D F0, F2, F4 2. ADD.D F0, F0, F6 3. MUL.D F4, F0, F8 4. ADD.D F2, F10, F12推演过程:
| 周期 | 事件 | 寄存器状态变化 |
|---|---|---|
| 1 | 发射指令1到 Mult1,F2、F4 就绪,开始执行 | F0 ← Mult1 |
| 2 | 发射指令2到 Add1,源 F0 等 Mult1,暂停读;F6 就绪 | F0 ← Add1 |
| 3 | 发射指令3到 Mult2,源 F0 等 Add1,F8 就绪 | F4 ← Mult2 |
| 4 | 发射指令4到 Add2,F10、F12 就绪,开始执行 | F2 ← Add2 |
| 6 | 指令1执行完,CDB 广播结果,写入 F0;Add1 抓走 | F0 ← Add1 |
| 8 | 指令4执行完,CDB 广播;F2 更新 | F2 就绪 |
| 8 | 指令2执行完,CDB 广播;Mult2 抓走 F0 的新值 | F0 就绪 |
| 12 | 指令3执行完,CDB 广播;F4 更新 | F4 就绪 |
这个表最有意思的一点在周期 2:指令2写 F0,寄存器状态表里的 F0 被覆盖成 Add1。这意味着后面的指令看到 F0 时,等的是指令2,而不是指令1。指令1的结果写回寄存器堆时,其实对后续指令已经没有影响了——因为它被重命名“盖”掉了。这就是 WAW 被消除的具体表现。
4. 控制相关:分支这件事到底该怎么处理
数据相关处理完,剩下的就是控制相关。控制相关比数据相关麻烦的地方在于,它不是“等一等就能解决”,而是“你根本不知道该等谁”。遇到一条分支指令,下一条该取哪条指令,取决于分支的结果,而分支的结果要到流水线后面才出来。这个矛盾,是所有分支处理技术的出发点。
4.1 排空与冻结:最笨但最稳的做法
最简单的办法是遇到分支就停下来,等分支结果确定之后再继续取指。具体又分两种做法:一种是排空,把已经取进来但还没执行的指令全部作废,重新从正确地址取;另一种是冻结,把流水线前面的段停住,不往下推,等分支结果出来再决定。两种做法都能保证正确性,代价是性能损失大。
排空的代价很好算:如果分支在执行段解决,那么前面已经进了取指段和译码段的两条指令要作废,相当于浪费两个周期。如果分支在译码段就能解决,浪费一个周期。所以“分支什么时候能解决”直接决定了惩罚大小。这也是为什么很多处理器会把分支的判断逻辑尽量往流水线前面放,甚至专门加一套分支预测电路,争取在取指阶段就猜出方向。
4.2 延迟槽:把惩罚转嫁给编译器
延迟槽是另一种思路:既然分支后面那个位置反正要等,那不如规定“分支后面那一条指令无论分支跳不跳都会执行”。这条指令就叫延迟槽指令。编译器负责把一条有用的指令塞进这个槽里,把原本浪费的周期利用起来。
延迟槽的好处是硬件完全不用改动,代价是编译器负担重,而且一旦塞不进去(找不到合适的指令),就只能填一条空操作,那浪费的还是浪费了。另外延迟槽对指令集的兼容性有影响,程序的可移植性也变差,所以现代主流的指令集已经不太采用这种方式了。
提示:延迟槽相关的题目经常考“分支延迟槽可以填哪些类型的指令”。答法是:只能填那些无论分支是否跳转都不会产生错误的指令,比如前面已经算完、不影响分支判断的运算指令,或者暂时用不到的 load。不能填会改变分支条件本身的指令。
4.3 静态预测与动态预测,BTB 与两位饱和计数器
预测的思路是:与其等,不如猜。猜对了零惩罚,猜错了再冲刷。
静态预测就是固定规则,比如“总是猜不跳转”,或者“向后跳转猜跳、向前跳转猜不跳”(因为循环的回边一般是向后跳)。静态预测的好处是硬件成本几乎为零,缺点是对复杂程序的准确率有限,一般也就六七成。
动态预测会根据历史行为调整。最经典的是两位饱和计数器,它有四个状态:
| 状态编码 | 含义 | 遇到跳转 | 遇到不跳转 |
|---|---|---|---|
| 11 | 强预测跳转 | 保持 11 | 变成 10 |
| 10 | 弱预测跳转 | 变成 11 | 变成 01 |
| 01 | 弱预测不跳转 | 变成 10 | 变成 00 |
| 00 | 强预测不跳转 | 变成 01 | 保持 00 |
为什么要两位而不是一位?一位计数器的问题是,一个循环退出时会预测失败一次,下次进来又失败一次。两位计数器给了“容错空间”:偶尔一次异常不会立刻翻转预测方向,必须连续两次才翻。这就是“饱和”这个词的含义。
另一位重要部件是分支目标缓冲(BTB)。它缓存的是“这个分支上一次跳到哪”,这样在取指阶段就能直接给出预测的目标地址,不用等分支结果算出来。有了 BTB,预测对了的情况下,取指可以完全无缝衔接,零气泡。
4.4 分支惩罚对 CPI 的量化影响
这部分是考试的重灾区,也是我觉得最应该用公式说清楚的地方。平均每条指令因为分支带来的额外周期数,可以这样估:
额外 CPI = 分支指令比例 × 预测失败率 × 预测失败惩罚举个具体数字。假设分支占了全部指令的 20%,两位预测器的准确率是 90%,也就是失败率 10%,每次预测失败要冲刷 3 个周期。那么:
额外 CPI = 0.20 × 0.10 × 3 = 0.06也就是说,理想 CPI 如果是 1.0,加上分支的影响就变成 1.06,性能下降 6%。这个数字看着不大,但如果换成静态预测、准确率只有 70%,额外 CPI 就变成0.20 × 0.30 × 3 = 0.18,性能下降 18%,差距一下就出来了。
这个公式的价值在于它可以反过来用:题目如果告诉你“希望分支带来的额外 CPI 不超过 0.1”,你就可以反推需要多高的预测准确率、或者需要把分支解决提前到哪个阶段。我带过的同学里,凡是能熟练用这个公式反推的,做这类题基本不会错。
5. 动手:用 Python 把流水线时序算一遍
纸面上推演多了容易糊,我建议写个小脚本,把指令序列的逐周期时序算出来,用数据说话。这个脚本不需要多复杂,几十行就够,但它能帮你验证前面所有的推导结论。
5.1 模型假设与数据结构
先说清楚假设,避免算出来的数字对不上教材。我用的是经典五级顺序流水线:IF、ID、EX、MEM、WB,每段一拍,指令按序发射、按序执行。功能部件延迟简化处理:ALU 类指令的结果在 EX 段末尾可用,load 指令的数据在 MEM 段末尾可用。寄存器堆假设为先写后读,所以不带定向时,数据要等到 WB 段结束才算真正可用。
指令用元组表示:(操作名, 目的寄存器, 源寄存器列表, 类型),类型分alu、load、store。
# 指令格式: (op, dst, srcs, kind) PROG = [ ("LW", "R1", ["R2"], "load"), ("ADD", "R3", ["R1", "R4"], "alu"), ("SW", None, ["R3", "R2"], "store"), ("ADD", "R5", ["R6", "R7"], "alu"), ]5.2 定向与停顿的判定逻辑
核心逻辑是:对每条指令,先算它最早能进 EX 段的周期(至少比上一条晚一拍),再检查它的每个源寄存器,如果这个寄存器被前面的指令写过,就要看生产者什么时候能把数据准备好。前递打开时,ALU 结果在EX开始 + 1可用,load 结果在EX开始 + 2可用;前递关闭时,统一要等到EX开始 + 3。
def simulate(prog, forward=True): n = len(prog) ex = [0] * n # 每条指令的 EX 段起始周期 producer = {} # 寄存器 -> 最后写它的指令下标 for i, (op, dst, srcs, kind) in enumerate(prog): # 顺序发射:至少比上一条指令晚一个周期进 EX start = 1 if i == 0 else ex[i - 1] + 1 for s in srcs: if s in producer: j = producer[s] jk = prog[j][3] if forward: avail = ex[j] + (1 if jk == "alu" else 2) else: avail = ex[j] + 3 start = max(start, avail) ex[i] = start if dst: producer[dst] = i # 估算总周期:最后一条指令的 WB 段结束 last_kind = prog[-1][3] tail = {"alu": 3, "load": 4, "store": 3}[last_kind] return ex, ex[-1] + tail5.3 三组对照实验的结果
我把上面这段程序跑了两遍,一遍开定向,一遍关定向,结果如下:
| 指令 | 类型 | 开关定向时 EX 起始 | 关闭定向时 EX 起始 |
|---|---|---|---|
| LW R1, 0(R2) | load | 1 | 1 |
| ADD R3, R1, R4 | alu | 3 | 4 |
| SW R3, 8(R2) | store | 4 | 5 |
| ADD R5, R6, R7 | alu | 5 | 6 |
| 总周期 | — | 8 | 9 |
开定向时,ADD R3, R1, R4的 EX 起始从预期的 2 被推到了 3,多花了一拍,这就是 load-use 停顿时序图上那个气泡。关定向时,它被推到 4,多花两拍,因为数据要等 WB 段结束。后面两条指令顺延,总周期数从 8 变成 9。
这个结果和前面手推的结论完全一致。有意思的是总周期只差一个周期,看起来影响不大,但如果你把这条序列放进一个一千次迭代的循环里,差的就是一千个周期。这就是“单次看着小、累计起来大”的典型例子。
5.4 从实验结果里能读出什么
第一,停顿是会被“传导”的。第一条指令的停顿会让后面所有依赖它的指令顺延,但只要中间出现一条不相关的指令,这个传导链就断了。上面例子里,ADD R5, R6, R7本来在第 4 拍就能进 EX,因为前面堵了,被推到第 5 拍。如果编译器把它提前到LW后面,堵车就能缓解。这就是静态调度存在的意义。
第二,定向的收益和指令序列的密度强相关。如果程序里相关指令挨得紧,定向的收益就大;如果本来就隔得远,定向省下的周期很有限。这也解释了为什么现代处理器即便有复杂的定向网络,仍然需要编译器做调度。
第三,写这个脚本的时候我犯过一个错:一开始忘了处理“顺序发射”这个约束,直接让每条指令只按数据依赖算 EX 起始,结果算出来两条不相关的指令可以同时进 EX。这在单发射流水线里是不可能的。加上start = ex[i-1] + 1这一行之后结果才对。这个坑提醒我,做题时一定要先确认流水线是单发射还是多发射,这个前提不同,答案完全不同。
6. 习题与实操中的高频踩坑清单
最后这部分是我觉得最有价值的地方,因为它是从一道道做错、算错的题里攒出来的。
6.1 时空图与周期数计算最容易错的几个点
第一个错法是把“段”和“周期”搞混。五级流水线的“5 段”不等于“5 个周期”,一条指令走完全程确实是 5 个周期,但流水线灌满之后每个周期能流出一条指令。有人算总时间的时候用“指令数 × 5”,那是把流水线当串行了。
第二个错法是忘了算第一条和最后一条指令的“填充”和“排空”。k 段流水线跑 n 条指令,总周期是k + n − 1,不是n × k,也不是k × n。这个公式看起来很基础,但加上停顿和气泡之后,很多人就忘了要把气泡也算进 n 里。
第三个错法是数气泡数的时候漏数。我的经验做法是:先画不带任何停顿的理想时空图,再逐条检查相关,每次需要在哪一拍插气泡就在图上标一个小叉,最后数小叉的个数。不要心算,一定画出来。
第四个错法是把定向的适用条件和寄存器的可用时间搞混。前面说过,定向能不能省周期,取决于数据产生时间和消费时间的先后,不是一个固定数字。
6.2 概念混淆速查表
我把这一讲里最容易混淆的几组概念整理成表,考前扫一遍很有用:
| 容易混淆的一组 | 区别关键 |
|---|---|
| 结构相关 vs 数据相关 | 前者抢硬件资源,后者抢寄存器数据 |
| RAW vs WAR | 前者是后读前写,后者是后写前读,前者是真相关 |
| 记分牌 vs Tomasulo | 前者检测并等待 WAW/WAR,后者用重命名消除 |
| 静态调度 vs 动态调度 | 前者编译期决定,后者运行期由硬件决定 |
| 静态预测 vs 动态预测 | 前者规则固定,后者根据历史行为调整 |
| 排空 vs 冻结 | 前者作废已取指令,后者暂停但不作废 |
| 延迟槽 vs 分支预测 | 前者把惩罚转给编译器,后者由硬件猜方向 |
这张表里的每一行,我都在作业或者考试里见过被混淆的版本。特别是“记分牌 vs Tomasulo”这一行,是最容易答错的地方。
6.3 一套可以自检的检查流程
做完一道流水线题目,我习惯按这个流程检查一遍:
- 确认流水线深度、每段功能、是否支持定向、是否单发射。这四个前提不清楚,答案一定不对。
- 找出所有相关指令对,逐对判断类型。只有 RAW 需要处理,WAR 和 WAW 在顺序流水线里跳过。
- 对每个 RAW,算数据产生周期和消费周期,判断要停几拍,在时空图上标记。
- 如果有分支,算分支频率和预测失败率,套 CPI 公式。
- 汇总总周期数,用
k + n − 1 + 气泡数交叉验证一遍。
这套流程走下来,基本能覆盖这一讲所有类型的题目。我自己的体会是,第三讲真正难的不是公式,而是前提条件多。同一道题,换一个前提(比如从有定向变成无定向),答案能差出一大截。所以每次动笔之前,先把题目给的前提条件抄一遍在草稿纸角落,这个习惯帮我省下了不少返工的时间。
还有一个特别实用的小技巧:如果题目给的指令序列不长,直接在草稿纸上画一张表,横轴是周期,纵轴是指令,每格填阶段名。画完之后,谁等了谁、等了多久,一眼就能看出来,比纯靠脑子推可靠得多。我后来做这类题基本都这么干,虽然费点纸,但正确率提高得很明显。