Kernel Dev 训练营/ 提示与讲解
C3 提示与讲解 · Track C · CUDA C++ · 对照 solutions/c3_transpose.cu 逐行读

C3 讲解:转置里的合并访存与 bank conflict

转置一个数都不算,却能把带宽从 ~800 GB/s 提到 ~1400 GB/s。逐行读 naive 和 tiled 两个 kernel:二维索引、为什么写入不合并、shared memory 中转时 block 坐标怎么对调,以及那一列 padding 怎么消掉 bank conflict。

怎么读这一页

对照 drillsc3_transpose.cu 读。最容易绕晕的是第 ③ 节的“坐标对调”,那里用一个具体的线程把所有下标都算了一遍。

① 先看全貌:读和写不可能同时连续

y = x.T,x 是 [M, N],y 是 [N, M],都是行优先。y[c][r] = x[r][c]。

两头总有一头是跨步的。解决办法是找一个不怕跨步的中转站:shared memory。

naive vs tiled:跨步访问发生在哪里悬停节点查看连接
tiled 版本把“跨步”挪进了 shared memory:片上内存没有 sector 的概念,跨步读不浪费带宽,只需要避开 bank conflict。

RTX 5090 上 8192×8192 的实测(题目页的数据):naive 805 GB/s,x.t().contiguous() 804 GB/s,tiled 1409 GB/s。

类比 · 搬书换书架

旧书架上的书按行摆,新书架要求按列摆。naive 的做法:拿起一本书,走到新书架对应的位置放下,再回来拿下一本。旧书架这边是顺手一排排拿的,新书架那边每放一本都要跑到另一列:腿都跑断了(跨步写)。tiled 的做法:先从旧书架一次抱一摞(32×32)到手边的桌子上(shared memory),在桌上换个方向,再一排排地放上新书架。桌子就在手边,在桌上怎么挪都很快。

② naive kernel:二维 launch 和二维下标

drillstranspose_naive_kernel(18–24 行) · host 端 drillsdim3 block / grid(66–67 行)

dim3 block(32, 8);                                         // 每个 block 32 × 8 = 256 个线程
dim3 grid((N + 31) / 32, (M + 7) / 8);                     // x 方向覆盖列,y 方向覆盖行

const int c = blockIdx.x * blockDim.x + threadIdx.x;       // 列:快维度
const int r = blockIdx.y * blockDim.y + threadIdx.y;       // 行
if (r < M && c < N) y[(int64_t)c * M + r] = x[(int64_t)r * N + c];

同一个 warp 里,读写地址长这样(M = N = 8192):

lane c 读 x[r*N + c] 写 y[c*M + r]
0 0 r·8192 + 0 0 + r
1 1 r·8192 + 1 8192 + r
2 2 r·8192 + 2 16384 + r
… … 连续,4 个 sector 每个相隔 32 KB,32 个 sector

写入要 32 个独立的 32 字节 sector,每个只用 4 字节,有效带宽只剩 1/8。

③ tiled kernel:最绕的是坐标对调

drillstranspose_tiled_kernel(26–53 行)

每个 block 负责 x 的一个 32×32 tile,用 32 × 8 = 256 个线程,每个线程搬 4 个元素。分两个阶段,中间一个 barrier。

先看动画:一个 tile 的两个阶段

转置动画(缩小版:8×8 的 tile,一个 warp 8 个 lane)
x(显存,行优先)
→
tile[8][9](shared memory)
→
y = xᵀ(显存,行优先)
读 x 的事务0 写 y 的事务0
格子里的两位数是元素在 x 里的 (行, 列),颜色表示它在 x 里的行。每一步是一个 warp 发出的一条访存指令;一个小方块代表一次内存事务(这里假设一行 8 个 float 正好是一次事务)。

动画里最关键的是两种模式下“写 y 的事务”那一行:tiled 写 8 次,naive 写 64 次。真实的 32×32 tile 上差距是 32 倍。tiled 版本的代价是阶段 2 按列读 shared memory,这就是第 ④ 节 bank conflict 的来源。

阶段 1:把 x 的 tile 按行读进 shared memory

drills33–39 行

const int c  = blockIdx.x * TILE + threadIdx.x;   // x 的列
const int r0 = blockIdx.y * TILE + threadIdx.y;   // 这个线程负责的第一行
for (int j = 0; j < TILE; j += ROWS) {            // j = 0, 8, 16, 24
  const int r = r0 + j;
  if (r < M && c < N) tile[threadIdx.y + j][threadIdx.x] = x[(int64_t)r * N + c];
}

block 只有 8 行线程,tile 有 32 行,所以每个线程隔 8 行搬一次,共 4 次:

tile 行
0–7
8–15
16–23
24–31
谁来搬
j = 0ty 0–7
j = 8ty 0–7
j = 16ty 0–7
j = 24ty 0–7

threadIdx.y = 1 的那一行线程依次搬 tile 的第 1、9、17、25 行。每次都是 32 个线程读 x 同一行的 32 个连续 float:合并。

为什么不直接开 32×32 = 1024 个线程、每人搬一个?可以,但每个线程只做一次 load/store,固定开销占比更高;block 太大,每个 SM 能同时驻留的 block 也更少。256 个线程、每人 4 个是常见的折中。

barrier

drills41 行 __syncthreads():阶段 2 里一个线程要读别的线程写进 tile 的数据(它读的是 tile[threadIdx.x][...],行列反过来了)。必须等整个 tile 写完。

阶段 2:block 坐标对调,再按行写 y

drills45–52 行

const int yc  = blockIdx.y * TILE + threadIdx.x;   // y 的列(= x 的行)
const int yr0 = blockIdx.x * TILE + threadIdx.y;   // y 的行(= x 的列)
for (int j = 0; j < TILE; j += ROWS) {
  const int yr = yr0 + j;
  if (yr < N && yc < M) y[(int64_t)yr * M + yc] = tile[threadIdx.x][threadIdx.y + j];
}

两件事同时发生:

  1. tile 级别对调:x 的第 (by, bx) 个 tile 转置后是 y 的第 (bx, by) 个 tile。所以 y 的行用 blockIdx.x 算,y 的列用 blockIdx.y 算。
  2. tile 内部对调:threadIdx.x 仍然走 y 的列(连续方向),保证写入合并;数据从 tile[threadIdx.x][threadIdx.y + j] 取,也就是在 shared memory 里按列读。
一个 tile 的旅程:x 的 (by, bx) → y 的 (bx, by)悬停节点查看连接
同一个 block (bx=2, by=5) 在阶段 1 读 x 的第 5 行第 2 列 tile,在阶段 2 写 y 的第 2 行第 5 列 tile。

拿一个具体线程验算一遍:block (bx = 2, by = 5),线程 (tx = 3, ty = 1),只看 j = 0。

阶段 计算 结果
1 c = 2·32 + 3 = 67,r = 5·32 + 1 = 161 读 x[161][67],写进 tile[1][3]
2 yc = 5·32 + 3 = 163,yr = 2·32 + 1 = 65 写 y[65][163],值取自 tile[3][1]
验证 tile[3][1] 是谁写的?阶段 1 里 tx = 1、ty = 3 的线程:r = 163,c = 65 tile[3][1] = x[163][65],而 y[65][163] 应该等于 x[163][65] ✓

注意同一个线程在两个阶段搬的不是同一个元素:它写进 tile[1][3],却读出 tile[3][1]。这就是为什么中间必须有 barrier。

类比 · 拼图块换位置

把整张图转置,就像把一幅拼图沿对角线翻过来。每一块拼图(tile)都要搬到对角位置:第 5 行第 2 列的那块搬到第 2 行第 5 列(block 坐标对调)。而且每一块本身也要翻过来(tile 内部的行列对调)。负责这块拼图的工人(block)先把它拿到工作台上(shared memory),翻过来,再放到新位置。工作台上一块拼图里的小格子谁拿都行,所以拿起来的人和放下去的人不必是同一个(不同线程,需要 barrier)。

④ 那一列 padding:bank conflict

drills27–30 行 __shared__ float tile[TILE][TILE + 1];

shared memory 被分成 32 个 bank,每个 bank 每周期能服务一个 4 字节的字。地址为 a(以 float 为单位)的元素在第 a % 32 个 bank。一个 warp 里如果有多个线程访问同一个 bank 的不同地址,就要排队串行,这叫 bank conflict。

先用 4 个 bank 看懂:二维的 tile 其实是一条一维数组

先别管 32,缩小成 4 个 bank、4×4 的 tile。关键只有一条规则:一个数在第几个 bank,只看它的地址除以 bank 数的余数。

shared memory 其实是一条长长的一维数组,地址 0、1、2、3、4……依次排下去,bank = 地址 % 4,就是 0、1、2、3、0、1、2、3……一直循环:

地址:  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
bank:  0  1  2  3  0  1  2  3  0  1  2  3  0  1  2  3

二维的 tile[i][j] 是按行一行接一行放进这条数组的:地址 = i × 每行长度 + j。

不加 padding(每行 4 个):地址 = 4i + j。把每一格的 bank 号画出来:

         列0 列1 列2 列3
第0行:    0   1   2   3      ← 地址 0..3
第1行:    0   1   2   3      ← 地址 4..7
第2行:    0   1   2   3      ← 地址 8..11
第3行:    0   1   2   3      ← 地址 12..15

每一行都从 bank 0 开始:行长 4 正好等于 bank 数,转一圈又回到起点。读第 0 列时,4 个线程读地址 0、4、8、12,全是 bank 0。一个 bank 每拍只能服务一个线程,只能排队,读一次花了 4 拍。

加 padding(每行 5 个,最后一个空着不用):地址 = 5i + j。

         列0 列1 列2 列3 (空)
第0行:    0   1   2   3   0      ← 地址 0..4
第1行:    1   2   3   0   1      ← 地址 5..9
第2行:    2   3   0   1   2      ← 地址 10..14
第3行:    3   0   1   2   3      ← 地址 15..19

每一行多占了一格,下一行的起点就往后挪了一个 bank。再读第 0 列:地址 0、5、10、15,bank 是 0、1、2、3,各不相同,4 个 bank 同时工作,1 拍读完。

下面的演示把这个过程一步步做出来:先把 tile 一行一行地放进一维内存,再读第 0 列。切换“每行 4 个 / 每行 5 个”对比。

二维 → 一维(4 个 bank,4×4 的 tile)
shared memory 实际的样子:一条一维数组。每格上面是地址,下面是 bank = 地址 % 4
你在代码里看到的二维 tile[i][j](格子下面是它的 bank)
读第 0 列时,每个 bank 要排几个请求
颜色代表 bank:同一种颜色就是同一个 bank。“空”是 padding 占掉的位置,不存数据。

一句话:问题不在“读一列”本身,而在于行长正好等于 bank 数时,每一行都从同一个 bank 开始,于是同一列的元素全落在同一个 bank 上。每行多加一个空位,行长从 32 变成 33,每一行的起点就错开一个 bank,同一列的 32 个元素正好分散到 32 个不同的 bank。代价是每行浪费一个 float,32 行一共 128 字节。

类比 · 店员排班

一家店有 4 个店员(bank),顾客按号码分配:0 号给店员 0,1 号给店员 1……4 号又回到店员 0,依次循环。

把上面的 4 换成 32,就是真实的硬件:

阶段 2 里一个 warp 读的是 tile[0..31][k](同一列,tx 变化):

动画:bank conflict 是怎么排队的

bank 动画(缩小版:8 个 bank,一个 warp 8 个 lane)
tile 的每一格:所在的 bank
8 个 lane 的请求
8 个 bank 的队列(每周期每个 bank 只能服务 1 个请求)
格子里的数字是 bank 号 = 地址 % 8,颜色也按 bank 着色。tile[8][9] 的最后一列是 padding,永远不存数据,只是把每一行往后错开一个 bank。

试一试这四种组合:tile[8][8] + 读一列要 8 个周期(全部挤在 bank 0),tile[8][9] + 读一列只要 1 个周期,而读一行不管有没有 padding 都是 1 个周期。真实硬件上把 8 换成 32:不加 padding 是 32 路冲突。

读第 0 列时,前 8 个线程各自落在哪个 bank:

线程(tile 行 i)
0
1
2
3
4
5
6
7
[32][32] 的 bank
0
0
0
0
0
0
0
0
[32][33] 的 bank
0
1
2
3
4
5
6
7

多出来的那一列永远不存数据,只是把每一行往后错开一个 bank。代价:32 × 4 = 128 字节 shared memory。

阶段 1 是按行写 tile[i][0..31],bank = (33i + k) % 32,k 连续,本来就无冲突,padding 不影响它。

另一种做法:swizzle(异或)

padding 浪费一点空间,也会让地址不再对齐到 2 的幂,对向量化读写和 Tensor Core 不友好。生产代码更常用 swizzle:不多加列,而是把列号和行号异或,物理列 = j ^ (i % 32)。同一列 j 的 32 行被异或到 32 个不同的物理列,同样无冲突。

C4 和 G2 会再遇到这个技巧。

⑤ 边界:M、N 不是 32 的倍数时

grid 是 ⌈N/32⌉ × ⌈M/32⌉,最右边和最下边的 tile 只有一部分在矩阵里。两个阶段各自做越界检查:

注意阶段 2 的条件是 yr < N && yc < M,因为 y 的形状是 [N, M]。M 和 N 写反是这题最常见的 bug,而方阵测试发现不了,所以测试里一定要有 M ≠ N 的形状。

⑥ 常见坑与自测

自测 1:tile 改成 `float tile[32][34]`,阶段 2 按列读还有冲突吗?

bank = (34i + k) % 32 = (2i + k) % 32。i 从 0 到 31 时 2i % 32 只取 16 个不同的值(0, 2, 4, …, 30),每个 bank 被两个线程访问:2 路冲突。padding 必须让行长度和 32 互素,33 是最省空间的选择。

自测 2:M = 100,N = 50,tiled 版本的 grid 是多少?最后一个 block 有多少个线程真正干活?

grid = (⌈50/32⌉, ⌈100/32⌉) = (2, 4)。最后一个 block(bx = 1, by = 3)覆盖 x 的第 96–127 行、32–63 列,合法的只有行 96–99、列 32–49,即 4 × 18 = 72 个元素。阶段 1 里只有 72 次读真正发生。

源码与资料索引

本仓库:drillssolutions/c3_transpose.cu · drillsmine/c3_transpose.cu · drillstests/test_c3_transpose.py · drillscontent/c3.md

对照实现

文档