优化 shared memory 访问¶
上游 NVIDIA / H200 调优参考
本页保留自 tile-ai/TileOPs.github.io 的 NVIDIA 平台教程。所有实测数字、图表、硬件参数及 SM、warp、shared memory 等 规则均属于原文的 H200 / CUDA 语境,不是 Ascend 910B1 的结果或硬件说明。 保留这些案例用于理解调优方法;向 Ascend 移植时须重新验证硬件规则与性能。 本 fork 的 Ascend 测量方法见计时方法。
数据经过 shared memory 中转时,多了一处需要考虑 access pattern 的地方:从 global memory 写进来、再从 shared memory 读进寄存器,两步都在访问 shared memory。这一页讲这两步上的 bank conflict —— 它由什么决定、怎么用 pad 消掉、以及 pad 该按什么算。
这一页的实测都在 H200 上、SM 时钟锁在 1830 MHz、输入大于 L2 的 60 MiB、block 数足以填满整卡 —— 这组条件之外结论可能反转,判据见优化 global memory 访问。
shared memory 的 bank 结构¶
shared memory 由 32 个 bank 构成,每 bank 宽 4 个字节。将 shared memory 的地址空间按 4 字节划分成 word(下文一律按 word 计数),一个地址落在哪个 bank 上,由 (字节地址 / 4) mod 32 决定。一个 bank 每周期只能处理一个 word 的访问,多个线程并发访问 shared memory 时,只要访问落在不同的 bank 上,它们就在同一个周期一起完成。多个线程落在同一条 bank 上时,又分三种情形:
- 访问的是不同的 word —— 硬件把这次请求拆成若干次无冲突的请求依次完成,拆分的次数就是冲突的路数。
- 读取的是同一个 word —— 任意两个线程只要落在同一个 word 内(哪怕取的是其中不同的字节),这个 word 会被广播给所有请求它的线程,不产生冲突。分布在不同 bank 上的多个广播还会合并为一次 multicast。
- 写入的是同一个地址 —— 只有一个写入生效,是哪一个未定义。
访问 shared memory 的 access pattern 应当尽可能做到无 bank conflict。
冲突路数由什么决定¶
我们考虑一种不失通用性的情况。下面这个一维数组 sh 在 shared memory 上,每个线程读取其中连续的 chunk 个元素:
sh = T.alloc_shared((threads * chunk,), dtype) # 一维数组,threads * chunk 个元素
for c in T.serial(chunk):
acc[0] = acc[0] * sh[tx * chunk + c] # 线程 tx 读自己那一段
一个 warp 的 32 个线程同步执行这个循环,同一次迭代里 c 对它们取同一个值、tx 取 0 到 31:这时 32 个线程以等间隔访问 shared memory。例如 chunk = 64 时,这 32 个线程在同一次迭代里读的是第 c、第 64 + c、第 128 + c、…… 第 1984 + c 个元素。相邻两个线程相差 chunk 个元素,这个差值称为这段访问的 stride,记作 \(S\),换算成 word 是 \(S = \text{chunk} \times E / 4\) 个(\(E\) 是元素的字节数)。stride 与数组声明成几维、下标怎么写都无关。
另一个变量是访存指令的位宽。以向量化的方式一次访问 \(w\) 个连续的 word,\(w\) 取 1、2、4,对应 32 bit、float2 的 8 字节、float4 的 16 字节。于是线程 \(t\) 读的是第 \(St\) 到 \(St + w - 1\) 个 word。
两条前提成立时,冲突路数可以直接从上面的硬件事实数出来:
- \(S\) 是整数个 word。 fp16 的
chunk取奇数时它带半个 word,取公约数无从谈起,只能按字节地址逐个算出线程落在哪条 bank 上再数。 - 32 个线程请求的 \(32w\) 个 word 互不相同,即 \(S \ge w\)。落在同一个 word 上的线程走广播,不占额外的周期,下面按 \(32w\) 个 word 计数就不成立;\(S < w\) 时相邻线程的向量区间彼此重叠,同样不成立。
整个 warp 要取 \(32w\) 个 word,而 shared memory 每周期最多处理 32 个,所以这条指令至少要 \(w\) 个周期 —— 这是位宽带来的下界,与地址无关,冲突指的是超出这个下界的部分。\(St \bmod 32\) 只取到 \(g = \gcd(S, 32)\) 的倍数,也就是 \(32/g\) 条 bank,每条被碰到 \(g\) 次;再叠加 \(j = 0, \dots, w-1\) 的平移。\(g\) 与 \(w\) 都是 2 的幂,必有一个整除另一个,于是分两种情形:
| bank 的落点 | 周期数 | 冲突 | |
|---|---|---|---|
| \(g \le w\) | 32 条 bank 各被请求 \(w\) 次 | \(w\),正好是下界 | 无 |
| \(g > w\) | 只有 \((32/g) \cdot w\) 条被碰到,各 \(g\) 次 | \(g\) | \(g / w\) 路 |
标量读(\(w = 1\))时它就是 \(\gcd(S, 32)\):\(S\) 与 32 互素时 32 个线程铺满 32 条 bank,无冲突;\(S\) 是 32 的倍数时全部挤在同一条 bank 上,32 路串行 —— fp16、chunk = 64 就是后者,\(S\) 是 128 字节即 32 个 word。
写成不等式,是因为最后一步假定硬件能把任意一组无冲突的 word 凑进一个周期,而 NVIDIA 没有公开 64 bit 与 128 bit 访问时 lane 的实际分组方式。
用 pad 改变 stride¶
stride 由 chunk 决定,而 chunk 通常由算法决定,往往无法任意改动。当发生\(N\)路冲突时,一种可行方式是在每段末尾增加 pad 个元素,让 stride 变成 chunk + pad —— gcd 随之改变,冲突路数也就随之改变。例如:fp16、chunk = 64时,上只要加 2 个元素,stride 就从 32 个 word 变成 33 个,与 32 互素,冲突完全消失。
pad = 0 时 32 个线程全挤在 bank 0;加 2 个元素之后 stride 变成 33 个 word,与 32 互素,32 个线程正好铺满 32 条 bank。pad 该取多少¶
判据由上面的式子给出,两条都要满足:
| 访问位宽 | \(w\) | 冲突路数要求 | 段起点的对齐要求 | fp32、chunk = 64 上最快的 pad |
|---|---|---|---|---|
| 32 bit,标量读 | 1 | \(\gcd(S, 32) = 1\) | 4 字节 | pad = 1(\(S = 65\) word) |
64 bit,float2 |
2 | \(\gcd(S, 32) \le 2\) | 8 字节 | pad = 2(\(S = 66\) word) |
128 bit,float4 |
4 | \(\gcd(S, 32) \le 4\) | 16 字节 | pad = 4(\(S = 68\) word) |
两条方向相反,所以不能只看一条:位宽越宽,对冲突路数的要求越松,对对齐的要求越紧。奇数 pad 对标量读是最优解,对向量化读却是最差的 —— 它让段起点错开 4 字节。128 bit 的 shared 读要求 16 字节自然对齐,对齐不足时这条指令的行为是未定义的;编译期能看出对齐不足时,编译器会退回窄指令,下面实测里 \(\gcd = 1\) 那一行就是这种情形。
实测(H200,fp32,chunk = 64,消费循环重复 32 遍使 shared 一侧成为瓶颈,耗时随遍数成比例)给出每种组合比同位宽最快的那一行慢多少倍:
| \(\gcd(S, 32)\) | 32 bit | 64 bit | 128 bit | 下界预测(32 / 64 / 128) |
|---|---|---|---|---|
| 1 | 1.00 | 1.10 | 2.27 | 1 / 1 / 1 |
| 2 | 1.72 | 1.00 | 1.01 | 2 / 1 / 1 |
| 4 | 3.29 | 1.81 | 1.00 | 4 / 2 / 1 |
| 8 | 6.52 | 3.55 | 4.04 | 8 / 4 / 2 |
| 16 | 12.97 | 7.62 | 7.99 | 16 / 8 / 4 |
| 32 | 25.82 | 13.97 | 15.71 | 32 / 16 / 8 |
对角线上的三个 1.00 就是上表那三行推荐。\(g \le w\) 那一侧下界是紧的;\(g > w\) 那一侧下界不紧,128 bit 实测恰好是它的 2 倍(4.04 对 2、7.99 对 4、15.71 对 8);成因要更低层的证据才能定,NVIDIA 未公开这两种位宽下 lane 的分组方式。\(\gcd = 1\) 那一行的 2.27 是对齐造成的:\(S = 65\) word 即 260 字节,不是 16 的倍数。
pad 要按 chunk 算¶
固定字节数的 pad 会在某些 chunk 上落回最坏情形。段起点的对齐要求把候选限制在 16 字节的整数倍,记 pad 为 \(k\) 个 16 字节(\(k = 1, 2, \dots\)),于是
\(k\) 固定时 \(\gcd(S, 32)\) 随 chunk 变。fp16 与 bf16(\(E = 2\))的几个 chunk:
| chunk | \(S\)(\(k = 1\)) | \(\gcd(S, 32)\) | \(S\)(\(k = 2\)) | \(\gcd(S, 32)\) |
|---|---|---|---|---|
| 32 | 20 | 4 | 24 | 8 |
| 56 | 32 | 32 | 36 | 4 |
| 64 | 36 | 4 | 40 | 8 |
| 72 | 40 | 8 | 44 | 4 |
| 128 | 68 | 4 | 72 | 8 |
chunk = 56 那一行的 \(S\) 恰好是 32 个 word,与 pad = 0 落在同一条 bank 上 —— pad 加了,冲突没消。做法是在 \(k = 1\) 与 \(k = 2\) 两个候选里取 \(\gcd(S, 32)\) 小的那个。\(\text{chunk} \times E\) 是 16 的倍数时,两者必有一个把 \(\gcd\) 降到 4:记 \(q = \text{chunk} \times E / 16\),则 \(S = 4(q + k)\),\(\gcd(S, 32) = 4 \gcd(q + k, 8)\),而 \(q + 1\) 与 \(q + 2\) 一奇一偶。
实测(H200,SM 时钟锁在 1830 MHz,bf16,CUPTI 设备耗时,每次迭代前清 L2,取 200 次的中位数,镜像 ghcr.io/tile-ai/tileops-runner:cu132-torch2.13-tl-afcebed1-dev)。四行的线程数都取到让 chunk 等于 56,同一行两列只差 pad:
| 输入 | 线程数 × chunk | \(k = 1\)(pad 8 个元素) TB/s |
\(k = 2\)(pad 16 个元素) TB/s |
|---|---|---|---|
| \(2048 \times 3584\) | 64 × 56 | 1.38 | 3.07 |
| \(2048 \times 7168\) | 128 × 56 | 1.58 | 3.29 |
| \(1024 \times 14336\) | 256 × 56 | 1.51 | 3.05 |
| \(512 \times 28672\) | 512 × 56 | 1.32 | 2.33 |
这四个宽度是 Qwen2-7B 的 hidden size、Llama-3-70B 的 hidden size、Llama-3-8B 与 Llama-3-70B 的 FFN 中间维,不是构造出来的反例。
这组测量的 shared 一侧不是标量读:cuobjdump 显示 \(S = 36\) word 时每线程 16 条 LDS.64,即 \(w = 2\),所以路数是 \(\gcd(S, 32) / 2\) —— 两列分别是 16 路与 2 路。路数差 8 倍而带宽只差 2.2 倍,是因为 2 路那一列的瓶颈已经回到 DRAM。段起点错开 8 字节以下时它退回 LDS(\(w = 1\))—— 位宽由编译器定,不由声明 pad 的人定,所以上表两列都要实测,不能只算 \(\gcd\)。
实测扫描¶
下面的实测每线程逐个元素读(\(w = 1\)),chunk 取 2 的幂,每个线程读自己那一段 —— 上面两个前提都成立。
H200,SM 时钟锁在 1830 MHz。fp16,输入 \(65536 \times 4096\)(512 MB,大于 60 MiB 的 L2)。kernel 把整行搬进 shared memory,每个线程一段 chunk + pad 个元素,逐段做串行前缀积再写回,读加写共 1 GB。括号内是上面公式预测的冲突路数:
| chunk | 线程数 | pad = 0 TB/s |
pad = 2 TB/s |
pad = 4 TB/s |
pad = 8 TB/s |
pad = 16 TB/s |
|---|---|---|---|---|---|---|
| 16 | 256 | 1.63(8 路) | 2.99(1 路) | 3.29(2 路) | 2.58(4 路) | 0.86(16 路) |
| 32 | 128 | 0.89(16 路) | 3.32(1 路) | 3.35(2 路) | 2.57(4 路) | 1.54(8 路) |
| 64 | 64 | 0.46(32 路) | 3.63(1 路) | 3.70(2 路) | 2.74(4 路) | 1.62(8 路) |
| 128 | 32 | 0.46(32 路) | 3.25(1 路) | 3.31(2 路) | 2.76(4 路) | 1.61(8 路) |
20 个配置里,1 路与 2 路在 3.0 以上,4 路降到 2.6 附近,8 路及以上跌到 1.6 以下。带宽随预测的路数单调下降,所以在这组条件下公式可以直接用来缩小 pad 的候选。
使用时的注意事项¶
-
公式给出候选,最终值靠实测。 上表里 1 路与 2 路都在 3.0 以上,4 路降到 2.6 附近,8 路及以上跌到 1.6 以下,所以公式的用处是把候选缩到「算出来不超过 \(w\) 路」的那几个。这几个之间要实测:四组
chunk上 2 路都略高于 1 路,但差距从 0.9% 到 10% 不等,不构成一条可以照搬的规则。 -
改完 pad 要重扫
chunk。pad = 0那一列最优的是chunk = 16(1.63),pad = 4那一列最优的是chunk = 64(3.70)。前一列的排序主要由冲突路数决定 ——chunk = 16撞的是 8 路,chunk = 64撞的是 32 路。冲突消掉之后四组都是 2 路,最优点换了位置。表里线程数与chunk联动(两者之积恒为行宽 4096),所以换位置的成因不止chunk一个,占用率与循环长度也跟着在变;能确定的只是改完 pad 之后chunk的排序会变。 -
chunk变了要重算 pad。 pad 写成固定字节数,等于假定 \(\gcd(S, 32)\) 与chunk无关,而上一节那张表说明它不是:chunk = 56、\(k = 1\) 时 \(S\) 回到 32 个 word。把 pad 写成chunk的函数 —— 在 \(k = 1, 2\) 里取 \(\gcd(S, 32)\) 小的那个 —— 才和这一页的判据自洽。 -
这一页只在数据经过 shared memory 时适用。 按优化 global memory 访问里的取舍,\(V\) 小时用向量化的 blocked,数据直接进寄存器,不经 shared memory,没有 bank 冲突可言;\(V\) 大到寄存器压力压低占用率时才改用 staged,以及整行需要被 block 内所有线程共享时,这一页才适用。
下面两段是 shared 缓冲的声明,差别只在 stride。反例,stride 恰好是 32 个 word 的倍数:
正例,pad 按 chunk 算,取 16 字节的整数倍里 \(\gcd(S, 32)\) 最小的那个:
import math
def pick_pad(chunk: int, elem_bytes: int) -> int:
"""16 字节的整数倍里,gcd(S, 32) 最小的 pad,单位是元素。"""
return min(
(16 // elem_bytes, 32 // elem_bytes), # k = 1、k = 2
key=lambda pad: math.gcd((chunk + pad) * elem_bytes // 4, 32),
)
pad = pick_pad(chunk, elem_bytes) # 候选之间仍要实测
sh = T.alloc_shared((threads * (chunk + pad),), dtype) # stride = chunk + pad