由于体系结构课上的云里雾里,查作业发现有题来自CSCI 510: Computer Architecture Written Assignment 3,决定把它做一遍。书用的《计算机体系结构:量化研究方法(第5版)》,对应第4章
Q
假设GPU参数如下:
不考虑存储器带宽和延迟,这个GPU的单精度浮点数运算吞吐量峰值是多少(单位GLFOP/秒)?考虑存储器带宽限制时,这个吞吐量能否保持?
一些缩写
SIMD - Single Instruction Multiple Data
GFLOP/sec - Giga Floating-point Operations Per Second
A
吞吐量峰值:1.5 × 16 × 32 = 768 GFLOP/sec
考虑到每个单精度浮点运算需要输入2个4字节操作数、输出1个4字节结果,需要的带宽为:
( 4 + 8 ) * 768 = 9216 GB/s
100GB/s的存储器带宽不能满足需求
Q
用向量方式处理这段代码,向量中包含单精度数:
for (i = 0; i < 400; ++ i) {
u[i] = a[i] * b[i] – c[i] * d[i];
v[i] = a[i] * d[i] + c[i] * b[i];
}
假设处理器频率为1GHz,向量最大长度为64。各单元启动开销(以时钟周期为单位)如下:
a. 这段代码的算数运算强度是多大?
b. 用条带挖掘技术将这个循环转换成VMIPS汇编代码
c. 若使用链接技术,假设流水线只有一个存储单元,需要多少个钟鸣?产生一个计算结果需要多少个时钟周期(包括启动开销)?
d. 仍使用链接技术,假设流水线有三个存储单元,假设进入循环没有共享内存冲突,产生一个结果需要多少个时钟周期(包括启动开销)?
一些词
VMIPS - 向量处理器
MVL - Maximum Vector Length 最大向量长度
VLR - Vector Length Register 向量长度寄存器
chimes - 钟鸣,执行护航指令组所花费的时间单位
convoy - 护航指令组,一组可以一直执行的向量指令,不包含任何结构性冒险
条带挖掘(strip mining)
一种生成代码的技术,使每个向量运算都是针对小于或等于MVL的大小来完成的。即将向量分段,第一段长度为n%MVL,所有后续段的长度为MVL,从而充分利用向量处理器的功能
本题b中,MVL = 64,n = 400 = 64*4 + 16 则第一段长度为 400%64 = 16,一次可以对16个浮点数进行运算,后续段长度均为64,一次可以对64个浮点数进行运算
改变向量运算的长度需设置VLR寄存器,在代码中表现为为变量VL进行赋值
链接(chaining)
类似前推(forwarding),让一系列相互依赖的向量操作运行得更快
https://blog.csdn.net/BPSSY/article/details/16965377
链接(chaining)-- 前推(forwarding)的概念在向量寄存器上的扩展
考虑一下简单的向量序列:
MULV.D V1, V2, V3
ADDV.D V4, V1, V5
在 VMIPS 中,就像我们看到的那样,这两条指令必须放到两个独立的 convoy中,因为他们是互相依赖的。另一方面,如果我们把向量寄存器,在本例中即V1,不看成一个单个个体,而是看成一组寄存器,那么所谓前推(forwarding)的概念就可以扩展以作用于向量的每个元素上。这一允许 ADDV.D 更早开始执行的电子称为链接(chaining)。Chaining 允许只要一个向量操作的源操作向量中的某个元素已经准备就绪时就开始执行该元素的操作:在链(chain)中,前一个功能单元的结果被 forward 到后一个功能单元。
对于前一个例子而言,可以达到持续达到每一个周期 2 个浮点操作,或者一个chime,的速率(不考虑启动开销),即使他们是互相依赖的!它的总共执行时间为:
向量长度 + ADDV 的启动时间 + MULV 的启动时间
A
a.
一个单精度浮点数占4字节。每次循环从主存读 4 × 4 = 16 bytes(第二次获取a[i], b[i], c[i] 和 d[i]是从缓存读的),写 2 × 4 = 8 bytes。每次循环进行6次浮点计算。故算数运算强度为 6 / ( 16 + 8 ) = 0.25
b.
li $VL,16 # 第一次迭代,设置向量运算长度为16
li $r1,0 # 0存入$r1,初始化索引
loop: lv $v1,a+$r1 # 取a
lv $v3,b+$r1 # 取b
mulvv.s $v5,$v1,$v3 # a*b
lv $v2,c+$r1 # 取c
lv $v4,d+$r1 # 取d
mulvv.s $v6,$v2,$v4 # c*d
subvv.s $v5,$v5,$v6 # a*b - c*d
sv $v5,u+$r1 # 存u
mulvv.s $v5,$v1,$v4 # a*d
mulvv.s $v6,$v2,$v3 # c*b
addvv.s $v5,$v5,$v6 # a*d + c*b
sv $v5,v+$r1 # 存v
bnez $r1,else # 检查是否是第一次迭代
li $VL, 64 # 后续迭代,设置向量运算长度为64
addi $r1,$r1,#64 # 第一次迭代,增加16*4=64($r1用作地址偏移量,16个浮点数*每个数4byte)
j loop # 确保进行下次迭代
else: addi $r1,$r1,#256 # 不是第一次迭代
# 增加64*4 = 256
skip: blt $r1,1600,loop # 是否进行下一次迭代?
c.
上面的代码可以分为以下8个钟鸣:
1) lv # 取a
2) lv # 取b
3) mulvv.s lv # a*b, 取c
4) lv mulvv.s # 取d, c*d
5) subvv.s sv # a*b – c*d, 存u
6) mulvv.s # a*d
7) mulvv.s # c*b
8) addvv.s sv # a * d + c * b, 存v
400 = 64*6 + 16,上面过程要进行7次,其中第一次向量长度为16,后面6次向量长度为64。总共有 7 × 6 = 42 个钟鸣
第一次迭代中,向量长度为16
钟鸣1、2需要 (15 + 16) × 2 = 62 个时钟周期,其中15为存取单元启动开销,16为后续16个浮点数在流水线中的延迟
钟鸣3、4需要 (8 + 15 + 16) × 2 = 78 个时钟周期,其中8为乘法单元启动开销,15为存取单元启动开销
钟鸣5需要 5 + 15 + 16 = 36 个时钟周期
钟鸣6、7需要 (8 + 16) × 2 = 48 个时钟周期
钟鸣8需要 5 + 15 + 16 = 36 个时钟周期
3-8共 78 + 36 + 48 + 36 = 198
后续迭代中,向量长度为64
钟鸣1、2需要 (15 + 64) × 2 = 158 个时钟周期
钟鸣3、4需要 (8 + 15 + 64) × 2 = 174 个时钟周期
钟鸣5需要 5 + 15 + 64 = 84 个时钟周期
钟鸣6、7需要 (8 + 64) × 2 = 144 个时钟周期
钟鸣8需要 5 + 15 + 64 = 84 个时钟周期
3-8共 174 + 84 + 144 + 84 = 486
总共需要 62 + 198 + (158 + 486) × 6 = 4124 个时钟周期
产生每个结果平均用时 4124 / 400 = 10.31 个时钟周期
⁉️答案中198算成了196,总数是 62 + 196 + 486 × 6 = 3174,没有考虑后续迭代钟鸣1、2的开销?不知道到底要不要算…后面的平均值也是 3174 / 400 = 7.935
也不知道为什么"per result"是除以400…
d.
将b中代码分成以下部分:
第一部分:第一次迭代
1) lv, lv, mulvv.s lv # load a and b, a*b, load c
2) lv mulvv.s # load d, c * d
3) subvv.s sv # a*b – c*d, store u
4) mulvv.s # a*d
5) mulvv.s # c*b
钟鸣1:15 + 8 + 15 + 16 = 54
钟鸣2:15 + 8 + 16 = 39
钟鸣3:8 + 15 + 16 = 39
钟鸣4、5: 2 × (8 + 16) = 48
第二部分:除去最后一次的所有迭代
1) addvv.s sv, lv, lv # a*d + c*b, store v, load a and b
钟鸣1:8 + 15 + 64 = 87
第三部分:除去第一次的所有迭代
2) mulvv.s lv, lv # a*b, load c and d
3) mulvv.s # c*d
4) subvv.s sv # a*b – c*d, store u
5) mulvv.s # a*d
6) mulvv.s # c*b
钟鸣1:8 + 15 + 64 = 87 cycles.
钟鸣2:8 + 64 = 72 cycles
钟鸣3:8 + 15 + 64 = 87 cycles.
钟鸣4、5:2 × (8 + 64) = 144 cycles
第四部分:最后一次迭代
7) addvv.s sv # a*d + c*b, store v
钟鸣7:8 + 15 + 64 = 87 cycles.
总共需要 400/64(向上取整) × 6 = 7 × 6 = 42 个钟鸣
总共需要 54 + 39 + 39 + 48 + 87 × 6 + (87 + 72 + 87 + 144) × 6 + 87 = 3129 个时钟周期
平均每个结果需要 3129 / 400 = 7.8225 个时钟周期
Q
将第2题中的循环转化成MIPS SIMD
A
LA R1, a ; load base address of a
LA R2, b ; load base address of b
LA R3, c ; load base address of c
LA R4, d ; load base address of d
LA R5, u ; load base address of u
LA R6, v ; load base address of v
DADDIU R7, R1, #1600
LOOP: L.4S F0, 0(R1) ; load a[i]..a[i+3]
L.4S F4, 0(R2) ; load b[i]..b[i+3]
MUL.4S F16,F4,F0 ; a * b
L.4S F8, 0(R3) ; load c[i]..c[i+3]
L.4S F12, 0(R4) ; load d[i]..d[i+3]
MUL.4S F20, F12, F8 ; c * d
SUB.4S F20, F20, F16 ; a * b - c * d
S.4S F4, 0(R5) ; store u[i]..u[i+3]
MUL.4S F16, F12, F0 ; a * d
MUL.4SF20,F8,F4 ; c * b
ADD.4S F20, F20, F16 ; a * d + c * b
S.4S F20, 0(R6) ; store v[i]..v[i+3]
DADDIU R1, R1, #16
DADDIU R2, R2, #16
DADDIU R3, R3, #16
DADDIU R4, R4, #16
DADDIU R5, R5, #16
DADDIU R6, R6, #16
DSUBU R8, R7, R1
BNEZ R8, LOOP
Q
将第2题中的循环转化成CUDA
A
// Invoke remi() with 64 threads per Thread Block
__host__
int nblocks = (400 + 63) / 64;
remi<<>>(400, a, b, u, c, d, v);
// remi in CUDA
__device__
void remi(int n, double *a, double *b, double *u, double *c, double *d, double *v)
{
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i < n)
{
u[i] = a[i] * b[i] – c[i] * d[i];
v[i] = a[i] * d[i] + c[i] * b[i];
}
}
Q
reduction是一种特殊的循环重复,比如:
dot = 0.0;
for (i = 0; i < 64; ++ i)
dot = dot + a[i] * b[i];
一个向量编译器可能会采取一种叫做scalar expansion的变化,将点扩展成向量,并分割循环,从而可以用向量计算乘法,将reduction转化成互不相关的scalar operation:
for (i = 0; i < 64; ++ i)
dot[i] = a[i] * b[i];
for (i = 0; i < 64; ++ i)
dot[0] = dot[0] + dot[i];
如果允许浮点加法相关,有几种可行的并行化的方法
a.
一种方法叫做recurrence doubling,它将一系列逐渐变小的向量相加(例如,先加两个32个元素的向量,然后加两个16个元素的向量,诸如此类)。如果第二个循环采用此方法,写出C代码
b.
对于有些向量处理器,向量寄存器中的每个元素都是可以寻址的。此时,向量操作的操作数可以是同一个向量寄存器的两个不同部分。这样就有了另一种叫做partial sums的方法。它的思路是将向量拆分成m个部分,其中m是向量处理单元的总延迟(包括读写的时间)。假设VMIPS的向量寄存器可以寻址(例如,可以用V1(16)初始化操作数,即输入操作数从第16个元素开始),假设加法运算的总延迟是8个时钟周期(包括读操作数和写结果)。写出VMIPS代码,将V1的内容拆分成8个部分的和。
c.
在GPU上进行reduction运算时,输入向量的每个元素都关联到一个线程。每个线程的第一步是将对应的值写到共享内存中。接下来,每个线程进入一个循环,将每一对输入值相加。每循环一次,元素个数就可以减半,这意味着每次循环,活动的线程数也会减半。为了使性能最大化,每次循环时完全利用的warp数量都应该最大,也就是说,活动线程的数量应该接近。并且每个线程应该对共享数组记录索引,从而避免共享内存的bank conflict。下面的循环违背了这些准则中的第一条,并且使用了GPU中非常昂贵的取模运算:
unsigned int tid = threadIdx.x;
for (unsigned int s=1; s < blockDim.x; s *= 2) {
if ((tid % (2*s)) == 0) {
sdata[tid] += sdata[tid + s];
}
__syncthreads();
}
改写这个循环,不使用取模运算并使其满足上面所有的准则。假设每个warp有32个线程,每当同一个warp中2个或以上的线程指向了对32取模后相等的索引时,就会发生bank conflict
A
a.
for (k = 32; k > 0; k /= 2) {
for (i = 0; i < k; ++ i) {
dot[i] = dot[i] + dot[i + k];
}
}
b.
li $VL,4
addvv.s $v0(0),$v0(4)
addvv.s $v0(8),$v0(12)
addvv.s $v0(16),$v0(20)
addvv.s $v0(24),$v0(28)
addvv.s $v0(32),$v0(36)
addvv.s $v0(40),$v0(44)
addvv.s $v0(48),$v0(52)
addvv.s $v0(56),$v0(60)
c.
for (unsigned int s = blockDim.x/2 ; s>0 ; s/=2) {
if (tid<s) sdata[tid] = sdata[tid] + sdata[tid+s];
__syncthreads();
}
Q
本题中,我们将分析几个循环是否可以并行处理
a. 下面这个循环有没有循环间相关?
for (i = 0; i < 100; ++ i) {
A[i] = B[2*i+4];
B[4*i+5] = A[i];
}
b. 找出下面循环中所有的真相关、输出相关和反相关。并通过重命名消除输出相关和反相关
for (i = 0; i < 100; ++ i) {
A[i] = A[i] * B[i]; /* S1 */
B[i] = A[i] + c; /* S2 */
A[i] = C[i] * c; /* S3 */
C[i] = D[i] * A[i]; /* S4 */
}
c. 考虑下面循环:
for (i = 0; i < 100; ++ i) {
A[i] = A[i] + B[i]; /* S1 */
B[i+1] = C[i] + D[i]; /* S2 */
}
S1和S2直接是否相关?这个循环能否并行?如果不能,如何使它可以并行?
A
a.
使用GCD(最大公约数)测试检查 B[2i+4] 和 B[4i+5] 直接有没有循环间的相关。如果GCD(2,4)能整除(5-4),那么循环间存在相关。有GCD(2,4) = 2,(5-4) mod 2 = 1。因此,B[]没有循环间相关。
循环体中A[]使用相同的索引,因此也没有循环间相关。
b.
真相关:
由于A[i],S1和S2、S3和S4存在真相关
输出相关:
由于A[i],S1和S3存在输出相关
反相关:
由于B[i] ,S1和S2存在反相关
由于A[i] ,S2和S3存在反相关
由于C[i] ,S3和S4存在反相关
重写代码:
/* Assume A1,B1,C1 are copies of A,B,C */
for (i=0;i<100;i++) {
X[i] = A1[i] * B1[i]; /* S1 */
B[i] = X[i] + c; /* S2 */
A[i] = C1[i] * c; /* S3 */
C[i] = D[i] * A[i]; /* S4 */
}
c.
B[]在第i次和第i+1次循环间存在反相关,不能并行
重写代码:
A[0] = A[0] + B[0];
for (i=0; i<99; i=i+1) {
B[i+1] = C[i] + D[i];
A[i+1] = A[i+1] + B[i+1];
}
B[100] = C[99] + D[99];
英文原版
hw3-key.pdf