ARTICLE DETAIL

资讯详情

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

计算机体系结构流水线冒险:结构、数据、控制冒险与前递、分支预测

计算机体系结构流水线冒险:结构、数据、控制冒险与前递、分支预测 1. 先搞清楚流水线为什么会卡壳学到《计算机体系结构》第3章流水线技术2大多数人都会在同一个地方栽跟头——冒险。前半段讲流水线基本概念、性能公式还好受一到数据相关、控制相关、前递通路、停顿周期数这些概念脑袋就开始发胀。教材上的时序图看着挺规矩自己一画就错行尤其是几所高校常用的教学与习题指导里那种带星号的综合题稍微一绕就翻车。这篇东西我想换个讲法先把流水线为什么会在半路卡住这件事从根上讲透再一类一类地把结构冒险、数据冒险、控制冒险的处理手段拆开最后附上我踩过的坑和做题、动手实现时的排查办法。不管你是刚接触流水线的初学者还是已经学过一遍想再巩固的复习党照着往下读应该都能有收获。1.1 经典五段流水线与它的理想吞吐先把参照物立起来。最经典的 RISC 五段流水线把一条指令的执行切成五个阶段IF取指令从指令存储器读出一条指令PC 自增ID译码同时读通用寄存器EX执行ALU 运算或者计算访存地址MEM访存读或写数据存储器WB写回把结果写回寄存器堆理想情况下一条指令占一个阶段一个周期五个阶段错峰推进每个周期都有一条指令完成稳态下CPI 等于 1。这个模型美得像装配线工人 A 拧螺丝、B 装外壳、C 检验、D 打包只要工位不空产品就源源不断流出来。问题在于装配线是靠每个工位都能独立干活、上游产物永远及时到位这两个假设撑住的。处理器里这两个假设随时会被打破。上游的运算结果可能还没算出来下游的指令就已经伸手要了执行单元在同一时刻可能被两条指令争抢而只要一遇到分支后面取进来的指令可能压根就不该执行。这三种打破假设的情形就是课本里的结构冒险、数据冒险和控制冒险。后面几节就顺着这三条线索走。1.2 三类冒险的发生位置与快速判断法先给一张对照表把三类冒险的作案现场和判断方法摆出来做题时对着表逐条筛比凭感觉靠谱得多。冒险类型触发条件常发生阶段典型指令组合主要化解手段结构冒险硬件资源在同一周期被争用IF 与 MEM 争存储器、功能单元冲突取指与访存同时发生部件分离、资源复制、插入停顿数据冒险指令之间存在数据依赖RAW 为主ID、EX 前后ADD R1,...后紧跟SUB ...,R1,...前递、停顿、乱序调度控制冒险分支/跳转改变了取指方向IF 与分支结果产生时BEQ、BNE、J等延迟槽、分支预测、冲刷判断方法我总结成一句话先看有没有资源撞车再看有没有前后指令用同一寄存器最后看有没有分支改变流向。三条按顺序过一遍基本不会漏。要注意的是很多综合题会把三类冒险叠加在一起比如load 后紧跟一个分支分支延迟槽里还放了条要用 load 结果的指令这时候就得逐周期地画时序表静态的表格对照只能帮你定位不能替你算停顿数。有一点初学者常搞混数据冒险里的 RAW、WAR、WAW 不是平级的。RAW先写后读是真相关任何流水线都躲不开必须处理而 WAR先读后写和 WAW先写后写属于名字相关只有当你引入乱序执行、写回顺序被打乱时才会冒出来它们在顺序流水线里根本不存在。搞清楚这个前提后面讲动态调度时你就不会觉得寄存器重命名是多此一举了。2. 结构冒险硬件资源撞车之后怎么办结构冒险的本质不是数据问题也不是控制流问题而是物理资源不够用。同一个周期里两条指令都想用同一个部件硬件只能服务一个另一个就得等。它最直观、最好理解但也最容易被忽略因为很多人画时序图时默认取指和访存用的是两块独立存储器而现实中的早期处理器恰恰只有一块。2.1 结构冒险到底卡在哪几个位置最常见的两个冲突点是存储器端口和功能单元。先说存储器。如果指令和数据共用一块单端口存储器那么当流水线里一条指令处于 IF 阶段、另一条指令同时处于 MEM 阶段时两者都要访问这块存储器物理上只能做一次。这在一个五段流水线的稳态里几乎每周期都会发生——因为稳态下总有一条指令在取指、一条在访存。所以单存储器单端口的经典流水线根本无法满速运行这不是设计缺陷而是资源限制。第二个是寄存器堆端口。IF 不碰寄存器但 ID 阶段要同时读两个源寄存器WB 阶段要写一个目的寄存器。也就是说寄存器堆要能被两读一写并发访问需要至少两个读端口加一个写端口。早期为了省面积用的是单端口就得靠复用或者错峰代价就是停顿。第三个是执行单元。浮点乘除、访存单元这些往往只有一份一旦多条指令排队抢它也会形成结构冒险。这类冲突在多发射、超标量设计里尤其突出因为一个周期可能发射好几条指令全都想用同一个功能单元。2.2 三种典型化解手段及其取舍化解结构冒险的思路其实就三条分离、复制、等待。部件分离是最干净的一招。把原来合一的存储器拆成指令存储器和数据存储器也就是常说的哈佛结构IF 和 MEM 各走各的通路冲突瞬间消失。代价是存储空间实际翻倍但从性能角度这笔账非常划算现代处理器基本都采用指令 cache 和数据 cache 分离的布局。你可以把它理解成给装配线上游和下游各配一个仓库而不是共享一个中转站。资源复制指的是给功能单元多配几份。比如加两个 ALU、两条访存通路。它在超标量时代是常态但代价是面积和功耗上升而且如果指令本身发射宽度有限、用不满复制出来的单元那多出来的部分就是浪费。所以复制要结合实际的发射策略来定不能盲目堆数量。插入停顿是最后的兜底手段。当资源实在无法复制、又必须共享时就让其中一条指令暂停一个周期俗称插一个气泡bubble把资源让给另一条。这一招最省硬件但直接拉低吞吐属于能用前两招就别用它的下策。提示做题判断结构冒险时务必先确认题目给的是统一存储器还是分离存储器。这一个字的差别会直接决定是零停顿还是每周期都停很多错误都源于读题时默认了哈佛结构。2.3 一个存储器端口冲突的算例复盘举个具体的帮助理解。假设一条五段流水线只有一块统一存储器只支持单端口访问。现在执行下面这段没有数据相关、也没有分支的代码I1: ADD R1, R2, R3 I2: OR R4, R5, R6 I3: XOR R7, R8, R9理想五段流水线里I1 在周期 1 的 IF、周期 4 的 MEM 都要用存储器I2 的 IF 落在周期 2、MEM 落在周期 5I3 的 IF 在周期 3、MEM 在周期 6。你会看到 IF 和 MEM 的访问点错得比较开头几条指令还看不出问题。但进入稳态后麻烦就来了。设想 I1 在周期 4 访存而 I4 在周期 4 取指——两者都要存储器的同一个周期。如果强行在同一周期访问就得给其中一条插入停顿。因为冲突几乎每周期发生最终的结果是流水线无法达到 CPI1实际吞吐会掉到接近一半。这也正好说明为什么现代处理器宁可多花存储面积也要把指令和数据通路彻底分开。这个算例的价值在于提醒你结构冒险不产生在指令之间有没有依赖上而是产生在资源够不够分上。哪怕三条指令毫无关系只要抢同一份硬件照样停顿。3. 数据冒险前递与停顿这对搭档数据冒险是考试和实际设计里的重头戏。它源于指令之间的数据依赖后一条指令要用前一条指令还没写回的结果。如果什么都不做后一条只能在 ID 阶段死等前一条把结果写进寄存器堆再读出来白白浪费好多个周期。前递技术就是为了把这个等消灭掉而生的。3.1 RAW 才是主角WAR/WAW 先放一边前面提过顺序流水线里真正要处理的是RAW。典型场景I1: ADD R1, R2, R3 # R1 R2 R3 I2: SUB R4, R1, R5 # R4 R1 - R5依赖 I1 的 R1I1 在 EX 段末尾算出 R1但要到 WB 段才写回寄存器堆而 I2 在 ID 段就要读 R1。按流水线推进I1 的 WB 在周期 5I2 的 ID 在周期 3I2 读到的还是 R1 的旧值结果就错了。这是一个标准的 RAW。要处理它有两种主流办法前递也叫旁路 forwarding/bypassing和停顿。前递不改变指令的执行时序只是给数据开一条捷径让结果还没写回寄存器堆就先送到需要的执行单元停顿则是让 I2 真的等上两个或三个周期。实际设计里两者是配合使用的前递能覆盖大部分情况覆盖不了的角落才用停顿兜底。3.2 前递通路怎么搭数据从哪来前递的核心思路是EX 段算完的临时结果、MEM 段读出的数据都通过额外的数据通路直接引到 ALU 的输入端不必绕道寄存器堆。具体来说通常要接三条旁路旁路来源数据含义送到哪解决的场景EX/MEM 寄存器上一条指令的 ALU 结果下一条的 EX 输入相邻指令的立即依赖MEM/WB 寄存器上上条指令的结果 / load 数据下一条的 EX 输入中间隔一条的依赖MEM/WB 寄存器上上的 store 数据下一条的 MEM 输入store 需要前递数据判断该用哪条通路靠的是比较源寄存器和流水线寄存器的目的寄存器。如果 EX 段指令的源寄存器编号和 EX/MEM 寄存器里保存的目的寄存器编号一致而且上一条确实要写寄存器那就从 EX/MEM 前递如果和 MEM/WB 的目的寄存器一致就从 MEM/WB 前递。同时命中时优先用 EX/MEM因为它更新鲜数据更接近当前周期。这个机制你可以这样理解快递本来说好要走发货→中转站→收货点前递相当于让快递员在中转站门口直接把手里的包裹递给下一位收件员省掉进出仓库的时间。数据一样只是路径换成了专用线时序就优化了。3.3 load-use前递也救不了的那一种不是所有 RAW 都能靠前递解决最典型的就是load-use 冒险I1: LW R1, 0(R2) # 从内存读数据到 R1 I2: ADD R3, R1, R4 # 用到 R1I1 的 R1 要到 MEM 段结束才拿到因为要从存储器读而 I2 的 EX 段要用 R1。除非你能让 I2 的 EX 推迟到 I1 的 MEM 结束之后否则 EX 输入端的 R1 还是空。前递能把 MEM 段末的数据引过来但前提是 I2 的 EX 得等 I1 的 MEM 完成。结果就是无论怎么前递都得插一个停顿周期让 I2 的 EX 顺延。这就引出一条经验规则凡是load 紧跟使用它的指令这样的组合至少插一个气泡。如果你在多发射或乱序的语境下可以通过调度器把一条不相关的指令塞进这个空档把气泡填满但那是属于动态调度的范畴静态流水线里只能老实停顿。这个结论特别容易出题比如让你数下面一段代码需要多少个停顿周期load-use 那一个千万不能漏。3.4 停顿周期怎么数才不会错数停顿这件事光背结论会翻车最好按逐周期对齐的方式推。技巧是先画每一阶段的周期号再把依赖指令的读点、算点标出来看数据在哪一刻才可用差值就是停顿数。以 3.1 里的 I1/I2 为例。I1 的 EX 在周期 3结果在周期 3 末可用I2 若不停顿EX 在周期 4此时需要 R1而前递可以在周期 4 从 EX/MEM 拿到 I1 的结果刚够用零停顿。这就是前递的威力。换成 load-useI1 是 LW其结果在周期 4 的 MEM 末才可用I2 的 EX 原本在周期 4此时数据还没出来只能推后到周期 5于是要插一个停顿。停顿造成 I1 和 I2 之间出现一个气泡整个流水线往后顺延一周期。我个人的习惯是只要涉及 load就默认先记一个停顿再检查后面几条能不能靠前递补回来。这样做多了看到题目就能条件反射地圈出 load 的位置。4. 控制冒险把分支带来的代价压下去控制冒险来自分支、跳转这类改变取指方向的指令。麻烦在于流水线是边取边译边执行的当分支在 EX 段算出到底跳不跳的时候后面几条指令早就进了流水线。如果分支真跳了这几条就是废的得冲刷掉重新取。冲掉的代价就是控制冒险。4.1 分支延迟槽与延迟分支最经典的一招是分支延迟槽规定分支指令后面紧跟的那条指令一定会执行不管分支跳不跳。编译器想办法把一条无论如何都要执行的有用指令比如循环计数器自增塞进延迟槽这样即使分支跳转这条指令也没白做相当于用一条指令的时间干了两件事。延迟槽的优点是硬件实现简单——流水线根本不用判断方向照着取就行。缺点也很扎眼一是槽里往往填不满找不到合适的指令时只能塞 NOP等于白搭一周期二是它把分支后一条必然执行这个反直觉的语义暴露给程序员和编译器写汇编时容易出事。所以现代主流架构大多放弃了延迟槽改用分支预测。4.2 静态预测与动态预测的分野静态预测在编译或设计时就把方向定死常见策略有总是预测不跳转forward not taken和向后跳转预测跳转、向前跳转预测不跳转BTFN。前者简单对于循环回边往后跳预测得很差但结合延迟槽也够用BTFN 则利用了循环大概率继续这个先验命中率明显更高。动态预测靠硬件在运行时记录历史。入门级是 1 位预测器记一个上次跳没跳但它在循环边界上会连续错两次改进版是 2 位饱和计数器用强不跳、弱不跳、弱跳、强跳四个状态做迟滞勉强能扛住偶尔的抖动。再往上还有相关预测、Tournament 预测器、以及基于分支历史的现代预测原理都是用更多上下文换更高准确率。预测方式依据典型命中率硬件成本总是预测不跳转固定策略约 30%~50%极低BTFN跳转方向地址方向约 60%~70%极低1 位动态上次结果约 70%~80%低2 位饱和带迟滞的历史约 85%~90%低相关/混合分支历史全局历史90% 以上高4.3 分支代价的量化计算要估算分支带来的平均开销用这个公式平均额外周期 分支指令占比 × 预测失败率 × 失败惩罚周期举个具体数。假设分支占全部指令的 15%2 位预测器命中率 90%预测失败的惩罚是 4 个周期要冲刷掉已取进流水线的几条指令那么失败率 10%平均额外周期 0.15 × 0.10 × 4 0.06周期/指令也就是说理想 CPI1 的机器考虑分支后 CPI 大约变成 1.06。看起来不多但如果换成总是预测不跳转、命中率只有 60%、惩罚同样是 4则平均额外周期 0.15 × 0.40 × 4 0.24CPI 直接涨到 1.24。这中间的差距就是把分支预测器从简单做到复杂的全部意义。算这道题的关键是分清失败率和命中率别一个手快把 90% 代进去算那就南辕北辙了。5. 动态调度让静态流水线活起来前面的前递、停顿、预测都是在指令按程序顺序发射、按顺序推进这个框架里做优化。动态调度则更进一步允许指令乱序执行、乱序完成只要不破坏数据依赖的正确性就行。它把等这件事从硬件停顿变成了软件/硬件联合的调度是理解现代处理器流水线的分水岭。做这一章的综合题只要牵涉多条指令争同一个功能单元、还要保证结果正确基本就是往这个方向考。5.1 记分牌算法第一个吃螃蟹的调度器记分牌Scoreboard的核心思想是发射阶段就判断资源够不够、有没有 WAW 冲突能发就发执行阶段再等操作数齐了才放行。它把一条指令的生命周期拆成四个阶段发射Issue功能单元空闲、且没有其他指令正在写同一个目的寄存器才发射读操作数Read Operands等源操作数都就绪从寄存器堆或前递总线读入执行Execute进功能单元运算写结果Write Result没有 WAR 冲突时写回寄存器堆记分牌维护三张表指令状态表、功能单元状态表、寄存器结果状态表。发射阶段用功能单元状态表判断资源读操作数阶段用寄存器结果状态表判断数据是否就绪。它已经能做到乱序执行和部分乱序写回但有个明显短板——没有寄存器重命名WAR 和 WAW 得靠停顿甚至阻塞发射来解决所以并发度有限。理解记分牌是为了对比后面的 Tomasulo。5.2 Tomasulo 与寄存器重命名Tomasulo 算法在记分牌基础上做了两件大事引入保留站Reservation Station和公共数据总线CDB并借寄存器重命名消除名字相关。保留站的作用是给每条等待中的指令一个待命席指令发射后不再回寄存器堆读操作数而是去保留站里等。保留站里保存的是我的操作数来自哪个功能单元这样的标签一旦对应功能单元算出结果通过 CDB 广播保留站就能即时捕获。这套机制的好处是结果一算出来就广播给所有等待者不需要挨个查寄存器。寄存器重命名解决的是 WAR/WAW。举例I1: MUL F0, F2, F4 # 写 F0 I2: ADD F0, F6, F8 # 也写 F0与 I1 形成 WAW因为两条都写 F0顺序执行时必须保证 I1 先写、I2 后写否则结果错。但如果把 I2 的目的寄存器偷偷换成一个内部物理寄存器 T1写的时候写 T1 而不是 F0最后再让 T1 和 F0 建立映射WAW 就消失了。同理可以消除 WAR。重命名本质上就是给每个写入动作配一个唯一的内部名字让不同指令之间不再因为共用同一寄存器名而互相牵制。5.3 保留站与公共数据总线的配合细节Tomasulo 的时序链条是这样跑的指令发射时如果源操作数已经在寄存器堆里就顺手读进来如果还没算完就记下由哪个保留站产出的名字等 CDB 广播时匹配。一旦保留站里所有操作数都齐了这条指令就可以离开保留站进入功能单元执行执行完把结果打到 CDB 上所有在等这个结果的保留站和寄存器都同时更新。这里有几个容易被忽略的细节。第一CDB 是稀缺资源一般只有一条或几条同一周期多个功能单元想广播结果就得仲裁谁先广播谁后广播会影响调度结果。第二load/store 通常单独用一组缓冲区因为它们和 ALU 操作的行为不同不能混在一个保留站池里。第三由于结果广播可能乱序最终写回寄存器堆的顺序也必须靠额外的机制保证不能想当然。从记分牌到 Tomasulo 的进化实质上完成了从停顿等资源到重命名消名字相关、乱序抢资源的跨越。现代处理器的乱序执行引擎基本都是这条路线的延伸。6. 常见问题与排查技巧实录概念讲完剩下的就是实打实的操作细节。这一节专门整理我在做题和动手写流水线仿真时反复踩的坑附上排查思路供你对照。6.1 画流水线时序图的三步核对法题目里最容易出错的就是数周期尤其是数停顿周期。我的固定套路是三步先确定每条指令的各阶段基准周期不考虑任何冒险按 IF/ID/EX/MEM/WB 顺序排出周期号。标注依赖点和数据可用时刻找到那些后一条要用前一条结果的地方标出前一条结果实际在哪个周期末可用。对齐依赖点补停顿数据可用时刻晚于消费时刻就补停顿并把这之后所有阶段整体后移。很多同学的错误在于只对某一条指令补停顿忘了把后面所有指令一起顺延结果图表看着对一拍总数就错。牢记停顿是流水线的行为会让整个后续流一起往后挪。6.2 习题里最容易错的几个点我整理了下面这张速查表都是高频翻车区易错点错误做法正确做法load-use 停顿认为前递万能不插停顿至少插 1 个气泡分支失败率把命中率当失败率算失败率 1 − 命中率结构冒险前提默认分离存储器读题看是否统一存储器WAR/WAW在顺序流水线里也去找顺序流水线不存在 WAR/WAW停顿顺延只停一条指令后续指令整体后移前递优先级同时命中时随便选一条优先 EX/MEM数据更新注意考试里常见的给出代码序列求执行总周期数这类题判分往往卡在两个细节上——load-use 的那一个停顿以及分支预测失败带来的惩罚周期。把这两个数点出来基本就稳了。6.3 关键参数计算速查最后把几个最常用的公式列在一起方便随手核对。理想吞吐[ CPI_{ideal} 1 ]含数据冒险的 CPI[ CPI 1 \frac{\text{总停顿周期数}}{\text{指令总数}} ]含分支惩罚的 CPI[ CPI 1 f_{branch} \times (1 - p_{hit}) \times k_{penalty} ]加速比流水线 vs 非流水线[ S \frac{T_{seq}}{T_{pipe}} \approx \frac{k}{1 \text{停顿率}} ]其中 k 为流水线段数流水线效率[ E \frac{S}{k} \frac{1}{1 \text{停顿率}} ]用这些公式时特别提醒一句加速比公式里的 k 指的是段数别和分支惩罚周期混淆。我见过有人把 5 段流水线的加速比写成5/1.06结果被批注没有考虑流水线本身带来的理想加速上限。理想加速比的上限就是段数 k这跟理想 CPI1 是一回事的不同说法。我个人在实际做题和写仿真脚本时体会最深的一点是流水线技术的难点从来不在公式而在时序直觉。公式是死的可你面对一段陌生代码时能不能一眼看出哪里会撞资源、哪里会等数据、哪里会走错路靠的是大量画图积累出来的手感和对硬件行为的理解。我早期图省事对着结论硬背一到稍微变形的题目就露馅后来逼着自己把每条指令的五个阶段老老实实画在格子纸上画了几十张之后那些冒险点就像红灯一样自己跳出来。这个笨办法我现在还推荐给身边的同学。顺着这个思路这个内容后面还能继续往下挖如果想练乱序执行可以自己写一个极简的 Tomasulo 模拟器把保留站、CDB、寄存器重命名的状态变化一条条打出来跑一遍就明白乱序到底乱在哪如果要应付难度更高的综合题可以先从两条 load 加一条 ALU 再加一个分支这种小组合练起再逐步叠加到十几条指令的规模把停顿数和分支惩罚分开统计。练到后来你会发现第3章这半截看似最难其实是最讲道理的一章——它不跟你玩虚的每一步停顿的背后都有明确的硬件因果。
返回列表