指令级并行(ILP)指通过通过流水线等技术实现多条指令同时并行执行的并行技术
实现ILP主要的方法有:
指令间的相关性限制了指令级的并行度,相关性主要分为(真)数据相关、名称相关和控制相关
(1)数据相关
指令i位于指令j的前面,下面两种情况下称指令j数据相关于指令i:
数据相关在一定程度上限制了ILP,最常用客服相关性的方法是在不改变相关性的条件下通过指令调度消除数据冒险
(2)名称相关
当两条指令使用相同寄存器和存储器位置(称为名称),但与该名称相关的指令之间并没有数据流动,此时存在名称相关
主要分为以下两种情况(指令i位于指令j的前面):
名称相关并不是真正的数据相关,通过寄存器重命名技术来消除名称相关
(3)数据冒险
数据冒险是指指令间存在相关性并且这两条指令相聚非常接近,足以使执行期间的重叠改变相关操作数的访问顺序,数据冒险分成三类:
(4)控制相关
控制相关是指分支指令限定了指令i相对于其的执行顺序,和分支条件相关的指令必须先于分支指令执行,受控于分支指令的指令必须在分支指令后执行,不受控于分支指令的指令必须在分支指令前执行
相关与冲突的区别和联系:
相关包括数据相关,名称相关和控制相关
冲突包括结构冲突,数据冲突,控制冲突
其中:
结构冲突是由硬件资源冲突造成的
数据冲突是由数据相关和名称相关造成的
控制冲突是由控制相关造成的
相关是程序的固有属性,它反映了程序汇中指令之间的相互依赖关系
冲突是流水线的属性,存在相关不一定会引起冲突
因此,开发指令级并行可以从两方面考虑:
- 保持相关但避免冲突的发生(如指令调度)
- 进行代码变换直接消除相关性
基本块是指一段顺序执行的代码,除了入口处没有其他转入分支,除了出口处没有其他转出分支
考虑一下C语言代码:
for (i = 1; i <= 1000; i++) {
x[i] = x[i] + s;
}
其基本块对应的汇编程序为:
#简化起见,假设最低地址是8
Loop: LD F0,0(R1) # R1处给F0赋值 <-
ADDD F4,F0,F2 # F0+F2->F4
SD 0(R1),F4 #存储结果到F4 ->
DADDI R1,R1,#-8 R1作为地址下标
BNEZ R1,Loop # R1 != 0
遵循以下指令延迟规定:
那么可以直接分析基本块汇编程序的指令周期延迟(共9个周期):
1Loop: LD F0,0(R1)
2
3 ADDD F4,F0,F2
4 #由上表可知加操作需要两个停顿周期
5
6 SD 0(R1),F4
7 DADDI R1,R1,#-8
8
9 BNEZ R1,Loop
静态调度是指通过改变指令顺序而不改变指令间数据相关来改善指令延迟,把上述R1的递减改到前面并利用延迟槽技术(设置延迟槽为1)可以让上述基本快代码压缩到6个周期完成:
1Loop: LD F0,0(R1)
2 DADDI R1,R1,#-8
3 ADDD F4,F0,F2
4
5 BNEZ R1,Loop
6 SD 8(R1),F4
说明:
静态调度能够大幅提升基本快执行效率(50%),但是还有一个周期的停顿不能消除,那么由此引入另一种块内消除延迟方法——循环展开
循环展开是将循环中多个基本块展开成一个基本块从而填充stall间隙的方法
将上段基本块做4段展开,并做调度:
1Loop: LD F0,0(R1)
2 LD F6,-8(R1)
3 LD F10,-16(R1)
4 LD F14,-24(R1)
5 ADDD F4,F0,F2
6 ADDD F8,F6,F2
7 ADDD F12,F10,F2
8 ADDD F16,F14,F2
9 SD 0(R1),F4
10 SD -8(R1),F8
11 DADDI R1,R1,#-32
12 SD 16(R1),F12
13 BNEZ R1,Loop
14 SD 8(R1),F16
平均每个次循环仅需要14/4=3.5个cycle,性能大幅增加!
上述最优循环展开代码调度是我们手工完成的,能够完成高效的调度是因为我们知道R1代表枚举变量,并知道R1-32前的-16(R1)和16(R1)指向的同一内存区域,但编译器确定对存储器引用却不是那么容易,如其无法确定:
上面举例不同次循环间是不相关的,但如果出现下述情况:
for (i = 1; i <= 100; i++) {
A[i] = A[i] + B[i]; //S1
B[i+1] = C[i] + D[i]; //S2
}
S1和S2没有相关,但是S1依赖于前一次循环的B[i],这种循环常不能直接并行执行,需要修改代码:
A[1] = A[1] + B[1];
for (i = 1; i <= 99; i++) {
B[i+1] = C[i] + D[i]; //S2
A[i+1] = A[i+1] + B[i+1]; //S1
}
B[101] = C[100] + D[100];
由上例可知,通过循环展开,寄存器重命名,指令调度可以有效地开发出指令级并行,但是循环展开和指令调度需要注意以下几个问题:
- 正确性(展开后代码要正确,如循环控制和操作数偏移量的修改)
- 有效性(能否进行展开,不同循环体之间需要有无关性)
- 重命名(使用不同的寄存器避免名称冲突)
- 删除冗余指令(删除多于的测试指令和分支指令并对循环结束代码和新的循环体代码进行相应修正)
- 特别保证载入和存储指令的不相关(load和store在不同循环体中访问的地址不相同)
循环展开的不足之处:
- code size 增大,这可能会造成cache缺失率增大
- reg pressure 增大,寄存器的数量可能不足导致寄存器紧缺,影响重命名
分支预测(Branch Prediction)是现代处理器用来提高CPU执行速度的一种手段, 其对程序的分支流程进行预测, 然后预先读取其中一个分支的指令并解码来减少等待译码器的时间.维基百科上对此的解释是"a strategy in computer architecture design for mitigating the costs usually associated with conditional branches, particularly branches to short sections of code."
分支预测的方法有 静态预测 和 动态预测 两类:静态预测方法行为比较简单,如预测永远不转移、预测永远转移(jmp)、预测后向转移等等,它并不根据执行时的条件和历史信息来进行预测,因此预测的准确性不会很高;动态预测方法则根据同一条转移指令过去的转移情况来预测未来的转移情况。
由于程序中的条件分支是根据程序指令在流水线处理后的结果来执行的,所以当CPU等待指令结果时,流水线的前级电路也处于等待分支指令的空闲状态,这样必然出现时钟周期的浪费。如果CPU能在前条指令结果出来之前就预测到分支是否转移,那么就可以提前解码并执行相应的指令,这样就避免了流水线的空闲等待,也就相应提高了CPU的整体执行速度。但另一方面,一旦前条指令结果出来后证明分支预测是错误的, 也就是产生了错误的分支预测,那么就必须将已经装入流水线执行的指令和结果全部清除,然后再装入正确的指令重新处理,这样就比不进行分支预测而是等待结果再执行新指令有额外的周期消耗。
因此,分支预测的错误并不会导致结果的错误,而只是导致流水线的停顿,如果能够保持较高的预测准确率,分支预测就能提高流水线的性能, 换言之, 如果在软件开发过程中, 能够考虑这一特性, 减少甚至移除条件分支(值得一提的是, 条件转移不需要预测, 因此条件转移也远没有产生错误分支的性能代价大), 就能一定程度上提供程序的整体效率.
看体系结构,看了流水线,知道流水线流起来之后,就会产生hazard,然后就会对hazard进行分类,找出解决hazard的方法;然后大伙蜂拥而上,发现hazard中,对于branch hazard这部分带来的性能影响很大,根据量化书中写的,branch带来的性能影响是10%~30%,并且流水级数越长,性能影响越大,这个其实很好理解的。所以大伙就集中精力解决这个问题了。然后出现了各种方法了啊,根据书上写的,以及我的理解,对于分支指令的处理就两大类:静态的和动态的。
所谓的静态分支处理方法,就是不依靠程序执行期间的信息进行处理,静态的方法一般也就如下了哦:
1)出现branch,就冻结流水线——这是傻瓜也能想出的方法;
2)认为branch一定会token;
3)认为branch一定不会token;
4)由于branch的执行,第一步是IF取指令,然后第二部是译码,即ID,在第二阶段ID才能判断出当前指令是否是branch指令,为此可以在ID阶段继续取一条指令,作为分支指令的delay slot,这条指令不管branch是否token,都会执行。
所谓的动态分支处理方法,是指利用程序执行期间的信息,进行处理,所以这儿很明显带来的是硬件开销,因为程序执行期间是不可能由软件协助的,否则性能影响也就太大了吧。相对的,静态分支处理,其实是利用编译器实现的。
现在一般的处理器中,对于动态分支预测算法,支持的基本功能是BTB和BHT两个基本逻辑。
BTB——Branch Target Buffer——分支目标缓冲,记录分支的“目标”即跳转地址的缓冲
BHT——Branch Histroy Table——分支历史表,记录分支指令是否token
那么,我们应该在什么阶段进行分支预测呢?当然是越快越好!
考虑教学用5级流水线结构,第一级是取指令,这个时候,我们是不知道这条指令是否是分支指令的;第二级是译码,这个时候才能知道某条指令是否是分支指令的。为此,针对分支预测,则最快也才能在第二级才能进行分支预测,而进行预测也需要一个时钟周期的,因此,也就说,在第三级才能得到分支的结论:是否需要跳转。假设分支预测都是准确的,这个时候最少也会在流水线中插入两级stall,而不是一级stall。
为此带来的流水性能损失是不可接受的。大家也就继续想办法:能否再取指令的同时,进行部分译码呢?因为RISC的指令集都比较规整,分支指令的编码方式也比较规整,进行部分译码需要的逻辑不算多,不会过都影响流水线的时钟周期提升。
这个时候可以把上面的BHT以及BTB用上了,不过BTB和BHT是分别用在两个地方的:
BHT——Branch History Table,顾名思义,这是记录分支历史信息的表格,用于判定一条分支指令是否token;这儿记录的是跳转信息,简单点的,可以用1bit位记录,例如1表示跳转,0表示不跳转,而这个表格的索引是指令PC值;考虑在32位系统中,如果要记录完整32位的branch history,则需要4Gbit的存储器,这超出了系统提供的硬件支持能力;所以一般就用指令的后12位作为BHT表格的索引,这样用4Kbit的一个表格,就可以记录branch history了。当然,通过大伙的不懈努力和分析,发现在BHT中用1bit位记录分支是否跳转还不够准确,用2bit位记录就非常好了,而用3bit或者更多位记录,效果与2bit类似。所以在BHT中,一般就用2bit位记录分支是否跳转:例如11和10表示这条分支会跳转;01和00表示分支不会跳转。这个2bit计数器大伙叫做饱和计数器。
BTB——用于记录一条分支指令的跳转地址,由于这儿存储的是指令地址,例如32位地址,因此,这个表格就不能做到存储BHT那样多的内容了,如果也支持4K条指令,则需要128Kbit的存储空间,这几乎可以赶上一个L1Cache的容量了,所以BTB一般很小,就32项或者64项。由于这个BTB容量小,并且其用于是记录分支指令的跳转地址,因此,如果这条指令不跳转,即其下一条指令就是PC+4,则不会在BTB中记录的。
基于BTB和BHT的分支预测就很简单了:
1)在取指阶段利用PC寻址BTB,如果命中,则说明这是一条跳转指令,利用从BTB中获取到的地址去取icache;
2)由于BTB中保存的内容不够多,因此BHT的准确率更高,这个时候索引BHT表格,如果发现BHT也跳转,则说明这条指令预测是跳转的;如果BHT不跳转,则说明不跳转,这个时候就取消BTB中的指令地址,重新PC+4去取icache;
---------------------
常见的分支预测器: https://blog.csdn.net/edonlii/article/details/8754724
https://www.zhihu.com/question/23973128
之前所了解的静态调度技术存在困难,并且一旦出现无法消除的数据相关,那么流水线就会停顿,直到清除相关流水线才会继续流动
动态调度提出动态发射的思想,若流水线即将发生停顿,硬件会动态选择后续不会违反相关性的指令发射,这也就是常说的顺序发射、乱序执行、乱序完成
动态调度有许多优点:
记分牌算法把ID段分成两个步骤:
其主要思想为,在没有结构冲突时尽可能的发射指令,若某条指令停顿则选取后续指令中与流水线正在执行和停顿的指令发射
(1)记分牌四阶段
阶段 | 内容 |
---|---|
Issue发射 | 如果当前指令所使用的功能部件空闲,并且没有其他活动的指令(执行和停顿)使用相同的目的寄存器(避免WAW),记分牌发射该指令到功能部件,并更新记分牌内部数据。如果有结构相关或WAW相关,则该指令的发射暂停,并且也不发射后继指令,直到相关解除。 |
Read读操作数 | 如果先前已发射的正在运行的指令不对当前指令的源操作数寄存器进行写操作,或者一个正在工作的功能部件已经完成了对该寄存器的写操作,则该操作数有效。操作数有效时记分牌控制功能部件读操作数,准备执行。(避免RAW) |
Execute执行 | 接收到操作数后功能部件开始执行。当计算出结果后,它通知记分牌该条指令的执行结束,记分牌记录 |
Write写结果 | 一旦记分牌得到功能部件执行完毕的信息后,记分牌检测WAR相关。如果没有WAR相关就写结果,否则暂停该条指令。 |
(2)记分牌硬件部件
1.Instruction status记录正在活动的指令处于四阶段中的哪一步
2.Function unit status记录功能部件(完成运算的单元)的状态,其中每个FU有9个记录参量:
3.Register result status记录哪个FU对某个寄存器是否进行写操作(还未写入),若没有该域为空
(3)动态调度算法
有了上述三个部件,就可以完成四阶段中一些查看操作,如FU是否空闲、操作数是否就绪(能否执行)、是否存在WAW等
之所以该算法称为记分牌算法,是因为这三个部件就像公示的记分牌一样,流水线中各操作都需要去查看记分牌的状态并根据执行情况在记分牌中写入相应参数信息
将四阶段和记分牌控制用伪代码的形式给出,wait util是某指令向下阶段流水的必要条件,Book keeping是该流水段执行完毕后需要记录的信息:
Status | Wait Until | Book Keeping |
---|---|---|
Issue | !FU.busy && result[D] == null | FU.busy = true; FU.op = op; FU.Fi = ‘D’; FU.Fj = ‘S1’; FU.Fk = ‘S2’; Qj = result[S1]; Qk = result[S2]; Rj = (Qj == null ? true : false); Rk = (Qk == null ? true : false); Result[D] == ‘FU’(若是源操作数是立即数或R整数寄存器,对应Rjk直接是yes) |
Read | Rj && Rk | Rj = true; Rk = true; |
Execute | FU complete | record complete cycle |
Write | 任意其他FU的Fj和Fk不是FU.Fi(WAR)或者他们已经ready | “通知”所有Fj和Fk是FU.Fi的其他FU该操作数ready,修改Rj/k为true; Result[FU.Fi] = null; FU.busy = false; |
另一种动态调度算法是Tomasulo动态调度算法,它和记分牌的区别主要有:
Tomasulo受限于CDB的性能,一般采用高速CDB
(1)寄存器重命名
为什么寄存器重命名能够避免WAR和WAW?事例如下:
DIVD F0,F2,F4
ADDD F6,F0,F8
SD F6,0(R1)
SUBD F8,F10,F14
MULD F6,F10,F8
存在下列三个名称相关:
用T、S重命名寄存器,有:
DIVD F0,F2,F4
ADDD S,F0,F8
SD S,0(R1)
SUBD T,F10,F14
MULD F6,F10,T
且后续F8都用T代替,那么有:
(2)部件结构
1.RS的结构和记分牌算法的FU相似,因为有了寄存器重命名,它省去了F和R两个标志位:
2.Register result status中存对某一寄存器写操作的RS名字
(3)三阶段
阶段 | 内容 |
---|---|
Issue发射 | 如果对应RS空闲(无结构相关),则发射指令和操作数(RS重命名避免WAR和WAW) |
Execute执行 | 两操作数就绪后RS开始执行,若没准备好随时监听CDB以获取所需的操作数(避免RAW) |
Write写结果 | CDB传送所有结果,并修改Register result status |
(4)Tomasulo流水控制
Tomasulo动态调度算法的伪代码表示如下:
1.发射阶段:
// rs、rt为源操作数
// rd为目的操作数
void issue() {
if op == FP operation {
wait until: RS[r].busy == false; // r是和FP Operation对应的RS编号
if RegisterStatus[rs].Qi != null {
RS[r].Vj = null;
RS[r].Qj = RegisterStatus[rs].Qi;
} else {
RS[r].Vj = Register[rs];
RS[r].Qj = null;
}
if RegisterStatus[rs].Qk != null {
RS[r].Vk = null;
RS[r].Qk = RegisterStatus[rs].Qi;
} else {
RS[r].Vk = Register[rs];
RS[r].Qk = null;
}
RS[r].busy == true;
RegisterStatus[rd].Qi = r;
}
if op == Load or Store {
wait until: RS[r].busy == false; // Load Buffer和RS相同数据结构
if RegisterStatus[rs].Qi != null {
RS[r].Vj = null;
RS[r].Qj = RegisterStatus[rs].Qi;
} else {
RS[r].Vj = Register[rs];
RS[r].Qj = null;
}
RS[r].A = imm;
RS[r].busy = true;
if op == Load { // Load only
RegisterStatus[rt].Qi = r;
} else { // Store only
if (RegisterStatus[rd].Qi != null) {// 避免WAW
RS[r].Vk = null;
RS[r].Qk = RegisterStatus[rt].Qi;
} else {
RS[r].Vk = Register[rt];
RS[r].Qk = null;
}
}
}
}
2.执行阶段:
void execute() {
if op == FP Operation {
wait until: RS[r].Qj == null && RS[r].Qk == null
compute result with Operand in Vj and Vk;
}
if op == Load or Store {
wait until: RS[r].Qj = 0 && r is head of load-store queue(每次处理队头元素)
RS[r].A = RS[r].Vj + RS[r].A;
if op == Load {
wait until: RS[r].A写入完成
Read from Mem[RS[r].A]
}
}
}
3.写结果阶段:
void write() {
if op == FP Operation {
wait until: Execution of r is complete & CDB available
for all x in RegisterStatus_Index_Set par-do {
// 硬件并行执行,模拟直接for循环串行模拟即可
if RegisterStatus[x].Qi == r {
Register[x] = result;
RegisterStatus[x].Qi = null;
}
}
for all x in RS_Index_Set par-do {
if RS[x].Qj == r {
RS[x].Vj = result;
RS[x].Qj = null;
}
if RS[x].Qk == r {
RS[x].Vk = result;
RS[x].Qk = null;
}
}
RS[r].busy = false;
}
if op == Store {
wait until: Execution of r is complete & RS[r].Qk == null
Mem[RS[r].A] = RS[r].Vk;
RS[r].busy = false;
}
}
Tomasulo算法能够循环覆盖执行,关键点在于:
参考链接: https://blog.csdn.net/load2006/article/details/12774103
参考链接: https://blog.csdn.net/liudongdong19/article/details/80761637
参考链接: https://www.zhihu.com/question/21823699/answer/111606716
参考链接: https://blog.csdn.net/u014030117/article/details/46576043