news 2026/9/30 5:20:21

计算机体系结构:从理想流水线到带伤流水线的Hazard与调度

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
计算机体系结构:从理想流水线到带伤流水线的Hazard与调度

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。

我把三种相关在五级流水线里的具体表现整理如下:

相关类型完整名称五级顺序流水线是否出现消除手段
RAWRead After Write是,最常见定向、停顿、调度
WARWrite After Read否,除非乱序寄存器重命名
WAWWrite 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] + tail

5.3 三组对照实验的结果

我把上面这段程序跑了两遍,一遍开定向,一遍关定向,结果如下:

指令类型开关定向时 EX 起始关闭定向时 EX 起始
LW R1, 0(R2)load11
ADD R3, R1, R4alu34
SW R3, 8(R2)store45
ADD R5, R6, R7alu56
总周期—89

开定向时,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 一套可以自检的检查流程

做完一道流水线题目,我习惯按这个流程检查一遍:

  1. 确认流水线深度、每段功能、是否支持定向、是否单发射。这四个前提不清楚,答案一定不对。
  2. 找出所有相关指令对,逐对判断类型。只有 RAW 需要处理,WAR 和 WAW 在顺序流水线里跳过。
  3. 对每个 RAW,算数据产生周期和消费周期,判断要停几拍,在时空图上标记。
  4. 如果有分支,算分支频率和预测失败率,套 CPI 公式。
  5. 汇总总周期数,用k + n − 1 + 气泡数交叉验证一遍。

这套流程走下来,基本能覆盖这一讲所有类型的题目。我自己的体会是,第三讲真正难的不是公式,而是前提条件多。同一道题,换一个前提(比如从有定向变成无定向),答案能差出一大截。所以每次动笔之前,先把题目给的前提条件抄一遍在草稿纸角落,这个习惯帮我省下了不少返工的时间。

还有一个特别实用的小技巧:如果题目给的指令序列不长,直接在草稿纸上画一张表,横轴是周期,纵轴是指令,每格填阶段名。画完之后,谁等了谁、等了多久,一眼就能看出来,比纯靠脑子推可靠得多。我后来做这类题基本都这么干,虽然费点纸,但正确率提高得很明显。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 5:20:14

Ubuntu 20.04安装ROS Noetic全攻略:从换源到环境验证,亲测有效

开头Ubuntu 20.04装ROS Noetic这件事&#xff0c;我前前后后在不同的机器上折腾了不下二十次&#xff0c;有全新裸机、双系统、虚拟机&#xff0c;也有从ROS Melodic升级上来的老环境。说“亲测有效”不是标题党&#xff0c;而是每一步都真实跑过&#xff0c;踩过的坑比安装步骤…

作者头像 李华
网站建设 2026/9/30 5:19:00

Jenkins安装指南:JDK版本对齐与war包、容器化部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 5:18:51

Laya快速决策模型部署与微调实战:LLaMA Factory完整指南

Laya这个项目我盯了有一阵了&#xff0c;GitHub上17K Star的成绩在这个赛道里确实不常见。这两周我抽空把它的安装、部署、推理、微调从头到尾跑了一遍&#xff0c;结论是&#xff1a;单论System 1快速决策这类场景&#xff0c;Laya的表现确实可以用"爆打Jev"来形容。…

作者头像 李华
网站建设 2026/9/30 5:18:16

无蜂窝大规模MIMO与无人机辅助通信:基于DQN的调度策略实战

简介&#xff1a;这份资源是一篇面向通信工程、无线网络与人工智能交叉方向研究者的技术文档&#xff0c;聚焦无蜂窝大规模MIMO场景下无人机辅助通信与资源调度难题&#xff0c;适合具备一定强化学习与通信理论基础的研究生、科研人员及工程技术人员参考。压缩包内仅含1个docx文…

作者头像 李华
网站建设 2026/9/30 5:17:50

Windows整盘换Ubuntu 20.04单系统:分区引导与驱动实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华