机器之心发布
作者:XiaoyuWang
九大章节,一万余字,这篇文章可能是目前为止Maxas汇编器工作原理最全面、最细致的解析。
tid = threadId.x;
bx = blockId.x; // 可以看作C_ij的i
by = blockId.y; // 可以看作C_ij的j
blk = tid >= 32 ? by : bx;
ldx = tid >= 32 ? ldb/4 : lda/4; // lda和ldb为A的行数或B的列数,ldx为其在向量载入中的表示(每次4个浮点数故除以4),可以看成每一行的跨度
tex = tid >= 32 ? texB : texA; // 64个线程分为2个warp,第一个载入纹理A,第二个载入纹理B
tid2 = (tid >> 4) & 1; // 此处将32个线程分为两组,tid2=0为第一组载入图三中的第一行,tid2=1载入第二行
tid15 = tid & 15; // 本线程在每一行中列的位置
// 这些track变量表示本线程需要载入的数据在tex中的偏移,乘以4即在$A_i$或$B_j_T$中的偏移
track0 = blk*64/4 + tid15 + (ldx * tid2); // 本线程载入数据的起始位置,解释见后文
track2 = track0 + ldx*2; // 本线程载入的第二部分数据,在第一部分两行后,以此类推
track4 = track0 + ldx*4;
track6 = track0 + ldx*6;
end = track0 + (k-8)*ldx; // 载入结束的标志,其中k为A的列数和B的行数,即两个相乘矩阵的公共维度,对于NxN的矩阵, k=N。因为每次载入8行所以减去8作为最后一次的载入的标记
writeS = tid15*4*4 + tid2*64*4; // 本线程载入数据在共享内存中的偏移,注意其相当于track0的第二项的16倍,因为向量载入的偏移1代表(4个数x每个浮点数4字节)
writeS += tid >= 32 ? 2048 : 0; // 如果载入的是矩阵B的数据,放在矩阵A的数据之后,而矩阵A占用(64列x8行x每元素4字节)=2048字节
while (track0 < end)
{
tex.1d.v4.f32.s32 loadX0, [tex, track0];
tex.1d.v4.f32.s32 loadX2, [tex, track2];
tex.1d.v4.f32.s32 loadX4, [tex, track4];
tex.1d.v4.f32.s32 loadX6, [tex, track6];
st.shared.v4.f32 [writeS + 4*0*64], loadX0;
st.shared.v4.f32 [writeS + 4*2*64], loadX2;
st.shared.v4.f32 [writeS + 4*4*64], loadX4;
st.shared.v4.f32 [writeS + 4*6*64], loadX6;
// our loop needs one bar sync after share is loaded
bar.sync 0;
// Increment the track variables and swap shared buffers after the sync.
// We know at this point that these registers are not tied up with any in flight memory op.
track0 += ldx*8;
track2 += ldx*8;
track4 += ldx*8;
track6 += ldx*8;
writeS ^= 4*16*64; // 又见magic number!其意义是A和B在共享内存中一共占4*16*64=4096字节,但是共享内存一共分配了8192字节的两组,每次载入后用这个操作切换到另外一组,其目的是实现一个流水线,在一个warp载入数据进一组时另一个warp就可以操作另一组已经完成载入的数据了
// Additional loop code omitted for clarity.
}
tex.1d.v4.f32.s32 loadX0, [tex, track];
track += ldx*2;
tex.1d.v4.f32.s32 loadX2, [tex, track];
track += ldx*2;
tex.1d.v4.f32.s32 loadX4, [tex, track];
track += ldx*2;
tex.1d.v4.f32.s32 loadX6, [tex, track];
track += ldx*2;
readAs = ((tid >> 1) & 7) << 4;
图中线程 2*i 和 2*i+1 会用到同一段 A,可以写作 (i/2) % 8。这段位操作就是这个表达式的位操作实现。<<4 实现了每段 A 的列向量间隔 16 字节,这是向量载入 4 个浮点数的大小。
readBs = (((tid & 0x30) >> 3) | (tid & 1)) << 4 + 2048;
readAs = ((tid >> 1) & 7) << 4;
readBs = (((tid & 0x30) >> 3) | (tid & 1)) << 4 + 2048;
while (track0 < end)
{
// Process each of our 8 lines from shared
for (j = 0; j < 8; j++)
{
// We fetch one line ahead while calculating the current line.
// Wrap the last line around to the first.
prefetch = (j + 1) % 8; // +1代表预读取
// Use even/odd rows to implement our double buffer.
if (j & 1) // 在两组寄存器j0和j1直接切换
{
// 共享内存到寄存器的传输还是使用向量指令
// 在maxas的代码中可见j0Ax<00-03>是4个连续的寄存器,一个指令就可以完成到这4个寄存器的传输而无需一一写出
ld.shared.v4.f32 j0Ax00, [readAs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j0By00, [readBs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j0Ax32, [readAs + 4*(prefetch*64 + 32)];
ld.shared.v4.f32 j0By32, [readBs + 4*(prefetch*64 + 32)];
}
else
{
ld.shared.v4.f32 j1Ax00, [readAs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j1By00, [readBs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j1Ax32, [readAs + 4*(prefetch*64 + 32)];
ld.shared.v4.f32 j1By32, [readBs + 4*(prefetch*64 + 32)];
}
}
// swap our shared memory buffers after reading out 8 lines
readAs ^= 4*16*64;
readBs ^= 4*16*64;
// Additional loop code omitted for clarity.
}
FFMA R0, R4, R5, R0; # R0, R4 在 bank 0,R5 在bank 1,R0和R4产生bank冲突
0-63 号分配给 C 矩阵;
64-71 和 80-87 分配给 A 矩阵,72-79 和 88-95 分配给 B 矩阵(分配两倍于实际大小用于流水线预读取)。
FFMA R2, R4.reuse, R5, R2; # 此处指定R4将会被重用,将其放入缓存
FFMA R0, R4.reuse, R5, R0; # R0和R4本来产生bank冲突,但因为上一条指令缓存了第二个操作数R4,只要到bank中取R0即可,从而避免了bank冲突
FFMA R0, R5, R4, R0; # R0和R4的bank冲突依然会发生,因为所缓存的R4在第2个操作数,但本指令中R4落在第3个操作数上。
1, 0, 2, 3, 5, 4, 6, 7, 33, 32, 34, 35, 37, 36, 38, 39,
45, 44, 46, 47, 41, 40, 42, 43, 13, 12, 14, 15, 9, 8, 10, 11,
17, 16, 18, 19, 21, 20, 22, 23, 49, 48, 50, 51, 53, 52, 54, 55,
61, 60, 62, 63, 57, 56, 58, 59, 29, 28, 30, 31, 25, 24, 26, 27
--:-:-:-:1 FFMA R37, R71.reuse, R72.reuse, R37;
--:-:-:-:1 FFMA R36, R71.reuse, R73.reuse, R36;
#前2条指令对第3个操作数缓存了R72和R73,它们被接下来的2条指令用到
--:-:-:-:1 FFMA R38, R69.reuse, R73, R38;
--:-:-:-:1 FFMA R39, R69.reuse, R72, R39;
#前4条指令对第2个操作数缓存了R69和R71,它们被接下来的4条指令用到
--:-:-:-:1 FFMA R45, R71.reuse, R74.reuse, R45;
--:-:-:-:1 FFMA R44, R71, R75.reuse, R44;
--:-:-:-:1 FFMA R46, R69.reuse, R75.reuse, R46;
--:-:-:-:1 FFMA R47, R69, R74.reuse, R47;
01:-:-:-:0 FFMA R1, R66.reuse, R72.reuse, R1;
--:-:-:-:1 LDS.U.128 R80, [R106+0x200];
--:-:-:-:1 FFMA R0, R66, R73.reuse, R0;
--:-:-:-:0 FFMA R2, R64.reuse, R73.reuse, R2;
--:-:-:-:1 LDS.U.128 R88, [R107+0x200];
--:-:-:-:1 FFMA R3, R64, R72.reuse, R3;
--:-:-:-:0 FFMA R5, R67.reuse, R72.reuse, R5;
--:-:-:-:1 LDS.U.128 R84, [R106+0x300];
--:-:-:-:1 FFMA R4, R67, R73.reuse, R4;
--:-:-:-:0 FFMA R6, R65.reuse, R73.reuse, R6;
--:-:1:-:1 LDS.U.128 R92, [R107+0x300];
--:-:-:-:1 FFMA R7, R65, R72.reuse, R7;
tid31 = tid & 31;
tid32 = tid & 32; // 只可能取两个值,0 为第一个warp,32为第二个warp
// Remove the high bits if present from the last loop's xor.
// Also remove the 2048 added onto readBs.
// 之前对A和B在共享内存中分配了两倍于所需的容量(4096字节),一块用于已载入数据的计算,一块用于载入下一段A和B,每一块的前2048字节存放A,后2048字节存放B
//这个AND操作等价于取第一块存放A的内存来存放C
readAs &= 0x7ff; // 本线程左上角数据在64x64矩阵中的行坐标
readBs &= 0x7ff; // 本线程左上角数据在64x64矩阵中的列坐标
// Write to shared using almost the same shared mapping as before but collapse readBs down to stride one.
writeCs = (readBs / 4) * 64 + readAs; // 本线程左上角数据在64x64矩阵中的相对左上角的一维偏移,根据行坐标和列坐标计算出。64/4是行优先储存矩阵进行向量传输的跨度。对于线程0这就是图7左图中的绿色格子中最上面的一个。
// Read out with a mapping amenable to coalesced global writes
readCs = ((tid32 << 3) + tid31) << 2; // 本线程在读取时开始的一维偏移,不考虑用于将元素个数转换为字节数的<<2操作,可以看到每个warp中的31个线程的编号tid31对应连续的readCs,也就是图7右图中的一整列黄色格子,第一个warp对应左边一列,第二个warp对应右边一列,中间相隔4行,也就是64*4==32<<3(第二个warp中tid32就是32)个元素。由图7右图可见很明显一个warp可以一次传输32个连续的浮点数。
ldc4 = ldc * 4; // ldc是矩阵C在行优先储存中列方向的跨度,因子4代表其单位为字节而不是浮点数。
cx = bx*64 + tid31; // cx可看作所要写入主显存的那一整列的在整个矩阵C中所对应的行数
cy = by*64 + (tid32 >> 1); // cy可看作所要写入主显存的那一整列的在整个矩阵C中所对应的列数,显然对于同一个warp列数是一样的
// Cy00, Cy04, Cy08, Cy12 是图7右图中上面那四个绿色格点在整个矩阵C中的偏移
// 虽然它们在共享内存中相隔1列,在矩阵C中它们之间的间隔是4列,所有它们之间的偏移是ldc4*4
Cy00 = (cy*ldc + cx) * 4 + C;
Cy04 = Cy00 + ldc4 * 4;
Cy08 = Cy00 + ldc4 * 8;
Cy12 = Cy00 + ldc4 * 12;
foreach copy vertical line of 8 registers from C into .v4.f32 cs0 and cs4
{
// 步骤1. 从不连续的寄存器到共享内存
// Feed the 8 registers through the warp shuffle before storing to global
// 这里有一个缺失的步骤,每个线程首先将其上下对其的两个4x4矩阵中取出同一列的各四个元素,此时它们为了避免bank冲突
// 不得不位于不连续的寄存器上,这个步骤将其复制到8个连续的额外的寄存器cs0-cs7,上面的矩阵使用cs0-3,下面的使用cs4-7
st.shared.v4.f32 [writeCs + 4*00], cs0; // 将连续的寄存器cs0,cs1,cs2,cs3用向量指令传输到共享内存中该4个数对应的位置
st.shared.v4.f32 [writeCs + 4*32], cs4; // 和上一行同样的操作,因为上下两个4x4矩阵间隔32个数,需要对写入位置增加4*32字节的偏移
// 步骤2. 从共享内存读取到寄存器,重用cs寄存器,不过此时并不用到其连续的特性
// cs0, cs2, cs4, cs6位于同一行上,在行优先储存中相差64个元素,4*64字节
ld.shared.f32 cs0, [readCs + 4*(0*64 + 00)];
// cs1,cs3,cs5,cs7位于另一行上,由图7右图可见与上一行相差32个元素
ld.shared.f32 cs1, [readCs + 4*(0*64 + 32)];
ld.shared.f32 cs2, [readCs + 4*(1*64 + 00)];
ld.shared.f32 cs3, [readCs + 4*(1*64 + 32)];
ld.shared.f32 cs4, [readCs + 4*(2*64 + 00)];
ld.shared.f32 cs5, [readCs + 4*(2*64 + 32)];
ld.shared.f32 cs6, [readCs + 4*(3*64 + 00)];
ld.shared.f32 cs7, [readCs + 4*(3*64 + 32)];
// 步骤3. 将cs寄存器的数写入主显存,对于整个warp相当于将一列连续的32个浮点数写入主显存。逻辑上可以看作是步骤2的反过程,除了改列的位置在共享内存和主显存中有所不同。
st.global.f32 [Cy00 + 4*00], cs0;
st.global.f32 [Cy00 + 4*32], cs1;
st.global.f32 [Cy04 + 4*00], cs2;
st.global.f32 [Cy04 + 4*32], cs3;
st.global.f32 [Cy08 + 4*00], cs4;
st.global.f32 [Cy08 + 4*32], cs5;
st.global.f32 [Cy12 + 4*00], cs6;
st.global.f32 [Cy12 + 4*32], cs7;
// 在下一次循环中输出本线程所计算的4个4x4矩阵的下一列,对应于C矩阵中的下一列,注意不要和图7中共享内存中的下一列混淆。
Cy00 += ldc4;
Cy04 += ldc4;
Cy08 += ldc4;
Cy12 += ldc4;
// After processing forth set shift over to the stride 32 registers
// 补充说明,4次循环后4个4x4矩阵的左边两个已经传输到主显存了,接下去要传输右边的两个。
// 左边和右边两对4x4矩阵在C矩阵中对应的位置可以通过平移32列而重合,考虑到矩阵本身宽度有4列(在之前4次循环中已经通过 += ldc4 4次得到实现)
// 实际需要额外平移的是左右两对4x4矩阵的间距32-4=28列,这是这就是28这个magic number的来由
if (4th iteration)
{
Cy00 += ldc4 * 28;
Cy04 += ldc4 * 28;
Cy08 += ldc4 * 28;
Cy12 += ldc4 * 28;
}
}