
学到这里流水线的基本概念、加速比公式、五级流水线的时空图基本都过了一遍但真正让人头大的东西才刚刚开始。第三章第三讲在计算机体系结构这门课里是个明显的分水岭前面靠背公式、套时空图还能混过去从这一讲开始你必须真正理解硬件在每一个时钟周期里到底发生了什么。流水线技术走到这一步讨论的不再是理想情况下能跑到多少而是现实中为什么跑不到。结构冒险、数据冒险、控制冒险这三座山加上转发、停顿、分支预测、动态调度这一整套补救手段构成了整章最硬核的部分。湖南大学、国科大这类课程的中期考核以及大多数体系结构教材的课后压轴题基本都落在冒险判定、转发路径推导、预测准确率与CPI换算这几个点上。下面这些内容是我自己啃这块内容时反复推演、踩坑之后整理出来的重点放在为什么这么设计而不是结论是什么。1. 流水线冒险的本质为什么理想流水线永远跑不满1.1 从理想时空图到三处硬约束理想流水线的假设是每个周期都能取到一条新指令每条指令用五个周期走完彼此之间毫无干扰。这样算下来k 级流水线在装满之后每个周期都有一条指令完成吞吐率就是 1 条/周期。但这个模型有三个隐含前提被悄悄忽略了其一取指和访存不能同时使用同一个存储器端口其二后面指令需要的操作数前面指令必须已经算完并写回其三取到的下一条指令地址必须立刻确定不能等分支结果算出来。这三条里任意一条不成立流水线就得停下来等或者走上一条支路结果发现走错了只能把已经进来的指令全部作废重来。这就是冒险Hazard这个词的来源。它不是出错了而是存在一种潜在的冲突情形。这里有个很关键的概念区分很多人在复习时容易混冒险是一种可能性停顿是应对冒险的手段之一。比如两条指令之间存在数据依赖这是一个冒险而解决这个冒险可以选择转发、可以选择停顿、也可以靠编译器重排指令三者只是不同层次的解法。我习惯把冒险分成两个维度去看从硬件结构角度看是资源不够用从指令流角度看是顺序被打乱或结果没准备好。前者是结构冒险后两者分别对应控制冒险和数据冒险。这个分类不是死记的你只要问自己一句话——冲突发生在抢硬件、抢数据还是抢地址上答案自然就出来了。1.2 冒险判定三句话问清一条指令流判断一段指令序列里有哪些冒险不要一上来就画大表。我自己的顺序是这样的第一步看有没有两条指令在同一个周期要用同一个硬件部件。五级流水线里最常见的固定冲突是 IF 阶段的取指和 MEM 阶段的访存如果指令和数据共用一个存储器那么任何一条 load/store 在 MEM 阶段都会和同周期的取指抢端口。第二步逐个看后面的指令读了哪个寄存器、前面的指令写了哪个寄存器。只要存在先写后读RAW就是真数据相关必须处理如果是先读后写WAR或写后写WAW只有在流水线允许乱序或者有多个写回端口时才会真的出问题顺序流水线里这两类通常不会造成停顿。第三步看有没有分支、跳转指令以及它们在什么阶段才能解析出目标地址。在经典五级流水线里分支结果要到 EX 阶段末尾甚至 MEM 阶段才知道而在那之前 IF 已经取了好几条错误路径上的指令。把这三步走完一段代码的冒险图谱基本就清楚了。接下来分别拆解每一类。2. 结构冒险硬件资源不够时的取舍2.1 存储器端口冲突与指令数据分离结构冒险最经典的例子就是这个五级流水线在同一时刻IF 阶段要读一条指令MEM 阶段要读/写数据如果二者共享同一个单端口存储器物理上做不到同时在两个地址上访问。解决办法不是让存储器变快而是直接把存储资源拆开。最常用的方案是指令 Cache 和数据 Cache 分离也就是常说的哈佛结构。拆开之后取指走 I-Cache访存走 D-Cache两个端口互不干扰结构冒险直接消失。代价是芯片面积和布线复杂度上升但对于现代处理器来说这点代价完全可以接受——你去看任何一款主流处理器的 L1 Cache基本都是 I/D 分离的。另一个折中方案是把存储器做成多端口或者双端口或者给寄存器堆加写口在下半周期、读口在上半周期的时序安排。不过多端口寄存器的面积增长是平方级的端口数从 2 个加到 4 个面积可能翻好几倍所以这个方法通常只用在寄存器堆这种小容量、高带宽的结构上。我实测过用 FPGA 综合一个双端口寄存器堆和单端口相比LUT 消耗大概多出 60% 到 80%这个数字在做面积预算时要有数。2.2 功能单元复制与流水化改造第二类结构冒险来自功能单元本身。假设有一个非流水化的乘法器一次乘法要占用它 4 个周期那么第 1 条乘法指令进了乘法器之后第 2 条乘法在第 4 个周期想再用它就冲突了。解决方案有两种把乘法器流水化或者复制多个乘法器。流水化改造的思路是让乘法器内部也分成若干级每个周期只占用一级这样不同乘法指令可以同时处在不同的内部级上吞吐率从 1/4 提升到 1。代价是延迟可能略微增加多了一级寄存器而且控制逻辑变复杂。复制功能单元则是用面积换吞吐代价直接写在面积和功耗上。这里有个容易被忽略的点浮点运算单元往往是结构冒险的重灾区。整数流水线和浮点流水线的节拍可能不一致浮点加法可能需要 4 个周期、除法可能要 20 多个周期甚至更多如果这些单元不是流水化的后面的指令就会长时间被堵住。所以做性能分析时先看瓶颈功能单元的占用周期数再决定是流水化还是复制这个判断顺序别搞反了。2.3 结构冒险的排查与量化排查结构冒险最直接的工具是画资源占用表横轴是周期纵轴是功能单元把每条指令在每个周期占用的资源标出来重叠的格子就是冲突点。这个方法看着笨但对于小规模指令序列特别有效考试里也经常用。量化上结构冒险造成的性能损失通常用资源冲突停顿周期数 / 总指令数来衡量。如果发现某类功能单元的冲突占比超过 5%基本可以判断它是瓶颈值得为它增加并行度或流水化。这个 5% 不是硬指标是我在做几个流水线模型对比后总结的经验阈值实际项目里还要结合功耗和面积预算一起看。3. 数据冒险RAW、WAR、WAW 与转发技术3.1 三种数据相关性的识别与优先级数据相关的本质是指令之间存在访问顺序上的约束。按依赖方向分成三类RAWRead After Write后一条指令读的寄存器正好是前一条指令要写的这就是真数据相关。它是唯一必须靠硬件保证顺序的相关性也是流水线里造成停顿的主因。WARWrite After Read后一条指令写某个寄存器而前一条指令要读它。顺序流水线里后面的写回总在前面的读之后不会出问题但一旦允许乱序后写的指令可能先跑到写回阶段把前一条指令要读的旧值覆盖了就错了。WAWWrite After Write两条指令写同一个寄存器谁最后写进去决定了最终结果。顺序执行下没问题乱序执行下如果后写的先落地结果就是错的。这里的关键理解是RAW 是真相关WAR 和 WAW 是名字相关。名字相关只是因为两个不相关的计算碰巧用了同一个寄存器名硬件或编译器完全可以靠换个名字来消除。这个认识直接引出了后面的寄存器重命名技术。识别方法很机械把指令序列里每条指令的源寄存器和目的寄存器列出来两两比对。前一条的目的寄存器等于后一条的源寄存器就是 RAW前一条的源等于后一条的目的就是 WAR两条目的寄存器相同就是 WAW。我建议用表格形式列比在脑子里比快得多也不容易漏。3.2 转发路径的完整推导转发Forwarding也叫旁路 Bypassing的核心思想特别朴素结果在 EX 阶段末尾就已经算出来了为什么要等到 WB 阶段写回寄存器堆才让后面的指令读直接从功能单元的输出拉一根线送到需要它的地方不就行了。以经典五级流水线加转发的 MIPS 为例假设有这样一段代码add $s0, $t0, $t1 # I1: EX 末尾得到 $s0 sub $t2, $s0, $t3 # I2: EX 阶段需要 $s0I1 的结果在它自己的 EX 末尾产生此时 I2 正好进入 EX 阶段。所以只需要把 I1 的 EX/MEM 流水寄存器里的结果转发到 I2 的 EX 阶段输入端。判定条件大致是EX/MEM 流水级的目的寄存器编号等于当前 ID/EX 流水级的源寄存器编号且目的寄存器不是零号寄存器。如果中间隔了一条指令add $s0, $t0, $t1 # I1 sub $t4, $t5, $t6 # I2 and $t2, $s0, $t3 # I3: 需要 $s0这时 I1 的结果已经写回到寄存器堆了I3 可以从 MEM/WB 转发也可以直接读寄存器堆后者更省事通常优先走寄存器堆读。所以转发路径要写两条一条从 EX/MEM 到 EX 输入一条从 MEM/WB 到 EX 输入。如果两条同时命中说明两条指令竞争同一个寄存器优先级一定是 EX/MEM 高于 MEM/WB因为越靠近 EX 的结果越新。提示转发的判定条件里那个目的寄存器不为零的检查千万不能漏。零号寄存器永远读出来都是 0如果漏了这个条件一条add $zero, ...会让后面所有源寄存器是零号的指令都被错误转发。3.3 load-use 冒险转发也救不了的那一个周期转发有一个覆盖不到的场景就是 load-use 冒险。看这段代码lw $s0, 0($t0) # I1: 数据要到 MEM 阶段末尾才出来 add $t1, $s0, $t2 # I2: EX 阶段就要用 $s0I1 的数据来自存储器最早也要在 MEM 阶段末尾才拿到而 I2 的 EX 阶段紧跟在 I1 的 EX 之后时间上根本赶不上。这种情况下转发能做的只是把数据从 MEM/WB 转发到 EX但仍然需要至少插入一个气泡bubble也就是停顿一个周期。停顿的插入方式通常是冻结 PC 和 IF/ID 流水寄存器同时在 ID/EX 里插入一个全零的控制信号让一个空操作流过去。这一停整个流水线就少了一个周期的吞吐。所以编译器做指令调度时最优先做的事情之一就是把 load 后面的独立指令挪到这条空档里这叫装载延迟槽填充。计算停顿代价时要小心一个细节如果 load 后面隔两条或更多条无关指令就不需要停顿了隔一条时通常只需要一个停顿周期。判断依据就是数据出来时消费者有没有已经走到 EX 阶段。3.4 编译器调度与寄存器重命名硬件转发 停顿能保证正确性但停顿浪费的是性能。编译器层面的办法是静态调度在编译期重排指令顺序把无关指令填进延迟槽。比如lw $s0, 0($t0) add $t1, $s0, $t2 sub $t3, $t4, $t5可以重排成lw $s0, 0($t0) sub $t3, $t4, $t5 add $t1, $s0, $t2这样 load-use 的空档被sub填满停顿从 1 个周期降到 0。代价是可能增加寄存器使用压力而且编译器必须知道流水线的具体停顿时延这个数字依赖具体微架构编译器往往拿不到精确值所以实际效果因平台而异。寄存器重命名则是从根上解决 WAR 和 WAW 的名字相关。思路是维护一张逻辑寄存器到物理寄存器的映射表每遇到一次写操作就分配一个新的物理寄存器读的时候查映射表。这样不同的指令即使逻辑上写同一个寄存器物理上写的是不同的地方名字相关自然消失。Tomasulo 算法里的保留站、现代处理器里的重命名表本质上都是这个思想的不同实现。4. 控制冒险分支预测与延迟槽4.1 分支代价被冲刷掉的指令有多少控制冒险的麻烦在于分支结果没算出来之前取指单元不知道该取哪条路。五级流水线里分支的目标地址和条件判定通常在 EX 阶段完成而这时 IF 可能已经取了两条后续指令。如果分支真的跳转这两条取进来的指令就是错的必须冲刷掉同时插入和流水线级数相关的停顿。更一般地设分支在流水线第 k 级解析那么每次预测失败要冲刷的指令数大约是 k-1 条具体取决于取指和解析的相对位置。分支代价 预测失败率 × 每次失败的冲刷周期数 × 分支指令占比这个乘积直接加到 CPI 上。举例设分支指令占全部指令的 20%静态预测总是不跳转的准确率假设是 65%即 35% 的分支实际会跳分支在 EX 阶段解析每次失败冲刷 2 个周期。那么分支带来的额外 CPI 是 0.20 × 0.35 × 2 0.14。如果基准 CPI 是 1就等于性能下降了 14%。这个量级在做性能估算时绝对不能被忽略也正因如此分支预测的准确率每提升一个百分点都很有价值。4.2 静态预测与延迟槽调度静态预测是不依赖运行历史的预测方式主要有几种总是不跳转Predict Not Taken实现最简单不需要额外硬件但对循环类代码效果差因为循环回边的跳转几乎每次都会跳。总是跳转Predict Taken对循环友好但对 if-then 结构不利。BTFNBackward Taken, Forward Not Taken向后跳转循环预测跳向前跳转条件分支预测不跳。这个规则只靠指令地址的高低就能判断硬件代价极小而准确率通常明显高于前两种是很划算的一个方案。延迟槽是另一种思路干脆不预测规定分支后面的那一条或多条指令无论分支跳不跳都会被执行。编译器负责把一条有用的指令塞进延迟槽。这在一级流水线或者早期 RISC 上很常见但流水线越深、延迟槽越长编译器能找到足够多有用指令的概率就越低所以现代深度流水线基本不用这套。4.3 动态预测从一位计数器到 TAGE动态预测靠硬件记录历史行为。最基础的是一位预测器为每个分支存一个比特记上次跳没跳这次就按上次来。问题是它对循环的最后一次迭代特别差——循环退出时预测失败下一次进入循环时又预测失败两次错。两位饱和计数器解决这个问题状态在强跳转、弱跳转、弱不跳转、强不跳转之间迁移只有连续两次反向才翻转预测。这个设计对循环模式的容错能力明显更强是目前绝大多数教材和实现的基础单元。它的准确率在典型测试程序上通常能到 90% 左右。再往上是相关预测Correlating Predictor把最近几条分支的历史拼成一个全局历史寄存器用全局历史, 分支地址的组合去索引预测表。它利用的是分支之间相关这个事实——比如if (a0)和if (b0)两个分支第二个的结果可能强依赖第一个。竞赛预测器Tournament Predictor则同时跑两个预测器比如局部和全局用一个选择器决定信谁兼顾两种模式。TAGE 是目前学术和工业界都比较认可的方案用多个不同历史长度的预测表叠加长历史表负责捕捉长模式短历史表兜底。配套的还有BTB分支目标缓冲缓存分支的目标地址让取指在 ID 之前就能拿到目标省掉等 EX 解析的时间以及RAS返回地址栈专门预测函数返回的地址因为返回地址是动态的普通 BTB 处理不好。5. 动态调度记分牌与 Tomasulo 算法5.1 记分牌的四段式执行记分牌Scoreboard最早出现在 CDC 6600 上核心思想是把指令的发射和执行分开让多条指令在有资源、无冲突的前提下乱序推进。它把每条指令的处理拆成四段发射Issue、读操作数Read Operands、执行Execute、写结果Write Result。记分牌维护三张表指令状态表每条指令处在哪一段、功能单元状态表每个功能单元忙不忙、在算哪条指令、寄存器结果状态表哪个功能单元将要写哪个寄存器。发射阶段检查功能单元是否空闲以及有没有 WAW 冲突读操作数阶段等所有源操作数就绪这一步专门用来处理 RAW执行阶段完全按功能单元自己的时序写结果阶段要检查有没有 WAR 冲突有就等。记分牌能实现乱序执行但它有个明显短板它不能做寄存器重命名所以 WAR 和 WAW 只能靠等来解决这限制了并行度。而且它没有独立的转发网络结果只能通过寄存器堆传递进一步拉长了等待时间。5.2 Tomasulo保留站与公共数据总线Tomasulo 算法的关键改进在两点一是用**保留站Reservation Station代替寄存器堆作为操作数的暂存二是用公共数据总线CDB**广播结果。保留站的每条表项里存指令本身、两个源操作数的值或还没有等哪个保留站/寄存器以及目标寄存器编号。指令发射时如果源操作数已经在寄存器堆里就直接抄进保留站如果没准备好就记下要等哪个单元产生。结果一旦在 CDB 上广播出来所有在等这个值的保留站同时抓取这就是 Tomasulo 高效的原因——它把一对一等待变成了一对多广播。更重要的是保留站天然实现了寄存器重命名一条指令发射到保留站之后寄存器堆里的旧值立刻可以被后续指令读走而这条指令的结果会写到一个全新的位置保留站表项。WAR 和 WAW 就这样被消除了不需要任何额外的判定逻辑。这也是为什么 Tomasulo 的硬件比记分牌复杂但在实际并行度上高出一大截。注意Tomasulo 存在一个著名的问题——它无法处理精确异常。因为指令可以乱序完成如果某条较早的指令发生中断而它后面的指令已经改了寄存器状态异常现场就还原不回去了。这个问题最终靠重排序缓冲ROB解决所有结果先写进 ROB等指令按程序顺序提交时才真正生效一旦发生异常直接丢弃 ROB 里未提交的部分。5.3 重排序缓冲乱序执行、顺序提交ROB 的思想是给乱序执行加一个顺序提交的闸门。指令按顺序分配 ROB 表项乱序执行但结果提交必须严格按分配顺序只有队首的指令提交了后面才轮得到。表项里通常记录指令类型、目的寄存器、结果值、是否就绪。这样一改精确异常就自然实现了某条指令出错时所有在它之后ROB 中位置更靠后的指令都还没提交直接清空即可处理器的状态永远停在最后一条已提交指令之后这个合法点上。现代主流处理器几乎都采用乱序执行 顺序提交这个组合ROB 是其中的核心部件。它的容量直接决定了处理器能看到多大范围的指令并行度也是衡量微架构水平的重要参数之一。6. 超标量与乱序执行从 IPC 1 往 4 冲6.1 发射宽度与取指的匹配问题超标量就是每个周期发射多条指令发射宽度可以是 2、4、6 甚至更多。但这里有一个经常被忽略的匹配问题取指宽度、译码宽度、发射宽度、提交宽度必须基本一致任何一环偏窄都会成为瓶颈。假设取指每周期只能取 4 条发射宽度是 6那发射口的利用率就卡在 4 上了。实测标称 IPC 和实际 IPC 之间的差距主要来自三部分分支预测失败带来的冲刷、Cache 缺失带来的长时延停顿、以及各种资源冲突的等待。我做过一个粗略统计在一个典型的乱序核心上前沿宽度为 4 的情况下整数密集负载实际能拿到的 IPC 大概在 2 到 2.5 之间浮点负载可能只有 1.5 到 2。想要逼近 4需要预测准确率、Cache 命中率、执行单元数量三者同时达标缺一个都不行。6.2 乱序窗口与调度策略乱序执行能带来多少收益很大程度上取决于指令窗口的大小。窗口越大处理器能看到的独立指令越多能填满等待空档的机会越大。但窗口不是想加就加的ROB 和保留站都需要大容量的相联存储访问延迟和面积都随之上升太大会拖慢时钟频率。调度策略上常见的有最老优先和按就绪顺序选择两类。最老优先能降低饥饿概率保证公平按就绪顺序则更倾向于吞吐。实际设计里往往是混合策略再配合一些启发式规则比如优先唤醒关键路径上的指令。这块内容偏工程实现考试里一般不会深挖但在做微架构研究或者阅读处理器手册时会经常碰到。6.3 用 CPI 公式反推瓶颈在哪儿把前面所有内容串起来一个实用的 CPI 分解式可以写成这样组成部分来源典型量级基准 CPI流水线深度带来的固有开销1理想数据冒险停顿load-use、转发失败0.05 到 0.3控制冒险停顿分支预测失败冲刷0.05 到 0.4结构冒险停顿功能单元、端口冲突0 到 0.2访存停顿Cache 缺失0.2 到 2.0差异极大这个表的价值在于当你发现某个程序的 CPI 是 3.0 时可以逐项去查到底是哪一项超了。我的经验是访存停顿通常是大头其次才是分支预测。所以优化顺序一般是先动 Cache 和预取再调分支预测器最后才考虑增加执行单元。这个顺序如果搞反了很容易在一个不重要的点上花大量精力却看不到性能提升。7. 常见问题与排查技巧实录7.1 易错点速查表下面这张表是我从做题和实验里整理出来的高频坑按现象—原因—处理三列排开现象常见原因处理方法算出的停顿周期比预期多忘了 load-use 至少需要一个气泡检查数据源是不是来自 MEM 阶段转发条件判定总出错漏掉目的寄存器不是零号的检查在所有转发条件里加上判零分支预测准确率怎么调都上不去训练集太小历史表索引冲突严重加大表项或改用带标签的预测结构乱序模拟结果与顺序执行不一致ROB 提交顺序没严格限制检查是否只有队首表项允许提交结构冒险反复出现忽略了浮点单元的非流水化特性先确认每个功能单元的占用周期数时空图算出的周期数对不上把流水寄存器延迟和功能单元延迟搞混明确区分发射间隔和执行时延这张表里最值得多说一句的是最后一行。发射间隔Initiation Interval指的是同一条功能单元两次接受新指令之间至少隔几个周期执行时延Latency指的是指令从进入功能单元到结果可用经过几个周期这两个数在很多文档里写得很像但含义完全不同混淆了就会把时空图画错。7.2 模拟器实操与验证思路光靠纸面推导很多细节是记不牢的。我比较推荐的做法是找一个小型流水线模拟器把指令序列跑一遍对比自己手工画的时空图。验证时重点看几个东西每个周期的流水寄存器内容是否正确、停顿周期的插入位置是否和预期一致、分支预测失败时冲刷了几条指令。一个很有效的自检方法是构造对照实验。比如同一个指令序列分别关闭转发、打开转发看总周期数差多少或者把两位预测器换成一位看循环类代码的预测失败次数涨了多少。这种对比比单纯跑一遍更能暴露理解的盲点。我在第一次做 Tomasulo 实验时就是因为没有对照开关转发的结果误以为广播逻辑写错了排查了两个多小时才发现是保留站的操作数就绪判定条件写反了。提示做这类实验时一定要先把单条指令、无冒险的序列跑通再逐步加入冒险情形。一次性上复杂序列出错之后很难定位是控制逻辑问题还是数据通路问题。另外性能计数器的读取也有讲究。很多模拟器默认统计的是已提交指令数和总周期数但如果在统计开始和结束时没有精确对齐多算了几个周期就会让 CPI 偏差好几个百分点。我的做法是在统计区间两端各插入一条特殊的标记指令模拟器遇到标记才开始和结束计数这样能避免边界误差。7.3 从这一讲往下的延伸方向把冒险和调度这部分搞清楚之后往下走的方向其实很清晰一个是多发射和超长指令字另一个是存储层次和访存优化。前者研究的还是怎么让更多的指令同时跑起来后者研究的是怎么让等待内存的时间尽量被摊薄。两条线的底层逻辑其实是相通的——都是在指令流的空隙里找并行度只不过一个从计算侧找一个从存储侧找。我自己的体会是这一讲的内容如果只停留在背结论过两周就忘得差不多了但如果是自己动手推过一遍转发路径、数过一遍停顿周期、跑过一遍模拟器那些数字和条件会变得非常具体之后再看到任何一款处理器的结构图脑子里都能自动对应到具体部件上。这个从记到推的转变大概就是这一节真正的门槛所在。