结构
设计思路
从单周期到流水线
首先需要明白的是,单周期 CPU 和多周期 CPU 并没有本质上的区别
CPU 处理指令时,由于数据操作逻辑上的先后,不同的元件难以并行运行同一条指令
但如果将指令抽象地划分为几个互不影响的阶段,
不同的阶段便可以并行地运行不同的指令,也就是所谓的多周期 CPU
既然需要同时运行不同的指令,每个流水级就必须保存当前处理指令的信息。
这里的信息指的是当前流水级以及后续流水级需要完成操作所需要的全部数据。
在多周期 CPU 中,我们使用寄存器来保存信息并划分开不同的阶段
阶段的划分
本流水线 CPU 采取和教程一致的 5 级流水线结构,分别为:
流水级 | 简称 | 作用 |
---|---|---|
Fetch | F | 获取 PC 和指令 |
Decode | D | 获取寄存器值 |
Execute | E | 计算单元执行计算 |
Memory | M | 读写数据存储器 |
Writeback | W | 写入寄存器值 |
Decode
流水级的名称和用法不同,主要目的是保持与教程的命名一致
当然也可以采取不同的流水级划分方式,此处不再赘述
数据冲突(冒险)
CPU 由串行变为并行后,虽然单条指令不同阶段之间互不影响,
但是由于不同指令之间时序关系的存在,同时处理的指令可能会出现数据依赖关系:
当前指令无法获取前面指令中还未完成操作的数据。
便产生了数据冲突。
一个简单的解决方案是 阻塞 当前指令,等待前面指令完成再继续执行。
但是这样的阻塞在极端条件下会使得多周期 CPU 退化为单周期。
同时由于关键路径由不同阶段之和变为了不同阶段中的最大值乘上阶段数,反而导致了 CPU 效率的降低。
可以发现,在教程的 5 级流水线中,只有寄存器因为读写存在先后顺序从而导致数据冲突。后文中 数据冲突 和 寄存器值冲突 指同种情况。
倘若在数据真正写入寄存器之前,先将计算出来的结果给当前指令使用,那么就能够大大减少当前指令需要等待的周期。
比如在 ADD
指令中,真实的寄存器值在 E
级已经计算完成,就不必等到完成 W
级写入寄存器后再执行当前指令了。
这样的手段,称之为 转发
显然,即使经过转发,也不能完全解决寄存器值冲突。这样的情况下,我们仍然使用阻塞策略,等待寄存器值计算完成后取用
控制冲突(冒险)
跳转指令会导致整个指令流的变化,特别是分支类型的指令,在完成分支条件判断前根本无法确定后续的指令内容。
若在确定分支前继续取指,则可能执行错误的分支(指令)
和数据冲突一样,一个简单的解决方案是 阻塞 分支指令的后续指令,直到分支指令运算完成
同样的改进方案是,等到分支指令的判断条件完成,就直接依据该结果进行分支选择,类似于 PC
值的转发
同时,在不改变架构的前提下,尽量将分支指令的判断提前,从而尽快进行分支选择
本 CPU 基于 MIPS 指令集,可将分支指令判断设置在 D
级,在读取寄存器值后直接进行判断
若跳转分支, F
级的指令将会被清空,影响效率。可引入延迟槽来规避这一问题
延迟槽:紧跟在跳转指令后的指令,不管跳转指令是否执行,延迟槽指令都必然执行
延迟槽指令由编译器在翻译高级语言时自动选择,需要满足一定的条件。
需要满足的条件是什么呢?请自行思考
在绝大多数情况下,编译器都能选择出一条延迟槽指令从而提升性能。若无法选择延迟槽指令,只需在延迟槽中加入 nop
指令。
整体而言,引入延迟槽能使得运行速度提升
当然,现代 CPU 还有许多控制冲突的解决方案,比如分支预测等。此处不再赘述
流水线数据存储方式
前文中提到:同时运行不同的指令,每个流水级必须保存当前处理指令的信息
这里给出流水级信息储存的两种方式:
集中式译码流水
集中式译码:在 D
级将所有指令转化为控制信号
流水线存储:
- 需要使用的中间结果
- 后续需要使用的控制信号
分布式译码流水
分布式译码:将指令类型保存,在每一级分别译出控制信号
流水线存储:
- 需要使用的中间结果
- 指令类型
优劣
集中式译码:
- 流水级之间传递信号数量较大
- 增加指令和控制信号时不容易遗漏
分布式译码:
- 灵活
- 增加指令和控制信号时容易遗漏,需要合理设计默认行为
本 CPU 采取分布式译码
伪代码
1 | assign 指令 1 = instr match 指令 1 形式 |
tip: 若使能信号默认低电平,只需关注当前指令数据通路上的信号控制,其他通路数据最后将被忽略
阻塞/转发策略
倘若在数据真正写入寄存器之前,先将计算出来的结果给当前指令使用,那么就能够大大减少当前指令需要等待的周期。
比如在ADD
指令中,真实的寄存器值在E
级已经计算完成,就不必等到完成W
级写入寄存器后再执行当前指令了。
这样的手段,称之为 转发
我们可以直接按照上文的叙述方式构建一个最简单的阻塞/转发策略:
方案
- 指令在
D
级时,若当前指令需要读取的寄存器将被后续流水级中的指令写入,则产生数据冲突,发生阻塞/转发 - 在 阻塞/转发 状态下:
- 显然我们只需要考虑和
D
级寄存器冲突的距离D
级最近的流水级指令,称之为冲突指令 - 假设冲突指令仍未计算出写入寄存器的结果
- 阻塞当前指令,等待冲突指令计算结果
- 假设冲突指令恰计算出写入寄存器的结果
- 直接将计算结果取出,作为当前指令在
D
级取得的寄存器值
- 直接将计算结果取出,作为当前指令在
- 假设冲突指令在前面流水级中计算出结果还未写入寄存器
- 由于要写入,该结果肯定保存在流水级寄存器中
- 将流水级寄存器的值取出,作为当前指令在
D
级取得的寄存器值
- 显然我们只需要考虑和
问题/修改方案
问题:
- 判断是否处于 阻塞/转发 状态较为复杂
- 当前指令在
D
级读取的寄存器可能在E
级甚至更后面才会使用,提前请求数据浪费了时间
修改方案:
- 引入需要寄存器的编号
aUse
和时间tUse
- 引入写入寄存器的编号
aNew
和时间tNew
。逐级流水,tNew
依次减少 - 当前指令在
D
级时,计算出aUse
,tUse
,aNew
,tNew
- 考虑后续流水级中
aNew
==D
级aUse
并且距离D
级最近的指令,称之为冲突指令- 若冲突指令
tNew
> 当前指令tUse
,说明在当前指令使用之前所需寄存器值无法计算出来- 阻塞当前指令,等待冲突指令
tNew
不再 > 当前指令tUse
- 阻塞当前指令,等待冲突指令
- 若冲突指令
tNew
== 当前指令tUse
,说明在当前指令使用时所需寄存器值恰已经计算出来- 无需阻塞,当前指令取得错误寄存器值往下流水,等待更新
- 当该指令
tNew
==0
即恰计算出结果时,将对应值转发给当前指令所需部件
- 若冲突指令
tNew
< 当前指令tUse
,说明在当前指令使用之前所需寄存器值早已计算出来- 无需阻塞,当前指令取得错误寄存器值往下流水,等待更新
- 当该指令
tNew
==0
即已经计算出结果时,将对应值转发给当前指令替代原值进行流水
- 若冲突指令
问题/修改方案
问题:
直接使用计算部件转发结果,那么当前指令流水级(假设是 D
级)需要等冲突指令流水级(假设是 M
级)稳定,才能开始自身的计算。这使得当前指令流水级的关键路径变成了当前指令流水级的关键路径和冲突指令流水级的关键路径之和,同等时间下所能进行的周期数可能小于原先的一半(M
级关键路径较长)
修改方案:
- 在之前方案的基础上,等到冲突指令的值存入下一流水级寄存器后再进行转发
- 即放弃原先冲突指令
tNew
== 当前指令tUse
情况下计算出结果直接转发的操作tNew
变为原先tNew
+1
- 某些冲突条件下阻塞增加一个周期
最终方案
在上述方案的基础上,本 CPU 的使用方案:
- 新增
vNew
:保存计算出的寄存器值,和aNew
,tNew
一起流水- 目的:使得转发数据通路完全独立于原数据通路
- 在每级流水线的最开始直接将从寄存器中取得的值
v1
,v2
与vNew
进行选择- 目的:使得转发操作对于计算元件透明
- 注:
D
级时由于v1
,v2
从GRF
取得,故该选择器需要放置在GRF
后
具体结构详见顶部 CPU 设计图
伪代码
1 | // 在 D 级产生 aUse, tUse |
原则
- 所有变量采取驼峰命名法,变量名的完整组成为
type+Module+Stage
- 比方说
E
阶段ALU
的输入v1
对应的变量名为v1AluE
- 比方说
- 流水线寄存器仅仅保存
PC
,Instruction
和计算得到的信息- 这也意味着能够直接从
PC
,Instruction
取得的信息将不会被保存- 比方说
JAL
指令需要链接到PC+8
,直接在W
级使用PC
计算 - 比方说
ADD
指令需要访问rs
寄存器,直接在D
级读Instr
获取寄存器编号
- 比方说
- 这也意味着能够直接从
测试方案
指令集:add
, sub
, ori
, lw
, sw
, beq
, lui
, jal
, jr
, nop
测试目标
- 单计算指令行为
- 单存取指令行为
- 单跳转指令行为
- 计算/存取指令数据冲突阻塞转发行为
- 跳转指令与计算/存取指令数据冲突阻塞转发行为
讨论
如果您在看完本文后仍然对流水线 CPU 没有一个较为完整的认识,
或有疑惑存在,不用灰心,单纯是作者水平不够
当您遭遇这类情况,请发 issue 与作者交流。愿与君共勉