Kernel Dev 训练营/ 提示与讲解
G1 提示与讲解 · Track G · Layout / CuTe · 对照 solutions/g1_layouts.py 逐行读

G1 讲解:BlockedLayout 和 SliceLayout 到线程的映射

Triton 里一个 [16, 16] 的张量看起来是一整块,硬件上它被拆给 2 个 warp × 32 个 lane,每个 lane 持有 4 个寄存器。这一页用参考解本身算出一张完整的“谁持有哪个元素”的图,逐个函数讲清楚坐标公式、order、重复和广播,再用同一个 layout 回答三个实际问题:tl.sum 沿哪个轴便宜、load 是否合并、归约之后的 SliceLayout 长什么样。

怎么读这一页

对照 drillsg1_layouts.py 读。这题是纯 Python,可以直接 from solutions.g1_layouts import * 在终端里跟着算。本页所有数字都是用参考解算出来的。

① 先看全貌:四个参数

一个 BlockedLayout 由四个参数描述(drills26–31 行):

参数 含义 本页的例子
size_per_thread (spt) 每个线程沿每一维持有几个连续元素 [1, 4]:每个线程拿一行里连续的 4 个
threads_per_warp (tpw) 一个 warp 的 32 个 lane 沿每一维怎么排(乘积必须是 32) [8, 4]:8 行 × 4 列
warps_per_cta (wpc) warp 沿每一维怎么排(乘积是 num_warps) [2, 1]:2 个 warp 上下摆
order 编号时哪一维变得最快(order[0] 最快) [1, 0]:列(第 1 维)最快,也就是行优先

一个 CTA tile 沿每一维覆盖 spt × tpw × wpc 个元素(drillstile()):[1·8·2, 4·4·1] = [16, 16],正好等于张量的形状。

用参考解算出来的完整映射,每格写的是持有它的 lane 编号,颜色是 warp:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0
0
0
0
0
1
1
1
1
2
2
2
2
3
3
3
3
1
4
4
4
4
5
5
5
5
6
6
6
6
7
7
7
7
2
8
8
8
8
9
9
9
9
10
10
10
10
11
11
11
11
3
12
12
12
12
13
13
13
13
14
14
14
14
15
15
15
15
4
16
16
16
16
17
17
17
17
18
18
18
18
19
19
19
19
5
20
20
20
20
21
21
21
21
22
22
22
22
23
23
23
23
6
24
24
24
24
25
25
25
25
26
26
26
26
27
27
27
27
7
28
28
28
28
29
29
29
29
30
30
30
30
31
31
31
31
8
0
0
0
0
1
1
1
1
2
2
2
2
3
3
3
3
9
4
4
4
4
5
5
5
5
6
6
6
6
7
7
7
7
10
8
8
8
8
9
9
9
9
10
10
10
10
11
11
11
11
11
12
12
12
12
13
13
13
13
14
14
14
14
15
15
15
15
12
16
16
16
16
17
17
17
17
18
18
18
18
19
19
19
19
13
20
20
20
20
21
21
21
21
22
22
22
22
23
23
23
23
14
24
24
24
24
25
25
25
25
26
26
26
26
27
27
27
27
15
28
28
28
28
29
29
29
29
30
30
30
30
31
31
31
31

warp 0(行 0–7)warp 1(行 8–15)warp 0 的 lane 5:第 1 行第 4–7 列,4 个寄存器

从四个参数到一张图悬停节点查看连接
每一层都是“把上一层的小块按某个形状摆开”:一个线程拿 1×4,32 个 lane 按 8×4 摆成 8×16,2 个 warp 按 2×1 摆成 16×16。order 决定 lane 0、1、2… 是先往右排还是先往下排。
类比 · 剧院售票

一个剧院 16 排 × 16 座。每张票是连着的 4 个座位(size_per_thread = [1, 4])。每个售票员(warp)手下有 32 个票贩子(lane),按 8 排 × 4 组的方式分座位(threads_per_warp = [8, 4]),order = [1, 0] 意思是票贩子先在同一排里从左到右编号,排满一排再到下一排。两个售票员一个管前 8 排、一个管后 8 排(warps_per_cta = [2, 1])。想知道 9 排 6 座是谁卖的,就按这个规则倒推:后半场 → 售票员 1;第 1 排(相对)、第 2 组 → 票贩子 5;组内第 3 个座 → 第 2 号寄存器。

② 坐标公式和 order

drillsthread_elements(78–95 行) 的核心是这一行:

coord[d] = (rep_c[d] * tile[d] + warp_c[d] * spt[d] * tpw[d] + lane_c[d] * spt[d] + s_c[d]) % shape[d]

四项从粗到细:第几个 tile 重复、warp 在 tile 里的位置、lane 在 warp 里的位置、元素在线程的小块里的位置。

order 的作用在 _delinearize(drills52–58 行):把一个一维编号(lane 号、warp 号)拆成多维坐标,order[0] 那一维变得最快。

手算 lane 5(tpw = [8, 4],order = [1, 0]):

步骤 d 计算 结果
先处理 order[0] = 1(列) 1 5 % 4 = 1,剩下 5 // 4 = 1 lane_c[1] = 1
再处理 order[1] = 0(行) 0 1 % 8 = 1 lane_c[0] = 1

lane 5 在 warp 里位于第 1 行第 1 组。warp 0 的 warp_c = [0, 0]。代入公式:行 = 0 + 0 + 1·1 + 0 = 1,列 = 0 + 0 + 1·4 + s = 4..7。所以 warp 0 lane 5 持有 (1, 4)、(1, 5)、(1, 6)、(1, 7),和图里一致。

反过来查:drillsowners((9, 6)) 返回 [(1, 5, 2)]:warp 1、lane 5、第 2 号寄存器。

寄存器编号的约定(docstring 17–19 行):同一个小块里的元素先编号,然后才是重复。这和 Triton 对 blocked layout 的寄存器编号方式一致。

③ 重复和广播

drillsreps():张量比 tile 大时,同一个图案沿那一维重复,每个线程拿到更多寄存器;比 tile 小时,坐标取模回绕,几个线程持有同一个元素(广播)。

张量形状 reps warp 0 lane 0 持有的元素 现象
[16, 16] (1, 1) (0, 0..3) 正好一个 tile,4 个寄存器
[32, 16] (2, 1) (0, 0..3) 和 (16, 0..3) 沿行重复两次,8 个寄存器
[8, 16] (1, 1) (0, 0..3) tile 有 16 行,取模后 warp 1 也落在第 0–7 行:owners((0,0)) 是 warp 0 lane 0 和 warp 1 lane 0

广播意味着两个 warp 在做完全相同的事,算力浪费了一半。layout 和张量形状不匹配时就会出现这种情况。

④ 用 layout 回答三个实际问题

问题 1:tl.sum 沿哪个轴便宜?

drillsreduce_plan(123–136 行) 把一次归约拆成 C2 讲过的三层:

axis = 1(沿列,行内求和) axis = 0(沿行,列内求和)
线程内(in_thread) spt[1] × reps[1] = 4 个数在寄存器里加 spt[0] × reps[0] = 1
warp 内 shuffle 轮数 log₂ tpw[1] = log₂ 4 = 2 log₂ tpw[0] = log₂ 8 = 3
跨 warp(shared memory + barrier) wpc[1] = 1:不需要 wpc[0] = 2:需要
同一个 layout,两个方向的归约代价悬停节点查看连接
这就是 T2、T3 里“一行一个 program、行内归约”的底层代价。好的 layout 让归约方向上的数据尽量留在一个线程、一个 warp 里。

问题 2:load 是否合并?

drillswarp_load_sectors(139–153 行):一个 warp 的一条向量化 load 碰到了多少个 32 字节 sector,和实际请求的字节数比。

layout dtype 碰到的 sector 请求的字节 效率
本页的例子 [1,4]、order [1,0] fp32 16 512 16 × 32 = 512,100%
同上 fp16 8 256 100%
按列切:spt [4,1]、order [0,1],形状 [64,16] fp32 32 512 32 × 32 = 1024,50%

第一种:每个 lane 拿一行里连续的 4 个 fp32(16 字节,一次向量化读取),同一行的 4 个 lane 拼成连续的 64 字节。第二种:每个 lane 拿的是一列里的 4 个,行优先的张量里它们相隔一整行,一个 sector 只用到一部分。

问题 3:归约之后是什么 layout?

tl.sum(x, axis=1) 的结果是一维的 [16],它的 layout 是 SliceLayout(dim=1, parent):把父 layout 的第 1 维去掉(drillsslice_elements(109–120 行))。

warp 0 的 lane 0–7 在 SliceLayout 下持有的元素:

lane
0
1
2
3
4
5
6
7
持有
(0,)
(0,)
(0,)
(0,)
(1,)
(1,)
(1,)
(1,)

父 layout 里第 0 行的 16 个元素分在 lane 0–3 手里,求和之后这 4 个 lane 都持有同一个结果:第 0 行的和。SliceLayout 天然带着广播。

这就是为什么 T2 里 x − tl.max(x, axis=0) 可以直接相减:max 的结果是 SliceLayout,它的每个值已经在需要它的每个线程手里了,广播回父 layout 不需要任何数据搬运。反过来,如果两个张量的 layout 不兼容(比如一个 blocked、一个 slice 但父 layout 不同),Triton 会插入一次 convert_layout,通常要经过 shared memory,很贵。

⑤ 对照 Triton 源码

⑥ 常见坑与自测

自测 1:同样的 layout,形状 [16, 32]。warp 0 lane 5 持有哪些元素?tl.sum(axis=1) 的 in_thread 是多少?

tile 是 [16, 16],reps = (1, 2),沿列重复两次。lane 5 持有 (1, 4..7) 和 (1, 20..23),共 8 个寄存器。in_thread = spt[1] × reps[1] = 4 × 2 = 8。

自测 2:想让 tl.sum(axis=0) 也不需要跨 warp,warps_per_cta 应该怎么改?

改成 [1, 2]:两个 warp 左右摆,每个 warp 负责全部 16 行中的一半列。这时 tpw = [8, 4] 只覆盖 8 行,tile 变成 [8, 32],对 [16, 16] 的张量沿行要重复 2 次、沿列取模回绕(广播)。所以还要同时调整 tpw(比如 [16, 2]),让 tile 重新和形状匹配。layout 的几个参数总是要一起调。

源码与资料索引

本仓库:drillssolutions/g1_layouts.py · drillsmine/g1_layouts.py · drillstests/test_g1_layouts.py · drillscontent/g1.md

Triton:tritonBlockedEncodingAttr · tritonGluon 02-layouts · tritonGluon 01-intro

相关讲解:C2 reduction(归约的三层)· T1(编译器怎么拆一维向量)· T2(tl.max 的广播)