指令级并行(ILP)指通过通过流水线等技术实现多条指令同时并行执行的并行技术
实现ILP主要的方法有:
指令间的相关性限制了指令级的并行度,相关性主要分为(真)数据相关、名称相关和控制相关
指令i位于指令j的前面,下面两种情况下称指令j数据相关于指令i:
数据相关在一定程度上限制了ILP,最常用客服相关性的方法是在不改变相关性的条件下通过指令调度消除数据冒险
当两条指令使用相同寄存器和存储器位置(称为名称),但与该名称相关的指令之间并没有数据流动,此时存在名称相关
主要分为以下两种情况(指令i位于指令j的前面):
名称相关并不是真正的数据相关,通过寄存器重命名技术来消除名称相关
数据冒险是指指令间存在相关性并且这两条指令相聚非常接近,足以使执行期间的重叠改变相关操作数的访问顺序,数据冒险分成三类:
控制相关是指分支指令限定了指令i相对于其的执行顺序,和分支条件相关的指令必须先于分支指令执行,受控于分支指令的指令必须在分支指令后执行,不受控于分支指令的指令必须在分支指令前执行
基本块是指一段顺序执行的代码,除了入口处没有其他转入分支,除了出口处没有其他转出分支
考虑一下C语言代码:
for (i = 1; i <= 1000; i++) {
x[i] = x[i] + s;
}
其基本块对应的汇编程序为:
Loop: LD F0,0(R1)
ADDD F4,F0,F2
SD 0(R1),F4
DADDI R1,R1,#-8
BNEZ R1,Loop
遵循以下指令延迟规定:
那么可以直接分析基本块汇编程序的指令周期延迟(共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];
之前所了解的静态调度技术存在困难,并且一旦出现无法消除的数据相关,那么流水线就会停顿,直到清除相关流水线才会继续流动
动态调度提出动态发射的思想,若流水线即将发生停顿,硬件会动态选择后续不会违反相关性的指令发射,这也就是常说的顺序发射、乱序执行、乱序完成
动态调度有许多优点:
记分牌算法把ID段分成两个步骤:
其主要思想为,在没有结构冲突时尽可能的发射指令,若某条指令停顿则选取后续指令中与流水线正在执行和停顿的指令发射
阶段 | 内容 |
---|---|
Issue发射 | 如果当前指令所使用的功能部件空闲,并且没有其他活动的指令(执行和停顿)使用相同的目的寄存器(避免WAW),记分牌发射该指令到功能部件,并更新记分牌内部数据。如果有结构相关或WAW相关,则该指令的发射暂停,并且也不发射后继指令,直到相关解除。 |
Read读操作数 | 如果先前已发射的正在运行的指令不对当前指令的源操作数寄存器进行写操作,或者一个正在工作的功能部件已经完成了对该寄存器的写操作,则该操作数有效。操作数有效时记分牌控制功能部件读操作数,准备执行。(避免RAW) |
Execute执行 | 接收到操作数后功能部件开始执行。当计算出结果后,它通知记分牌该条指令的执行结束,记分牌记录 |
Write写结果 | 一旦记分牌得到功能部件执行完毕的信息后,记分牌检测WAR相关。如果没有WAR相关就写结果,否则暂停该条指令。 |
1.Instruction status记录正在活动的指令处于四阶段中的哪一步
2.Function unit status记录功能部件(完成运算的单元)的状态,其中每个FU有9个记录参量:
3.Register result status记录哪个FU对某个寄存器是否进行写操作(还未写入),若没有该域为空
有了上述三个部件,就可以完成四阶段中一些查看操作,如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
为什么寄存器重命名能够避免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代替,那么有:
1.RS的结构和记分牌算法的FU相似,因为有了寄存器重命名,它省去了F和R两个标志位:
2.Register result status中存对某一寄存器写操作的RS名字
阶段 | 内容 |
---|---|
Issue发射 | 如果对应RS空闲(无结构相关),则发射指令和操作数(RS重命名避免WAR和WAW) |
Execute执行 | 两操作数就绪后RS开始执行,若没准备好随时监听CDB以获取所需的操作数(避免RAW) |
Write写结果 | CDB传送所有结果,并修改Register result status |
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算法能够循环覆盖执行,关键点在于: