机器之心矩阵相乘在GPU上的终极优化:深度解析Maxas汇编器工作原理( 五 )


2. 代码中用了四个 track 变量来记录四次载入的偏移 , 很容易想到只用一个 track 变量 , 载入一次后对偏移加两行再做一次载入来完成同样的工作:
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;
这样做的问题是 tex.1d.v4.f32.s32 指令发出后其 track 操作数是不会被保存的 , 为了保证其不被下一个增量指令修改 , 必须要插入控制代码等待前一条载入指令完成 , 而最坏情况下该指令可能花去上百个时钟周期 。 用四个偏移变量就可以不用等待传输完成就可以将四条载入指令一一发射出去 , 也是起到了流水线的作用 。 其代价是每个线程需要额外占用三个寄存器 , 所幸 GPU 上有足够的寄存器可以提供 。
将共享内存中的数据载入寄存器
上节的工作完成后 , 共享内存中就有 A 和 B 的数据各 8 行 , 每行 64 个浮点数 。 将其各取出一行就可以将其中的元素进行前述的加乘操作 , 完成后各再取出一行直到共享内存中的 8 行数据被用完 , 此时其他 warp 应该已经在共享内存的另一组完成了从纹理内存的传输 , 计算线程只需切换到另一组进行计算即可 。
如图 2 所示 , 对于每个线程 , 其实只需要 64 个浮点数中的 8 个 , 其在 A 和 B 向量中位置可以根据图上的计算出 , 具体计算过程在代码中是通过一顿骚位操作实现的 , 在此可以提前做一说明:
readAs = ((tid >> 1) & 7)
图中线程 2*i 和 2*i+1 会用到同一段 A , 可以写作 (i/2) % 8 。 这段位操作就是这个表达式的位操作实现 。
readBs = (((tid & 0x30) >> 3) | (tid & 1))
B 的行向量中的选择更复杂一点 , 首先观察到对于偶数线程每隔 16 个线程 B 方向就要差 2 段(8 个浮点数) , 所以对于 6 个比特位表示的 64 线程 , tid & 0x30 表示其中代表 tid mod 16 的后四位可以被遮盖掉 , 只有前两位对选择 B 有意义 。 其后的 >>3 其实是先 >>4 将前两位拉到个位数 , 再 *2 来表达相差的两段 。 | (tid & 1) 等价于 +(tid & 1) , 表达的线程 2*i+1 永远会选择线程 2*i 后的那段数据 , 这也补上了之前相差两段中缺失的部分 。
在图 2 中可能很早就有人注意到其中的线程排布顺序非常别扭 , 并没有按照线程号按行或列一个个排下来 , 其原因是为了避免共享内存访问的 bank 冲突 。 bank 冲突的定义和发生的条件在 CUDA 官方文档中有详细说明 , 简单地说就是共享内存的访问按地址被分成若干个 bank(最简单的方法是做求余数的操作) , 如果两个线程要访问位于同一 bank 的共享内存 , 其访问无法同时完成 , 访存时间成倍增加 , 增加的倍数由一个 bank 最多被多少个线程同时访问决定 。 当然这是最一般的情况 , GPU 中提供了一些机制 , 比如广播 , 尽量减轻 bank 冲突对访问时间的影响 。 图 2 所示的线程顺序就是为了消除 bank 冲突所作的调整后的最佳排序 。 另一个别扭的地方是每个线程计算 4 个而不是直接计算 , 这也是为了避免 bank 冲突的技巧 , 在每个线程的实际计算中 4 个完全等价于 1 个矩阵 。
在实现代码中还用到了一个技巧 , 虽然每线程只需要输入 16 个输入数据 , 实际分配的寄存器是这个数字的两倍 , 目的和前述的类似 , 是为了用两组寄存器实现流水线 , 即每个线程在用一行数据作计算时预先读取读取下一行的数据 。
readAs = ((tid >> 1) & 7) readBs = (((tid & 0x30) >> 3) | (tid & 1))
while (track0
// Additional loop code omitted for clarity.}


推荐阅读