ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

五级流水线冲突详解:转发、分支预测与Tomasulo

五级流水线冲突详解:转发、分支预测与Tomasulo 上一篇文章把五级流水线的时空图来回画了三遍你大概率会生出一种不过如此的错觉IF取指、ID译码、EX执行、MEM访存、WB写回五个阶段像五个人接力赛跑理想状态下每拍就交付一条指令加速比趋近于级数。可只要把一段真实的汇编扔进去——带分支、带访存、带寄存器写后读——那张漂亮的理想时空图立刻散成一地碎片。这就是计算机体系结构里流水线技术2真正要啃的硬骨头流水线从来不是一条畅通的传送带它更像早高峰的环形立交任何一处并线、抢道、前方急刹都能把整条线路拖成停车场。接着上一部分往下走这篇把结构冲突、数据冲突、控制冲突这三类堵点逐个拆开再把转发、停顿、编译期调度、分支预测、记分牌、Tomasulo这些听上去很唬人的调度手段讲清楚它们各自在解决什么问题。内容偏向实战理解而不是名词罗列适合正在学这门课、准备期末考试或者考研或者已经工作但想把体系结构这块地基重新夯实的人。胡伟武老师那本《计算机体系结构教学与习题指导》里关于相关的分类讲得很清楚我在下面会结合那道经典的五级流水线的思路展开但会把教材里一笔带过的为什么补上。1. 先把相关和冲突这两个词掰开揉碎很多人学到这里第一个坎不是不会算而是把相关和冲突当成同义词在用做题时概念一混后面全盘皆输。1.1 相关是程序的性质冲突是硬件的麻烦相关Dependence是程序本身固有的属性它由指令之间的数据流动和程序的控制流决定跟你用什么流水线、什么主频没关系。指令 i 写了一个寄存器指令 j 后来读它这两条指令之间就存在数据相关这是指令集和程序语义决定的客观事实。冲突Hazard是相关在具体流水线组织下产生的时序麻烦。同样一对写后读的指令放在一条能转发、能乱序执行的流水线上可能一拍都不用停放在一条最朴素的顺序五级流水线上就得老老实实插入两个气泡。相关是病根冲突是症状症状轻重取决于流水线的体质。这个区分看起来学究但它直接决定你做题时的思路顺序先看程序里有什么相关再看这条流水线对这些相关有没有缓解手段最后才落到插几个nop、停几拍的计算上。反过来做本末倒置一遇到复杂的题目就慌。1.2 三类冲突在五级流水里各自的落点流水线冲突通常分三类我习惯按它们卡在哪个阶段来记冲突类型本质典型触发场景卡住的阶段结构冲突硬件资源不够取指和数据访存抢同一个存储器功能单元未流水化IF 与 MEM 同时要用存储器数据冲突数据流动被打乱写后读RAW、读后写WAR、写后写WAW多在 EX 与 WB 之间控制冲突下一条地址不确定分支、跳转、异常IF 拿不到正确 PC顺序流水线里真正常见的是写后读RAW因为指令是按程序顺序流动的读后写WAR和写后写WAW只有在乱序执行或者多发射的时候才会冒出来。这一点特别关键如果你以后再遇到乱序执行的题目为什么突然冒出WAR和WAW答案就是指令的实际完成顺序被打乱了后面指令反而先读/先写产生了名相关。提示名相关WAR、WAW可以通过寄存器重命名消除因为它们只跟用了哪个寄存器名字有关跟真实的数据流动无关。真相关RAW是消除不掉的只能靠转发或者等待。2. 结构冲突当硬件资源不够用时怎么选结构冲突是最容易被忽略的一类因为教材里往往一句话带过——将指令存储器和数据存储器分开。但真正理解它对后面理解访存带宽、多发射的资源约束帮助极大。2.1 指令与数据存储器为什么必须分家最经典的结构冲突场景五级流水线的第4级MEM要去访存取操作数而第1级IF要取指令如果两者共用同一个单端口存储器那同一拍里两个阶段必然打架。解决办法说白了就两条路。第一条是把存储资源物理分离指令Cache和数据Cache各管各的这就是哈佛结构思想在现代处理器里的延续。它牺牲了存储空间的灵活性不能再把空闲的指令空间拿给数据用换来的是取指和访存可以并行。第二条路是把存储器做成多端口或者流水化比如让存储器每个周期能接受一次访问、访问延迟多个周期这样IF和MEM可以错开使用代价是控制逻辑变复杂还可能引入新的访问延迟。实际工程里这两种方案经常叠加使用L1层面用分离的I-Cache和D-Cache再往下的L2往往是统一Cache。你要是在题目里看到访问L2会产生结构冲突那多半是因为L2是统一存储、需要仲裁访问次序。2.2 用寄存器堆端口数量算一笔账结构冲突不止发生在存储器上寄存器堆也很典型。一个标准的单发射五级流水线每一拍需要同时在ID阶段读两个源寄存器、在WB阶段写一个目的寄存器。所以寄存器堆至少要支持两个读端口加一个写端口否则ID和WB就会撞车。现在假设我们把流水线升级成双发射一拍要处理两条指令那ID阶段可能同时要读四个源寄存器、WB阶段同时写两个目的寄存器。如果寄存器堆只有两读一写双发射立刻产生结构冲突。这就是为什么多发射处理器的寄存器堆端口数是实打实的成本大头——每个端口都要独立的译码和传输电路端口一多面积和延迟蹭蹭往上涨。我个人的经验是做题时遇到某资源每拍只能用一次的描述先画一张资源占用表把每条指令每个阶段占用的资源逐拍标出来冲突位置一目了然。这个方法比在脑子里空想靠谱得多。注意结构冲突和功能单元没流水化是一回事的两面。比如除法单元如果是非流水化的占用4拍那后面紧跟着要用除法的指令就得等这也算结构冲突。3. 数据冲突转发能救什么救不了什么数据冲突是三类冲突里考得最多、也最能体现理解深度的一块。核心就一句话转发Forwarding/Bypassing是性价比最高的解法但它有物理边界。3.1 三条转发路径到底怎么连考虑下面这段最朴素的RISC汇编add x1, x2, x3 # x1 x2 x3 sub x4, x1, x5 # x4 x1 - x5 and x6, x1, x7 # x6 x1 x7add的结果 x1 在EX结束时就算出来了可它要到WB阶段才写回寄存器堆。而sub要在自己的EX阶段读 x1。按顺序流水线sub的EX阶段比add的WB阶段早两拍直接读会读到旧值。转发解决的问题是结果算出来了别等它写回直接从流水线内部抄近道送给需要用它的地方。理想情况下EX阶段的ALU输出可以同时喂给下一条指令的EX输入这样add和sub之间一拍都不用停。转发路径通常有三类EX到EX转发上一条指令的EX结果直接送下一条的EX输入解决背靠背ALU运算。MEM到EX转发上一条指令的访存结果或经过MEM阶段的ALU结果送下一条的EX输入覆盖间隔一条指令的情况。MEM到MEM / WB到EX转发处理store要用到前一条load结果等特殊情形。3.2 load-use 冲突转发也救不了的那一拍转发的边界在哪看这段lw x1, 0(x2) # 从内存读 add x4, x1, x5 # 立刻用 x1lw的数据要到MEM阶段末尾也就是访存完成才拿得到而add在EX阶段就要用。MEM阶段比EX阶段晚一拍即使有最激进的转发路径lw的数据也来不及赶上add的EX。所以必须插入一个气泡stall一拍让add的EX往后推一拍此时lw的数据已经可以通过MEM到EX的转发路径送到。这就是著名的load-use冲突至少要停一拍。注意至少两个字如果是更长的访存延迟比如L1 miss停的就更多如果是带cache的多周期访存还要看数据在哪个周期可用。3.3 编译期指令调度比插nop聪明的做法既然load-use必须等编译器能不能把别的指令塞进这个空档当然可以。看这段代码lw x1, 0(x2) add x4, x1, x5 sub x6, x7, x8 # 与前面无依赖编译器可以把sub提到add前面变成lw x1, 0(x2) sub x6, x7, x8 add x4, x1, x5这样一来lw之后那条槽被sub填上了add的位置往后挪了一拍正好等到了lw的数据一条停顿都不需要。这叫静态调度编译期调度代价是编译器要足够聪明而且调度的自由度受限于可挪动的独立指令数量。调度方式谁来安排优点局限硬件转发处理器对代码透明无需重编译救不了load-use救不了长延迟插入气泡硬件实现简单浪费性能编译期调度编译器不增加硬件依赖可挪动的独立指令受寄存器数量限制硬件动态调度处理器对代码透明且能跨基本块硬件复杂度高我踩过的一个坑是练习题里给一堆指令让你用编译期调度消除所有停顿结果发现可挪动的指令不够硬凑会导致寄存器名字冲突WAR。这时候要么增加寄存器数量要么承认确实有一拍消不掉。别为了凑零停顿硬调物理上办不到的事就是办不到。4. 控制冲突分支预测凭什么值得占那么多晶体管控制冲突的本质是流水线刚取到一条分支指令时还不知道该不该跳、往哪跳但IF已经按顺序去取下一拍了。如果分支真的跳了那几拍预取的指令全白费得冲掉重来。4.1 分支延迟与延迟槽的由来最早的MIPS处理器的做法非常耿直分支的效果延迟一拍生效分支后面那条指令延迟槽无论分支跳不跳都会被执行。编译器负责往延迟槽里塞一条无论走哪个方向都该执行的指令塞不进去就塞nop。延迟槽的设计其实是把控制冲突的处理从硬件转移到了编译器。硬件省了一堆判断逻辑但代价是ISA对程序员可见——你写汇编时必须意识到延迟槽里的指令一定会执行这给后面换实现带来了历史包袱。后来的RISC-V就干脆取消了延迟槽把这个决策交给了硬件预测器。4.2 从静态预测到两位饱和计数器分支预测的演化是一条很清晰的曲线固定预测不跳最简单遇到分支就默认不跳。程序里的循环分支大多往回跳所以这个策略在循环场景下错得很离谱。固定预测跳对循环友好但对if-then这种前向分支不友好。BTFNBackward Taken, Forward Not Taken往回跳的预测跳往前跳的预测不跳。这是静态预测里性价比很高的一种方向信息的命中率能有六七成。两位饱和计数器用两位状态记录某个分支最近的走向00强不跳、01弱不跳、10弱跳、11强跳。只有连续两次预测错误才会翻转到另一侧。为什么是两位而不是一位一位预测器在循环退出那一次必然错两位预测器能给循环留出一次容错。举个例子一个循环执行10次一位预测器每次退出都要错一次两位预测器则只在真正退出时错错得没那么频繁。4.3 BTB与返回地址栈把目标地址也预测了只有方向预测还不够跳转的目标地址也得知道否则照样要等译码。**BTBBranch Target Buffer分支目标缓冲**用一个小Cache按分支指令地址索引直接给出预测的目标地址。这样IF在取指的同时就能查到这条分支大概会跳到哪一拍都不用等。函数调用和返回是另一类特殊分支。返回地址其实是动态的取决于调用点静态预测很难准。解决办法是返回地址栈RAS调用时把返回地址压栈返回时弹栈预测这样即使多层嵌套调用也能预测得八九不离十。提示考试里经常问BTB命中和预测正确是不是一回事答案是不是。BTB命中只说明知道目标地址方向对不对还得看方向预测器。两者要分开判断。5. 动态调度记分牌和Tomasulo各自在解决什么前面讲的转发和停顿都是顺序流水线框架下的补救。到了动态调度思路变了与其让整条流水线等一条卡住的指令不如让后面的指令先走。这就是乱序执行的开端。5.1 记分牌够用但不够优雅**记分牌Scoreboard**是CDC 6600上用的经典动态调度机制。它维护三张表指令状态表每条指令当前进行到哪一步、功能单元状态表每个功能单元忙不忙、占用它的是哪条指令、寄存器结果状态表哪个寄存器将被哪条功能单元写入。记分牌的调度分四步发射、读操作数、执行、写结果。发射时检查WAW有没有别的指令要写同一个寄存器和结构冲突功能单元是否空闲读操作数时检查RAW源寄存器有没有被未完成的指令将要写入。如果条件不满足指令就在那里等但不阻塞流水线的发射阶段——后面的指令可以继续发射到别的功能单元。记分牌能解决的是RAW引起的顺序停顿让无依赖指令并行跑起来。但它有个硬伤它不支持寄存器重命名所以只要指令之间存在WAW或WAR后面的指令就发不出去。这在寄存器数量有限、程序里复用寄存器频繁的场景下性能提升很有限。5.2 Tomasulo用保留站和CDB把名字问题也解决了Tomasulo算法的核心创新是寄存器重命名 保留站 公共数据总线CDB。它不再让指令直接读寄存器而是把操作数变成两样东西值本身或者等哪个保留站算完给我。这一手就把WAR和WAW消掉了——因为指令要的不再是寄存器x1而是产生x1的那条指令的结果名字冲突自然不存在了。每条指令发射到对应功能单元的保留站等它的两个源操作数都就绪值到位或者被标记为等某个保留站。一旦就绪功能单元执行结果广播到CDB上所有等着这个结果的保留站同时抓取需要写回寄存器的也一起写回。看一个RAW链的例子ld f0, 0(x1) # 加载 add f4, f0, f2 # 依赖f0 mul f8, f0, f6 # 也依赖f0ld发射后进访存单元保留站。add和mul发射后发现源操作数 f0 还没到就记下我的操作数来自即将产生f0的那个保留站。当ld完成结果广播上CDBadd和mul同时抓取都不用等第二条去读寄存器。这就是CDB一发多收的威力——一份结果可以同时被多个等待者消费。机制寄存器重命名支持乱序主要限制记分牌否部分发射顺序执行乱序WAW/WAR卡发射Tomasulo是是需要CDB带宽硬件复杂5.3 我理解的动态调度本质说了这么多机制其实动态调度就一个朴素的思想把指令什么时候能执行这件事从编译期猜出来变成运行时看情况定。编译期不知道cache会不会miss、分支会不会跳错硬件跑起来之后这些信息都明确了于是它就有了更好的调度依据。代价是硬件复杂度、功耗和验证难度——这也是为什么乱序处理器设计周期动辄好几年。6. 做题和面试里最容易翻车的几个点这部分不成体系纯粹是这些年做题、帮人答疑、看面试题积攒下来的翻车重灾区。第一转发的假设太多。很多人默认只要背靠背就能转发、一拍不停但题目往往会额外说明ALU结果在EX末尾可用访存结果在MEM末尾可用这类条件一旦忽略算出来的停顿数就全错。我的建议是把每个阶段的结果可用时刻标在时空图上转发能不能救看图判断最快。第二混淆冲突导致停顿和冲突导致错误。数据冲突如果不处理是结果错不是流水线卡住。结构冲突和控制冲突如果不处理可能是执行错指令。两种后果性质完全不同题目问如果不加转发会怎样答案往往是读到旧值、结果错误而不是停顿。这个坑我见过不止一个人踩。第三多发射下的资源算账。双发射处理器IF要取两条指令、ID要译码两条、寄存器堆要读四个源……任何一环跟不上就是结构冲突。做题时先把各阶段的带宽列出来往往结构冲突自己就暴露了。第四分支预测的准确率不等于性能。预测错了的代价是多少拍、能不能提前恢复这些跟流水线深度强相关。同样的预测准确率在五级流水和十几级流水上性能损失差好几倍。别只盯着准确率数字下结论。第五延迟槽只对一定执行的指令成立。在异常或中断场景下延迟槽里的指令可能没执行完就跳走了这给精确异常处理带来麻烦。RISC-V取消延迟槽某种意义上就是为了简化异常和中断的处理。最后分享一个我自己的学习习惯每学完一种调度机制就拿同一个小程序我常用那段 load-use 加两条独立运算的片段在纸上手动跑一遍画出每一拍每个功能单元在干什么。记分牌和Tomasulo各跑一遍你会明显感觉到后者让后面的指令先走带来的差异。这种手推比反复看教材有效得多因为你会被迫回答这一拍为什么它不能发射它到底在等谁这些教材默认你懂的问题。
返回列表