ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

指令调度与延迟分支实战:消除流水线冒险的完整思路

指令调度与延迟分支实战:消除流水线冒险的完整思路 计算机体系结构这门课做到实验三基本就到了一个分水岭。前两个实验如果还在熟悉指令集、数据通路那这个实验——指令调度和延迟分支——就是第一次让你真正站在编译器和硬件架构师的交叉点上思考问题程序不是写出来就能跑的怎么写、怎么排直接决定了流水线里那些晶体管到底在干活还是在空转。我在做这个实验的时候最大的感受是指令调度和延迟分支看起来是两个独立的知识点但在实验里它们是咬合在一起的。你把指令重排好了分支延迟槽填得不对照样白搭反过来延迟槽填得再漂亮前面的数据冒险不处理流水线还是得停。这篇文章我就把整个实验的完整思路、实操步骤和踩过的坑都梳理一遍代码示例都是可以直接照着跑的适合正在上体系结构课、被实验折腾得头疼的同学参考。1. 实验背景与整体设计思路1.1 流水线冒险到底在折腾什么要理解这个实验在做什么得先回到流水线本身。经典的五级流水线——取指、译码、执行、访存、写回——每个时钟周期理论上应该有一条指令完成执行CPI趋近于1。但这个理想值从来达不到原因就是冒险。冒险分三类结构冒险硬件资源不够用比如取指和访存争用同一个存储器数据冒险一条指令要用上一条指令还没算出来的结果控制冒险分支指令还没跳完流水线里已经塞满了不知道该不该执行的指令。这个实验的主角是后两个。数据冒险的典型场景就是lw后面跟着addlw $t0, 0($s1) # 第5拍才写回$t0 add $t1, $t0, $s2 # 第3拍就要读$t0在教科书式的五级流水线里add的执行阶段需要$t0的值但lw要等到访存阶段结束、写回阶段才把数据写进寄存器文件。如果不做任何处理add读到的就是旧值结果直接错掉。硬件上可以加前递forwarding旁路把lw访存的结果直接送到执行单元能解决一部分问题但lw后面紧跟使用它的指令时即使有前递add的执行阶段依然要比lw晚一拍才能拿到数据仍然需要一个stall。这个实验里你要做的不是加硬件而是改软件——通过编译器或者手工调整指令顺序把add往后挪让它晚一拍再执行。控制冒险则是分支指令带来的。比如beq指令在经典五级流水线里要到执行阶段末尾才能确定是否跳转、算出目标地址而它后面紧跟的两条指令已经被取进流水线了。如果跳转真的发生这两条指令就是白取的必须作废。为了保证程序正确最简单粗暴的办法是每次都停两拍等分支结果出来再取新指令。延迟分支的思路则是不白停让分支后面的两条指令照常执行等它们填满延迟槽再进入目标指令。1.2 为什么选择“静态调度延迟分支”这条路线这个实验的核心任务就是通过编译器级的指令重排静态调度来消除数据冒险带来的停顿同时利用延迟分支把控制冒险的损失降到最低。为什么不选动态调度MIPS R2000/R3000那个年代的经典架构是把这个任务交给编译器的。硬件动态调度比如Tomasulo算法、记分牌要等到90年代的高性能处理器才普及而且在教学实验里动态调度的复杂度极高你很难在课设周期内在一个简单的模拟器上看到清晰的效果。静态调度则直观得多你看着汇编代码手动挪几条指令然后对比流水线停顿周期数的变化。延迟分支也是如此它是RISC架构针对控制冒险的一种经典妥协方案。分支指令后面的那条或那几条指令无论分支是否跳转都会执行把空转周期变成有效计算。相比现代处理器动辄几万条目目的分支预测器延迟分支的实现成本低得可怜但它对编译器的要求很高——你得保证被挪进延迟槽的指令在跳转和不跳转两种情况下都能安全执行。说白了这个实验是在模拟真实编译器后端做指令调度的过程只不过你同时扮演了编译器和架构验证者两个角色。1.3 实验目标与合理预期做这个实验之前建议先明确三个目标第一能肉眼识别数据冒险尤其是lw之后的RAW冒险并熟练运用指令调度消除它。第二理解延迟槽的概念和填充策略知道什么指令能安全挪进去什么指令不能。第三能够量化评估优化效果——这不是玄学你要能算出优化前后流水线CPI的实际变化。实验环境方面如果你所在学校用的是WinMIPS64、MARS这类MIPS模拟器或者课程自带的流水线模拟器都没问题原理完全一致。关键不在于工具而在于你真的去数了流水线每一拍的执行情况。2. 指令调度的核心原理与实操要点2.1 数据冒险的分类与判定方法数据冒险分三种写后读RAW读后写WAR写后写WAW。在顺序执行的五级流水线里WAR和WAW理论上不会出现——因为指令都是按顺序进入流水线的写回也按顺序发生。你真正需要重点关注的是RAW冒险。RAW冒险的判定方法很简单一条指令要读某个寄存器而它前面的某条指令要写同一个寄存器且这两条指令的执行阶段有重叠。实际操作中我提供一个快速判断规则往前看两条指令。如果当前指令的源寄存器等于上一条或上两条指令的目的寄存器大概率有冒险需要进一步检查流水线时序。比如sub $t0, $t1, $t2 # 第3拍算出$t0 and $t3, $t0, $t4 # 第3拍需要$t0值这种情况下sub的执行阶段在第3拍结果第4拍才能写回and第3拍执行时会读到旧值。判定为RAW冒险。有一种情况容易忽略lw之后隔一条普通ALU指令再用结果同样可能冒险。因为lw的结果要等访存段结束即使加上前递使用它的指令也得在lw之后至少一拍才能读到正确值。lw $t0, 0($s1) addi $t1, $s2, 1 # 这条没用到$t0 or $t2, $t0, $t1 # 这条用了$t0算下来离lw只隔了一拍可能还需stall这种半隐藏的冒险在实验里最容易漏掉。2.2 指令调度怎么操作以“lw后紧跟使用”为例先看一段典型的需要调度的代码lw $t0, 0($s1) # load A[0] add $t1, $t0, $s2 # $t1 A[0] B紧贴lw使用必然stall sw $t1, 0($s3) # 存储结果在WinMIPS64或者自带的五级流水线模拟器上跑add那一拍会插一个气泡CPI变成了2。调度的思路很简单把不依赖$t0的指令插入lw和add之间。假设后面还有一段独立指令lw $t0, 0($s1) addi $t3, $zero, 10 # 这句和$t0无关 add $t1, $t0, $s2 sw $t1, 0($s3)把addi挪到lw后面原本的stall就消失了CPI回到1。这就是指令调度的本质用不相关的指令填掉流水线的气泡让每个周期都有指令在真正干活。需要特别注意的是不是所有指令都能随便挪。挪动时必须检查三条依赖关系会不会改变原有指令之间的数据依赖会不会改变存储访问的顺序比如两次store到同一地址会不会破坏分支指令的语义。前两条可以在论文里实现第三条是下一节的重点。2.3 循环展开与调度把理论用到实际循环里单独调度一条lw之后跟一条add太小儿科了实验里的重点考察场景是循环。举个例子下面的代码完成数组求和loop: lw $t0, 0($s1) # 取 A[i] add $t2, $t2, $t0 # sum A[i] addi $s1, $s1, 4 # i bne $s1, $s2, loop # 若in则继续这个循环里每轮迭代内部就有一个冒险lw紧挨着add每轮都要停一拍。同时addi更新地址和bne决定跳转又引入控制冒险。怎么优化先做循环展开。把循环体复制两份展开因子为2然后对展开后的指令做重排loop: lw $t0, 0($s1) # A[i] lw $t3, 4($s1) # A[i1] add $t2, $t2, $t0 # sum A[i] add $t2, $t2, $t3 # sum A[i1] addi $s1, $s1, 8 # 地址更新一次跳2个元素 bne $s1, $s2, loop这里就把两个lw放在一起先把两个内存值都取回来再做两次加法。这样A[i]的加法使用$t0时离lw隔了一条lw $t3足够消除RAW冒险A[i1]的加法同理。相比原始循环每个元素平均少了半拍的停顿循环开销分支和地址更新也被摊薄了。展开因子的选择要有讲究。不是越大越好展开因子过大会导致指令缓存压力增大寄存器不够用也会溢出到栈上。教学实验里展开2到4次最合适既能看清效果又不至于让代码失控。3. 延迟分支的实现与调度策略3.1 延迟槽到底是什么东西延迟分支的基本思想分支指令在判定跳转之前流水线里已经取入了几条后续指令就让这几条指令先执行完再进入目标地址取指。MIPS经典设计是单延迟槽也就是分支指令后面那一条指令——不管分支跳不跳它都会被执行。这个设计听起来有点违反直觉分支后面那条指令如果是计算指令为什么跳转后也要执行这是由流水线物理时序决定的。在R2000这种经典实现里分支判定在译码段就有初步结果但流水线已经取了后续一条指令与其让这条指令作废不如规定它必定执行这就是延迟槽。编译器要做的事情就是找一条“无论如何执行都不会出错”的指令把它填入延迟槽。如果你把一条普通指令直接放在bne后面没有任何特殊处理那这条指令在语义上是不正确的——跳转后它已经执行了但程序员和编译器可能都没有意识到这一点。所以实验里填延迟槽之前一定要确认这活到底是编译器帮你干的还是模拟器自动填NOP还是需要你手动把一个有效指令挪进去。3.2 三种延迟槽填充策略对比编译器把指令调度进延迟槽有三种策略各有适用的场景。从分支前调度From Before选分支指令前面那一条不相关指令挪进延迟槽。这是最优策略因为这条指令无论如何已经在分支之前准备执行了挪到分支后就相当于提前执行了它不影响任何语义。条件是它不能和分支指令本身有寄存器依赖。从目标处调度From Target选跳转目标处的第一条指令挪进延迟槽。限制很大只有当分支发生时该指令才应该执行如果分支不发生把它挪进延迟槽就错误了。所以编译器必须保证该指令在分支不发生时执行也没有副作用比如addi而不修改条件码MIPS没有条件码主要是注意寄存器冲突。从失败路径调度From Fall-Through选分支不跳转时执行的那条指令挪进延迟槽。和上面的情况正好相反必须保证分支发生时它执行了也无妨。为了更好地对比我整理了这个表策略指令来源安全性条件典型适用场景从前调度分支指令之前与分支指令无依赖即可循环体末尾分支前有独立指令从目标处调度跳转目标处第一条分支不发生时不改变结果循环体比较大目标处有安全指令从失败路径调度分支不跳转时的下一条分支发生时无副作用顺序执行为主、跳转概率低的代码实验题里最常见的组合是先做循环展开、做普通的数据冒险调度最后统一填延迟槽。填的时候优先用“从前调度”——从分支指令前面找一条已经调好的独立指令挪进去这是最安全也最好解释的。3.3 编译器如何识别可安全移入延迟槽的指令这个点值得单独拿出来说因为它实际上是实验报告的核心得分点——你要写清楚“为什么这条指令能挪进去”。判断标准就三个数据依赖检查、副作用检查、分支依赖检查。数据依赖检查就是看指令有没有读写分支指令会读写的寄存器。bne $s1, $s2, loop读了$s1和$s2那延迟槽里就不能放修改这两个寄存器的指令否则分支判定就变了。副作用检查针对从目标处调度和从失败路径调度如果分支实际不往这个方向走这条指令执行了会不会有事常见副作用包括异常除零、内存访问错误访存地址非法、以及修改了另一个分支要用的寄存器。分支依赖检查比较隐蔽尤其是在多个分支嵌套的时候。比如延迟槽里是一条bne的addi $s1, $s1, 4而下一个分支的指令还依赖$s1那这条addi就会被提前执行后面的分支判定条件就变了。这种问题在复杂控制流的实验题里很容易翻车。我个人的做法是填完延迟槽后把代码按“分支发生”和“分支不发生”两条路径分别仿真一遍对比寄存器最终值是否符合预期。这一步虽然繁琐但能拦下至少一半的错误。4. 完整实操在模拟器上完成指令调度和延迟分支验证4.1 实验环境准备与基线程序这节我用WinMIPS64的语法来展示因为它是教学模拟器里最接近经典MIPS R2000时序的而且能看到流水线气泡的图形化显示。如果你用的是MARS也能跑只是延迟槽行为需要在设置里确认一下默认可能关闭延迟槽模拟。先准备一段基线程序功能是统计数组中有多少个正数对应了分支数据冒险的典型场景.data array: .word 3, -1, 4, -5, 2, 0, 6, -2 n: .word 8 .text main: daddi $s1, $zero, 0 # i 0 daddi $s2, $zero, 0 # count 0 daddi $s3, $zero, 8 # n 8 daddi $s4, $zero, 0 # 数组基地址 loop: beq $s1, $s3, done # 如果 in 结束 ld $t0, 0($s4) # 取 array[i] slt $t1, $zero, $t0 # 若 0 array[i] 则 $t11 beq $t1, $zero, skip # 若非正数跳过count daddi $s2, $s2, 1 # count skip: daddi $s4, $s4, 8 # 地址864位下每个字8字节 daddi $s1, $s1, 1 # i j loop done: halt这段程序的写法故意很“天然”——没有考虑任何延迟槽和冒险它的功能是对的但CPI很差。4.2 基线性能测量先把坏消息看清楚在模拟器上跑完这段程序建议先把IPC每周期指令数或者CPI记下来。以WinMIPS64为例跑完会给出cycle数和instruction count。我记得第一次跑的时候8个元素的数组就跑了200多个cycle算下来CPI超过3。里面有两处主凶第一ld后面的slt紧贴使用产生数据冒险stall第二每一条beq后面直接跟了下一条要执行的指令如果没有延迟槽填充和stall消除每条分支都要额外停几拍。而且这个程序里分支密度极高——每处理一个数组元素就有两次分支控制冒险的代价被放大了。这一步的重点是不要追求跑得快先把基线数据记录清楚。后面每做一步优化就对比一次数据实验报告才有说服力。4.3 指令调度阶段消除数据冒险先把ld和slt之间的stall处理掉。方法还是老套路在ld和slt之间插入一条独立指令。观察循环体daddi $s4, $s4, 8地址更新和daddi $s1, $s1, 1循环计数增加都不依赖$t0可以挪上来loop: beq $s1, $s3, done ld $t0, 0($s4) daddi $s4, $s4, 8 # 提前更新地址填掉ld和slt之间的间隔 slt $t1, $zero, $t0 beq $t1, $zero, skip daddi $s2, $s2, 1 skip: daddi $s1, $s1, 1 j loop注意我把地址更新挪到了ld之后、slt之前修改后的代码对$t0没有影响对$s4的修改也不再影响ld的访存地址因为ld已经执行完了。这一步做完ld的数据冒险stall基本消除。再次模拟cycle数应该有明显下降。如果没降先检查自己的模拟器是否默认开了延迟槽开了的话还要考虑分支后面的那条指令有没有被当作延迟槽指令处理这会干扰你的判断。4.4 延迟分支填充三种策略的实战对比现在处理分支。目前所有分支后面直接跟着下一条要执行的指令这在没有延迟槽的模型下没问题但在有延迟槽的模型下每条分支后都会多出一个隐式的NOP周期因为分支判定结果要等一两个周期才出来后续取指被暂停。WinMIPS64里可以在设置中开关“delayed branching”。打开之后每条分支或跳转指令后面有一条延迟槽——要么你显式填一条指令要么硬件自动塞NOP。我们需要做的就是把NOP换成有效指令。先处理循环末尾的跳转skip: daddi $s1, $s1, 1 j loopj loop的延迟槽可以填bne。不行bne也是分支分支套分支晦气得很。填daddi $s1, $s1, 1它已经被填在skip后面了不能重复填。看来看去j loop的延迟槽最合适的候选是daddi $s1, $s1, 1那就把daddi $s1, $s1, 1挪到j loop后面作为延迟槽执行skip: j loop daddi $s1, $s1, 1 # 延迟槽无论跳转与否都执行这个移动安全吗daddi $s1, $s1, 1修改的是循环计数器在原始代码中它本来就在每次迭代末尾执行。把它放进延迟槽唯一的区别是它从“jump之前的那一拍”变成了“jump之后的那一拍”但执行结果完全相同因为没有其他指令在jump前后读取$s1。再看循环开头和中间的beq也用同样策略。处理beq $t1, $zero, skip时可以把它后面的daddi $s2, $s2, 1填进延迟槽不行——如果跳转真的发生skip处的指令会跳过daddi $s2, $s2, 1。放进延迟槽后跳转发生时它也会执行导致count被错误加一。这属于典型的“从失败路径调度”只有当分支不发生时才安全。这里正好反过来跳转发生时不该执行daddi $s2所以不能填。填什么看状态此刻beq $t1, $zero, skip判断的是$t1修改$t1不行它不读其他寄存器所以从后面找一条和$t1无关的指令。daddi $s1, $s1, 1修改$s1daddi $s4, $s4, 8修改$s4都不影响$t1。但daddi $s4已经被挪到上面过了不能再挪一次。只能把daddi $s1, $s1, 1填进来beq $t1, $zero, skip daddi $s1, $s1, 1 # 延迟槽跳转发生时$s1仍然自增1然后跳到skip。而skip处的指令不能再重复执行daddi $s1否则计数就错了。所以需要相应调整skip处的代码把那里的daddi $s1删掉。到这里实验报告里最想看到的那种“把延迟槽填成有效指令而不是NOP”的代码就出来了。每次跑完都对比一下cycle数延迟槽优化和指令调度优化的收益就清清楚楚了。4.5 模拟器上的验证清单完成代码修改后按下面的清单做一轮完整的验证先跑原始版本记录CPI和cycle数。加入延迟槽NOP看清分支延迟的基准代价。做指令调度填数据冒险间隔记录cycle数变化。做延迟槽有效填充记录cycle数变化。扩大数组规模比如16个、32个元素重新测一遍观察收益变化趋势。最后用寄存器验证指令功能是否正确确认优化没改语义。如果手上没有现成模拟器自己用Python写一个小型五级流水线模拟器也可以核心逻辑就是维护每一条指令的阶段状态、寄存器的写回时间戳、以及分支延迟槽的取指逻辑。这个工作量大一些但做完之后对流水线的理解会深得多。5. 常见问题与排查技巧实录5.1 延迟槽指令“被跳过”导致的错误这是做延迟分支实验最容易踩的坑。很多同学把一条指令放进延迟槽之后没有意识到一旦分支跳转这条指令依然会执行。于是本来只在某个分支路径上执行的指令被执行了两次或者在不该执行的时候执行了。排查方法把程序在“分支成立”和“分支不成立”两条路径上分别手工模拟一遍检查涉及的关键寄存器的变化。特别是循环计数器、数组指针、累加器这三个“容易出事”的寄存器优先检查它们的读写顺序。5.2 指令调度之后结果不对寄存器冲突调度lw后面紧跟的指令时容易把一条修改$s1数组基址的指令插入到lw和slt之间结果lw读的是旧地址还是新地址就取决于你插在哪。如果插入在lw之后lw已经完成了访存那么修改基址没问题如果插入在lw之前那lw就会读到错误的地址。这种问题在单条指令上看很清晰但代码一长就容易糊涂。我的排查办法是每做完一次调度立刻画出该小段的指令序列表格标出每条指令的依赖链检查是否引入了新的RAW冲突。5.3 循环展开后的边界处理出错展开因子为2的循环要求数组长度是偶数。实验里数据长度写死为8没问题但如果后续你改成动态输入就要处理奇数长度的剩余迭代。很多同学忘了给展开后的循环补一个处理剩余元素的小尾巴程序就会越界访问内存。正确做法是先算n % 2如果余1先处理一个元素再进入展开循环。或者在循环结束后额外判断一次补上缺失的迭代。5.4 一个实用的整体排查流程如果代码运行结果不对按这个顺序查效率最高先用单步模式跑一遍记录第一条结果出错的指令位置。检查该指令的源寄存器值是否和预期一致。检查产生这个源寄存器的上一条指令是否因为调度或延迟槽而提前/延后执行。如果都没有问题检查分支方向和延迟槽指令是否弄反了。实在查不出来把延迟槽指令全部换成NOP重新跑如果结果正确说明问题出在延迟槽填充而不是数据冒险调度。我在做这个实验的时候最后就是用NOP替换法锁定了一个隐蔽问题分支延迟槽里的指令和下一个分支指令之间又形成了新的数据依赖导致连锁错误。6. 实验之外的一点体会整个实验做下来我最深的感受是指令调度和延迟分支本质上是一体两面都是在“流水线有空档”的地方塞入有用的活儿。区别在于指令调度填的是数据冒险带来的气泡延迟分支填的是控制冒险带来的空窗。一个合格的编译器后端就是在反复做这两件事找到依赖、重排指令、把每个周期都填满。如果你后续还要做更深入的实验可以试着把这里的静态调度和后续的数据通路改造结合起来比如加上前递单元后看哪些冒险被硬件吃掉了哪些仍然需要编译器配合。那种“软硬协同”的感觉才是计算机体系结构最有魅力的地方。最后分享一个实操小技巧每次改动代码之前给版本加个编号模拟结果用表格记录下来。表格列可以是“版本说明、cycle数、指令数、CPI、关键错误”。有了这个表你的实验报告不需要额外编直接就是一份完整的数据分析。这个习惯我从实验三开始一直用到毕业设计省了无数返工的时间。
返回列表