体系结构复习1——指令级并行(循环展开和Tomasulo算法)

时间:2022-12-17 09:27:08

体系结构复习 CH5 指令级并行


5.1 指令级并行概念

5.1.1 指令级并行

指令级并行(ILP)指通过通过流水线等技术实现多条指令同时并行执行的并行技术

实现ILP主要的方法有:

  • 依靠硬件动态发现和开发并行
  • 依靠软件在编译时静态发现并行

5.1.2 指令间相关性

指令间的相关性限制了指令级的并行度,相关性主要分为(真)数据相关、名称相关和控制相关

(1)数据相关

指令i位于指令j的前面,下面两种情况下称指令j数据相关于指令i:

  • 指令i生成的结果可能会被指令j用到
  • 指令j数据相关于指令k,而指令k数据相关于指令j

数据相关在一定程度上限制了ILP,最常用客服相关性的方法是在不改变相关性的条件下通过指令调度消除数据冒险

(2)名称相关

当两条指令使用相同寄存器和存储器位置(称为名称),但与该名称相关的指令之间并没有数据流动,此时存在名称相关

主要分为以下两种情况(指令i位于指令j的前面):

  • 反相关:指令j对指令i读取的寄存器和存储器位置进行写操作时,发生反相关
  • 输出相关:指令i和指令j对相同寄存器和存储器位置进行写操作时,发生输出相关

名称相关并不是真正的数据相关,通过寄存器重命名技术来消除名称相关

(3)数据冒险

数据冒险是指指令间存在相关性并且这两条指令相聚非常接近,足以使执行期间的重叠改变相关操作数的访问顺序,数据冒险分成三类:

  • RAW写后读:j在i还没写入时就读取同一位置,会读取旧值
  • WAW写后写:j在i还没写入时就写入同一位置,会被i写入覆盖(存在于i指令执行时间较长,如浮点运算或乘法指令)
  • WAR读后写:j在i还没读入时就写入同一位置,i错误读取新值(一般先读后写的指令集不会出现,可能情况是j提前写而i在流水线后期才读的情况)

(4)控制相关

控制相关是指分支指令限定了指令i相对于其的执行顺序,和分支条件相关的指令必须先于分支指令执行,受控于分支指令的指令必须在分支指令后执行,不受控于分支指令的指令必须在分支指令前执行


5.2 软件方法的指令级并行——基本块内的指令级并行

基本块是指一段顺序执行的代码,除了入口处没有其他转入分支,除了出口处没有其他转出分支

考虑一下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

遵循以下指令延迟规定:

体系结构复习1——指令级并行(循环展开和Tomasulo算法)

那么可以直接分析基本块汇编程序的指令周期延迟(共9个周期):

1Loop:  LD      F0,0(R1)
2 <stall>
3 ADDD F4,F0,F2
4 <stall>
5 <stall>
6 SD 0(R1),F4
7 DADDI R1,R1,#-8
8 <stall>
9 BNEZ R1,Loop

5.2.1 静态调度

静态调度是指通过改变指令顺序而不改变指令间数据相关来改善指令延迟,把上述R1的递减改到前面并利用延迟槽技术(设置延迟槽为1)可以让上述基本快代码压缩到6个周期完成:

1Loop:  LD      F0,0(R1)
2 DADDI R1,R1,#-8
3 ADDD F4,F0,F2
4 <stall>
5 BNEZ R1,Loop
6 SD 8(R1),F4

说明:

  • DADDI让R1递减提前,那么SD中存储位置是R1+8而不是R1
  • 延迟槽是无论分支是否成功都要执行的指令

5.2.2 循环展开

静态调度能够大幅提升基本快执行效率(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,性能大幅增加!

5.2.3 编译器角度来看代码调度

上述最优循环展开代码调度是我们手工完成的,能够完成高效的调度是因为我们知道R1代表枚举变量,并知道R1-32前的-16(R1)和16(R1)指向的同一内存区域,但编译器确定对存储器引用却不是那么容易,如其无法确定:

  • 同一次循环中,100(R4)和20(R6)是否相等
  • 不同次循环中,100(R4)和100(R4)是否相等

5.2.4 循环间相关

上面举例不同次循环间是不相关的,但如果出现下述情况:

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];

5.3 硬件方法的指令级并行

之前所了解的静态调度技术存在困难,并且一旦出现无法消除的数据相关,那么流水线就会停顿,直到清除相关流水线才会继续流动

动态调度提出动态发射的思想,若流水线即将发生停顿,硬件会动态选择后续不会违反相关性的指令发射,这也就是常说的顺序发射、乱序执行、乱序完成

动态调度有许多优点:

  • 对一种流水线编译的代码可以在不同流水线上高效执行,而不需要针对不同微体系结构重新编译
  • 动态调度能克服编译器静态调度不能确定的相关性(上述难题)
  • 允许处理器容忍一些不确定/动态的延迟,如缓存缺失带来的延迟,静态调度是无论如何也不能做到这一点的

5.3.1 记分牌动态调度

记分牌算法把ID段分成两个步骤:

  • 发射:译码,并检测结构相关
  • 读操作数:等到没有数据相关时读入操作数

其主要思想为,在没有结构冲突时尽可能的发射指令,若某条指令停顿则选取后续指令中与流水线正在执行和停顿的指令发射

(1)记分牌四阶段

阶段 内容
Issue发射 如果当前指令所使用的功能部件空闲,并且没有其他活动的指令(执行和停顿)使用相同的目的寄存器(避免WAW),记分牌发射该指令到功能部件,并更新记分牌内部数据。如果有结构相关或WAW相关,则该指令的发射暂停,并且也不发射后继指令,直到相关解除。
Read读操作数 如果先前已发射的正在运行的指令不对当前指令的源操作数寄存器进行写操作,或者一个正在工作的功能部件已经完成了对该寄存器的写操作,则该操作数有效。操作数有效时记分牌控制功能部件读操作数,准备执行。(避免RAW)
Execute执行 接收到操作数后功能部件开始执行。当计算出结果后,它通知记分牌该条指令的执行结束,记分牌记录
Write写结果 一旦记分牌得到功能部件执行完毕的信息后,记分牌检测WAR相关。如果没有WAR相关就写结果,否则暂停该条指令。

(2)记分牌硬件部件

1.Instruction status记录正在活动的指令处于四阶段中的哪一步

2.Function unit status记录功能部件(完成运算的单元)的状态,其中每个FU有9个记录参量:

  • Busy:该功能部件是否正在使用
  • Op:该功能部件当前完成的操作
  • Fi:目的寄存器编号
  • Fj,Fk:两个源寄存器编号
  • Qj,Qk:产生源操作数Fj,Fk的功能部件
  • Rj,Rk:标识Fj,Fk是否就绪的标志位

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;

5.3.2 Tomasulo动态调度

另一种动态调度算法是Tomasulo动态调度算法,它和记分牌的区别主要有:

  • 控制和缓存在记分牌中是集中的,Tomasulo是分布在各部件中
  • Tomasulo的FU称做Reservation Station保留站(RS),RS中表示寄存器要么是寄存器值,要么是指向RS或Load Buffer(也可以看做特殊的RS)的指针;RS可以完成寄存器重命名,避免WAR、WAW,RS可以多于寄存器,实现更多编译器无法完成的优化
  • Register result status中记录的结果都是RS的名字,而不是寄存器
  • FU计算完毕后通过Common Data Bus(CDB)广播给所有其他RS,并修改Register result status中记录
  • Tomasulo可以跨越分支!不仅仅局限于基本快内部FP操作的乱序执行

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

存在下列三个名称相关:

  • SUBD的目的操作数是F8,ADDD源操作数是F8,存在WAR冒险
  • MULD的目的操作数是F6,SD源的操作数是F6,存在WAR冒险
  • MULD的目的操作数是F6,ADDD目的操作数是F6,存在WAW冒险

用T、S重命名寄存器,有:

DIVD    F0,F2,F4
ADDD S,F0,F8
SD S,0(R1)
SUBD T,F10,F14
MULD F6,F10,T

且后续F8都用T代替,那么有:

  • SUBD写入T可以先于ADDD读F8,WAR冒险消除
  • MULD写入F6可以在SD读入S之前,WAR冒险消除
  • MULD写入F6可以在ADDD写入S之前,WAW冒险消除

(2)部件结构

1.RS的结构和记分牌算法的FU相似,因为有了寄存器重命名,它省去了F和R两个标志位:

  • Busy:该RS是否正在使用
  • Op:该RS当前完成的操作
  • A:存放存储器地址,开始存立即数,计算出有效地址后存有效地址
  • Vj,Vk:源操作数的值
  • Qj,Qk:产生源操作数的RS

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;
}
}

5.3.3 Tomasulo处理循环

Tomasulo算法能够循环覆盖执行,关键点在于:

  • 寄存器重命名:不同循环中处理不同的物理存储位置,寄存器重命名将寄存器表示为动态的指针,增加了寄存器的个数
  • 整数部件先行:以便能够发射多个循环中操作