本文是《GPU Kernel 工程:从 CUDA 执行模型到 FlashAttention》系列的第 3 篇(共十一篇)。上一篇:CUDA 编程模型与第一个 kernel;下一篇:共享内存与 reduction——softmax、LayerNorm 与 online softmax。
上一篇写出了第一个 kernel:一个 BF16 的 y = x + b,每个线程处理一个元素。它能跑、结果正确,但没有回答”它跑得够快吗”。这一篇就回答这个问题,并把答案推到极限。
先把理论下界放在最前面,后面所有讨论都对着它算。BF16 的 y = x + b 对每个元素读 2 个 BF16、写 1 个 BF16,共 6 字节,做 1 次加法(1 FLOP):
A100 SXM 80GB 的 HBM2e 带宽约 2.0 TB/s,BF16 Tensor Core 约 312 TFLOPS(均为公开标称值),ridge point 是 \(312 / 2.0 \approx 156\) FLOP/B。0.17 与 156 差了三个数量级:这个 kernel 无论怎么写都是 memory-bound,它的时间下界只由字节数决定:
\[t_{\min} = \frac{\text{字节数}}{\text{带宽}} = \frac{3 \cdot n \cdot 2\ \text{B}}{2.0\ \text{TB/s}}\]\(n = 2^{28}\)(每个 tensor 512 MiB,三个共 1.5 GiB ≈ 1.61 GB)时,\(t_{\min} \approx 0.81\) ms。任何实现都不可能比这个数快;工程的全部目标,就是逼近它。
AI 负载里除 GEMM 与 attention 之外的绝大多数算子——激活函数、残差加、dtype 转换、RoPE、掩码、dropout、量化/反量化——都属于这一类。把它们写到带宽极限是最基本的功课,也是理解后面所有 kernel 的起点:GEMM 和 attention 的 tile 加载,本质上仍是这一篇讨论的访存模式。
总纲给这一篇的核心问题是:
一个 elementwise kernel 跑出了 90% 带宽,还有什么可优化的?1
一、总览
1. 答案先说在前面
答案是”没有了,要么融合,要么少做”。这条边界很重要——它决定了工程师应该把时间花在哪里。本文先把”如何到 90%”讲清楚,再解释为什么 90% 是一条墙。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 一个 warp 的内存请求发生了什么 | 32 字节 sector 与 128 字节 cache line、访问模式与效率表、AoS 与 SoA、写入与只读路径 |
| 三 | 向量化访存 | 为什么每线程 4 字节不够、float4 / __nv_bfloat162 / int4、对齐要求与尾部处理 |
| 四 | grid-stride loop 与占用率 | grid 不必等于元素数、Little’s law 与 memory-bound 为什么也需要多 warp、占用率的约束 |
| 五 | 非连续 Tensor | 把线性 index 变成多维 offset、TensorIterator 在 host 侧做了什么 |
| 六 | 读 ATen 的 elementwise 实现 | 入口与路径选择、launch_vectorized_kernel 与 can_vectorize_up_to、向量化与非向量化路径、AT_DISPATCH |
| 七 | 融合:90% 之后 | 三个 kernel 与一个 kernel、Inductor 融合的收益来源 |
| 八 | 实践:把 BF16 add 推到 90% | 理论下界、naive / 向量化 / 向量化 + grid-stride 三个版本、通用 2D stride 版本、load_inline 测试、读者应看到的量级 |
| 九 | 本文小结 | |
| 十 | 自测 | 5 道题 |
二、一个 warp 的内存请求发生了什么
1. 32 字节 sector 与 128 字节 cache line
GPU 不是按线程访问内存的,而是按 warp。一个 warp 的 32 个线程同时执行一条加载指令时,硬件收集 32 个地址,按它们落在哪些内存事务里进行合并(coalescing),然后向 L1/L2 发出请求。
合并的粒度有两级:
- sector:32 字节,是 L2 与 HBM 之间、以及 L1 与 L2 之间传输的最小单位;
- cache line:128 字节,由 4 个连续的 sector 组成,是 L1 的行大小。
把整条访存路径的各级放在一起看,sector 与 cache line 分别是哪两级之间的搬运单位、以及后面几章反复用到的延迟与带宽数字,都在这张表里(A100 SXM 80GB,延迟与 L1/L2 带宽为微基准测得的典型量级,不同型号有差异):
| 层级 | 容量 | 与上一级之间的传输粒度 | 延迟量级 | 带宽量级 |
|---|---|---|---|---|
| 寄存器 | 256 KB / SM(65536 × 32 bit) | 直接作为操作数 | 0 | — |
| L1(与共享内存合用) | 192 KB / SM | 128 B cache line = 4 个 sector | ~30 cycle | ~128 B / cycle / SM |
| L2 | 40 MB(整卡) | 32 B sector(L1 ↔ L2) | ~200 cycle | 数 TB/s(整卡) |
| HBM2e | 80 GB | 32 B sector(L2 ↔ HBM) | ~600 ns ≈ 800 cycle | 2.0 TB/s |
一次 warp 级加载最终被拆成若干个 sector 请求。决定效率的不是线程数,而是这 32 个地址一共触碰了多少个 sector。有效字节数(warp 真正需要的)除以实际搬运的字节数(sector 数 × 32 B),就是访存效率。
举最简单的例子:32 个线程每人读一个 float(4 字节),地址连续且起点按 128 字节对齐。总共需要 128 字节,恰好落在 4 个 sector 里,效率 100%。这是所有 elementwise kernel 应该追求的形态:
线程 0 1 2 3 ... 31
地址 0 4 8 12 ... 124 (相对 128 B 对齐的起点)
sector |---- 0 ----|---- 1 ----|---- 2 ----|---- 3 ----|
每个 sector 32 B,全部有效 → 4 sector,128 B,效率 100%
2. 访问模式与效率表
把访问模式改一改,sector 数会迅速膨胀。以下每种情况都是 32 个线程各读 4 字节(有效字节固定为 128 B):
- 跨步 2(线程 i 读地址 \(8i\)):地址范围 0–252,跨 8 个 sector,搬运 256 B,只用一半,效率 50%。
- 跨步 8(线程 i 读地址 \(32i\)):每个线程独占一个 sector,32 个 sector、1024 B,效率 \(1/8 = 12.5\%\)。
- 跨步 ≥ 32 字节:不管跨多大,每线程至少一个 sector,效率不会比 1/8 更好;再大只会让 L2/TLB 行为更差。
- 未对齐起始(起点偏移 4 B):128 B 数据从 sector 0 的第 4 字节开始,尾巴落入第 5 个 sector,搬运 160 B,效率 80%。
- 随机地址:最坏 32 个 sector,效率 12.5%,且没有任何局部性可利用。
前三种画出来(■ 是线程真正需要的 4 B,□ 是被一并搬来但没人用的字节,每格 32 B 一个 sector):
连续、对齐 |■■■■■■■■|■■■■■■■■|■■■■■■■■|■■■■■■■■| 4 sector 100%
s0 s1 s2 s3
起点偏移 4 B |□■■■■■■■|■■■■■■■■|■■■■■■■■|■■■■■■■■|■□□□□□□□| 5 sector 80%
s0 s1 s2 s3 s4
跨步 2 |■□■□■□■□|■□■□■□■□|■□■□■□■□|■□■□■□■□|■□■□■□■□|■□■□■□■□|■□■□■□■□|■□■□■□■□| 8 sector 50%
s0 s1 s2 s3 s4 s5 s6 s7
跨步 8 |■□□□□□□□|■□□□□□□□|■□□□□□□□| … 每线程独占一个 sector … |■□□□□□□□| 32 sector 12.5%
s0 s1 s2 s31
汇总成表:
访问模式(32 线程 × 4 B,有效 128 B) sector 数 搬运字节 效率
连续、128 B 对齐 4 128 B 100%
连续、起点偏移 4 B(未对齐) 5 160 B 80%
跨步 2(每线程间隔 8 B) 8 256 B 50%
跨步 4(间隔 16 B) 16 512 B 25%
跨步 8 及以上(间隔 ≥ 32 B) 32 1024 B 12.5%
随机 ≤32 ≤1024 B ≥12.5%
BF16 是 2 字节,32 个线程连续读只有 64 B,占 2 个 sector——效率仍是 100%,但每条加载指令只带回 64 B,要靠更多指令才能填满带宽。这一点是下一章向量化的动机。
需要注意:这张表算的是 L1/L2 层面的请求效率,HBM 侧因为 L2 会缓存被”浪费”的 sector,跨步访问在某些情况下的实际 DRAM 流量比表中略好;但 L2 命中率依赖数据能否留在 40 MB(A100)的 L2 里,对 GB 级的 tensor 不能指望。工程上直接按这张表估算即可。
3. AoS 与 SoA
跨步访问最常见的来源不是显式的 stride,而是数据结构。考虑存 \(n\) 个三维点:
struct Point { float x, y, z; }; // Array of Structures (AoS)
Point* pts; // 线程 i 读 pts[i].x
线程 i 读 pts[i].x 时地址间隔 12 B,32 个线程跨 384 B、12 个 sector,有效 128 B,效率 33%。如果一个 kernel 只需要 x 分量,另两个分量是纯浪费。改成 SoA(Structure of Arrays):
struct Points { float* x; float* y; float* z; }; // 三个独立数组
两种布局下,”warp 读 32 个点的 x 分量”触碰的内存:
AoS pts[i] = {x, y, z} 内存: x0 y0 z0 | x1 y1 z1 | x2 y2 z2 | x3 … 线程 i 读 x_i,间隔 12 B
warp 触碰:■□□■□□■□□■□□ … 共 384 B、12 sector,有效 128 B → 33%
SoA x[], y[], z[] 内存: x0 x1 x2 x3 … x31 | … (y、z 在别处) 线程 i 读 x[i],间隔 4 B
warp 触碰:■■■■■■■■■■■■ … 共 128 B、4 sector → 100%
x[i] 连续,效率 100%。深度学习框架里的 tensor 天然是 SoA:一个 tensor 一块连续内存,dtype 单一。这也是为什么 tensor 抽象对 GPU 友好——当你在 kernel 里定义结构体数组时,要意识到你在往 AoS 的方向走。
一个反例是刻意的 AoS:把 RoPE 的 cos/sin 交错存放成 (cos, sin) 对,这样一个线程一次 float2 加载就同时拿到两者,反而比两个数组各读一次少一条指令。规则不是”永远 SoA”,而是”让一个线程一次访问的字节在内存里连续,让相邻线程访问的字节也连续”。
4. 写入与只读路径
以上讨论的是加载;存储的合并规则相同——warp 的 32 个写地址按 sector 合并,部分写入的 sector 需要 L2 做字节掩码合并,效率同样按触碰的 sector 数计算。区别在于写不需要等待返回,warp 发出存储指令后可以立即继续,因此写延迟对 kernel 的影响远小于读延迟;但写流量在 HBM 侧同样占带宽,2 读 1 写的 add 中写占了三分之一。
对只读数据,从 Volta 起 L1 与纹理缓存已经合并为同一块存储,用 const T* __restrict__ 修饰的指针,编译器会生成 ld.global.nc(non-coherent,等价于 __ldg)加载,允许数据在 L1 中缓存且不必与其他 SM 的写入保持一致性。对 elementwise kernel 来说数据只读一次、L1 命中率为零,这条路径的收益不在缓存,而在于编译器得到”没有别名”的保证后可以自由重排加载与存储——把三条加载都提前发出、再统一计算和写回,这正是第四章要讨论的 ILP。
三、向量化访存
1. 为什么每线程 4 字节不够
上一节的结论是连续访问效率 100%,看起来问题已经解决。但 A100 的 2.0 TB/s 是一个很高的速率:每个 SM 每秒要吞掉 \(2.0 \times 10^{12} / 108 \approx 18.5\) GB/s,按 1.41 GHz 折算约 13 字节/周期。一条 32 线程 × 4 B 的加载指令带回 128 B,也就是每个 SM 每 10 个周期就得发出一条加载指令并让它命中——这还没算地址计算、边界判断、类型转换和存储指令。
每线程 4 B(float) 线程 0 1 2 3 … 31 一条 warp 加载 = 128 B
地址 |----|----|----|----| … |----|
每线程 16 B(float4) 线程 0 1 … 31 一条 warp 加载 = 512 B
地址 |----------------|----------------| … |----------------|
同样 4 个 sector 一组连续,但一条指令搬 4 倍的字节,在飞的请求也多 4 倍
对 BF16 更糟:每线程 2 B,一条指令只带回 64 B。指令发射能力(每个 SM 4 个 warp 调度器,每周期各发一条)在 memory-bound kernel 里通常够用,但 LSU(load/store unit)的请求队列和 L1 的每周期事务数是有限资源。经验上,每线程只搬 2–4 字节的 kernel 很难超过 70–80% 的带宽。
解法是让每条加载指令搬更多字节:CUDA 支持 8、16 字节的向量化加载(ld.global.v2.b32、ld.global.v4.b32),一个线程一次拿 16 字节,一个 warp 一条指令 512 B、4 条完整的 cache line。指令数减到 1/4(相对 float)或 1/8(相对 BF16),每请求字节数增大,LSU 压力同比下降。
2. float4、__nv_bfloat162 与 int4
CUDA 内建向量类型中,float4、int4、uint4、double2 都是 16 字节且按 16 字节对齐;float2、__nv_bfloat162、half2 是 4 或 8 字节。对 BF16 数据,一个 16 字节的加载对应 8 个元素,常用的做法是用 int4 搬运,用 __nv_bfloat162 计算:
#include <cuda_bf16.h>
// 对 16 字节(8 个 BF16)做 x + b,累加用 float
__device__ __forceinline__ int4 add_bf16x8(int4 xa, int4 ba) {
const __nv_bfloat162* x2 = reinterpret_cast<const __nv_bfloat162*>(&xa);
const __nv_bfloat162* b2 = reinterpret_cast<const __nv_bfloat162*>(&ba);
int4 ya;
__nv_bfloat162* y2 = reinterpret_cast<__nv_bfloat162*>(&ya);
#pragma unroll
for (int k = 0; k < 4; ++k) {
float2 xf = __bfloat1622float2(x2[k]);
float2 bf = __bfloat1622float2(b2[k]);
y2[k] = __floats2bfloat162_rn(xf.x + bf.x, xf.y + bf.y);
}
return ya;
}
__bfloat1622float2 把一对 BF16 转成 float2,__floats2bfloat162_rn 反向舍入打包。这里的 reinterpret_cast 作用在寄存器里的局部变量上,编译器会把它优化成纯寄存器操作,不产生额外访存。
为什么不用 __hadd2 直接在 BF16 上加?对单次加法其实两条路结果通常逐位相同——__hadd2 也是 round-to-nearest-even,一次加法只舍入一次,先转 float 再加再舍回 BF16 的最终结果一样(第二篇实测里两版对齐就是这个原因)。差别在多步:一旦要连做几次运算(融合了 relu、乘、加,或累加),中间量留在 float 里只在最后舍入一次,逐步用 BF16 则每步都舍入,误差会累积;而转换和 FP32 运算在 memory-bound kernel 里几乎免费(前提是有足够 warp 把它们藏在访存后面)。所以本文所有 kernel 都遵循”BF16 存储、float 计算”的约定,与 ATen 的 opmath_type 做法一致——理由是多步精度与代码统一,不是单次加法更准。
3. 对齐要求、reinterpret_cast 与尾部处理
向量化加载有一个硬性要求:地址必须按向量宽度对齐。用 int4 读一个不是 16 字节倍数的地址,会触发 misaligned address 错误并让 kernel 崩溃。这带来三个工程细节。
第一,起始地址检查。cudaMalloc 返回的指针至少 256 字节对齐,PyTorch 的 caching allocator 也保证 512 字节对齐,所以完整 tensor 的 data_ptr() 天然满足条件;但 x[:, 1:] 这样的切片、storage_offset 非零的 view 就不一定。host 侧要检查:
bool aligned16 = (reinterpret_cast<uintptr_t>(ptr) % 16) == 0;
不满足时退回标量路径。ATen 的 can_vectorize_up_to 做的就是这件事,第六章会读它。
第二,reinterpret_cast 的语义。reinterpret_cast<const int4*>(x)[i] 表示”把 x 看成 int4 数组,取第 i 个”,也就是从 x 起第 \(16i\) 字节处读 16 字节。它要求 x 本身 16 字节对齐,而不只是 x + 16i。这一点很容易被”我只在 i 为 8 的倍数处访问”的直觉误导。
第三,尾部。\(n\) 不是 8 的倍数时,最后 \(n \bmod 8\) 个元素不能用 int4 读(会越界读取,甚至越界写入)。常见做法是:主循环只处理前 \(\lfloor n / 8 \rfloor \times 8\) 个元素,剩余的由某个 block 的前几个线程用标量方式补齐。第八章的代码会给出完整写法。
三个细节合在一起,就是第八章向量化 kernel 的元素 ↔ 线程映射(BF16,每格 2 B):
n = 8·n_vec + tail 例:n = 21 → n_vec = 2,tail = 5
元素 e0 e1 e2 e3 e4 e5 e6 e7 | e8 … e15 | e16 e17 e18 e19 e20
字节 0 15 | 16 31 | 32 34 36 38 40
└──── int4 #0 ────────┘ └ int4 #1 ┘ └── 尾部:不足 16 B ──┘
线程 i=0 一条 16 B 加载 线程 i=1 block 0 的线程 0…4
各标量处理 1 个元素
起始地址决定整条路径能否向量化:
x.data_ptr() …0000 (allocator 保证 512 B 对齐) % 16 == 0 → int4
x[:, 1:].data_ptr() …0002 (storage_offset = 1 个 BF16) % 16 != 0 → 退回标量
float4 对 FP32 是 4 个元素/线程;__nv_bfloat162 × 4 是 8 个元素/线程;INT8 用 int4 是 16 个元素/线程。每线程处理 4–8 个元素是 elementwise kernel 最常见的配置,ATen 的默认也在这个范围。
四、grid-stride loop 与占用率
1. grid 不必等于元素数
上一篇的 kernel 用 grid = ceil(n / blockDim) 让每个线程恰好处理一个元素。这没有错,硬件的 block 调度器会依次把 block 派发到 SM 上,几十万个 block 也能跑完。但还有另一种写法:
for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < n;
i += (int64_t)gridDim.x * blockDim.x) {
// 处理第 i 个元素(或第 i 个向量)
}
n = 12 个元素,grid × block = 4 个线程(T0…T3)
一线程一元素(grid = 3 个 block) T0 T1 T2 T3 | T4 T5 T6 T7 | T8 T9 T10 T11
e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11
grid-stride(grid 固定 1 个 block) 第 1 轮 T0 T1 T2 T3 → e0 e1 e2 e3
第 2 轮 T0 T1 T2 T3 → e4 e5 e6 e7 步长 = 4 = 全 grid 线程数
第 3 轮 T0 T1 T2 T3 → e8 e9 e10 e11 每一轮内相邻线程仍访问相邻地址
这就是 grid-stride loop:grid 大小与 \(n\) 解耦,每个线程处理 \(\lceil n / (\text{grid} \times \text{block}) \rceil\) 个元素,步长是整个 grid 的线程总数。相邻线程仍然访问相邻地址,合并性质不变。
grid 该多大?一个常见选择是每 SM 可驻留 block 数 × SM 数,再乘一个小倍数(2–4):
\[\text{grid} = k \cdot N_{SM} \cdot \text{blocks\_per\_SM}, \quad k \in [1, 4]\]blocks_per_SM 可以用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 查询。这样恰好填满 GPU 一到几轮,每个线程循环多次。
好处有三:
- 任意 \(n\):\(n\) 超过 \(2^{31}\) 个 block 的上限、或极小时都能用同一个 kernel,不需要在 host 侧算 grid;
- 复用:每个线程处理多个元素时,
blockIdx/threadIdx的地址计算、边界检查等固定开销被摊薄,且一个线程连续发出多个独立加载(不同迭代之间没有依赖),为下一节的延迟隐藏提供 ILP(指令级并行); - 占用率可控:grid 大小成为一个显式参数,可以调;某些 kernel(如需要跨 block 归约的)也依赖”所有 block 同时驻留”这个性质。
grid-stride 不是免费的:循环控制和 64 位地址算术要占几条指令,尾部迭代会有一部分线程空转。它在 elementwise kernel 上的收益通常是几个百分点,不是决定性的;决定性的是向量化。ATen 的 elementwise kernel 就没有用 grid-stride,而是”每 block 处理固定 1024 个元素、grid = ceil(N / 1024)”,第六章会看到。两种写法都能到 90%。
2. 占用率与 Little’s law:memory-bound 为什么也需要多 warp
一个常见误解是”memory-bound 的 kernel 不需要高占用率,反正瓶颈在带宽”。恰恰相反:要把带宽用满,必须有足够多的字节在飞。
HBM 的访问延迟约 500–800 ns(A100 上典型值取 ~600 ns)。带宽 2.0 TB/s。Little’s law 说,稳态下系统中的在飞请求量 = 吞吐 × 延迟:
\[\text{在飞字节数} = 2.0\ \text{TB/s} \times 600\ \text{ns} \approx 1.2\ \text{MB}\]%% 图:Little's law 算占用率:在飞字节 = 带宽 × 延迟,每 warp 加载宽度决定需要多少 warp / SM
flowchart LR
classDef k fill:#dbeafe,stroke:#1d4ed8
BW["带宽 2.0 TB/s"]:::k --> L["Little's law<br/>在飞字节 = 带宽 × 延迟"]
LAT["HBM 延迟 ~600 ns"]:::k --> L
L --> F["整卡需保持 ≈1.2 MB 在飞<br/>÷ 108 SM ≈ 11 KB / SM"]
F --> A["每 warp 一条 32-bit 加载 = 128 B<br/>→ 需要 ≈88 个 warp / SM<br/>超过 64 的硬件上限"]
F --> B["每 warp 一条 128-bit 加载 = 512 B<br/>→ 需要 ≈22 个 warp / SM"]
F --> C["每 warp 两条 128-bit 加载在飞(ILP)<br/>→ 需要 ≈11 个 warp / SM"]
分到 108 个 SM 上,每个 SM 要保持约 11 KB 的加载请求在路上。一个 warp 一条 128-bit 加载(32 线程 × 16 B)是 512 B,所以每个 SM 至少要有
\[\frac{11\ \text{KB}}{512\ \text{B}} \approx 22\]个 warp 各有一条 128-bit 加载在飞,才能把带宽填满。如果每线程只用 32-bit 加载(128 B/warp),需要约 88 个 warp——超过了每 SM 64 个 warp 的硬件上限,单凭占用率填不满带宽。
有两条路:一是提高占用率,让更多 warp 驻留;二是让每个线程连续发出多个独立加载(例如 grid-stride 循环展开两次、或每线程处理两个 int4),一个 warp 就有 2–4 条加载在飞,需要的 warp 数按比例减少。后一条路就是 ILP,它对寄存器的要求更高(每个在飞的加载都要一个目标寄存器),但对占用率的要求更低。真实 kernel 通常两者兼用:每线程 2 个 int4、每 SM 32 个以上的 warp。
3. 占用率的约束
每 SM 最多 2048 个线程(64 个 warp)、32 个 block、65536 个 32-bit 寄存器、164 KB shared memory(A100)。elementwise kernel 不用 shared memory,占用率只受寄存器和 block 数限制。一个线程用 \(R\) 个寄存器、block 有 \(T\) 个线程时:
\[\text{blocks\_per\_SM} = \min\left(\left\lfloor \frac{65536}{R \cdot T} \right\rfloor,\ \left\lfloor \frac{2048}{T} \right\rfloor,\ 32\right)\](寄存器分配按 warp 级、以 256 个为单位向上取整,实际值略小。)向量化 add kernel 每线程用 20–30 个寄存器,\(T = 256\) 时 \(65536 / (32 \times 256) = 8\) 个 block、2048 线程,占用率 100%。elementwise kernel 几乎不会遇到寄存器压力;用 __launch_bounds__(256) 告诉编译器 block 大小,可以防止它为了 ILP 过度分配寄存器。
block 大小本身对 elementwise kernel 影响不大,128 到 512 都常见。太小(如 32 或 64)会撞上每 SM 最多 32 个 block 的限制——32 个 block × 64 线程 = 2048 线程刚好够,但 32 × 32 = 1024 线程只有一半占用率;太大(1024)则一个 SM 最多只能驻留 2 个这样的 block(2048 线程上限,还要寄存器与 shared 允许),block 之间切换时的空档更难被填补,且尾部 block 的浪费更多。ATen 取 128、本文取 256,都是让每 SM 驻留 8–16 个 block 的选择,粒度足够细,调度器有余地。
还有一个与占用率无关但常被忽视的因素:每 SM 的 L1/LSU 事务数上限。一条 warp 级加载指令覆盖 4 条 cache line 时,L1 需要 4 个周期(每周期处理一条 128 B 的 line)才能把它处理完——这不是坏事,恰恰说明 128-bit 加载让 LSU 的每条指令都在做满载的工作;反过来,2 字节的标量加载一条指令只占半条 line,L1 每周期能处理的有效字节数只有向量化时的 1/8。这就是第三章”naive 很难超过 70–80%”的微架构解释。
至此,把 elementwise kernel 写到带宽极限的三件事已经齐了:合并(连续对齐)、向量化(16 B/线程)、足够的在飞请求(占用率 + ILP)。第八章把它们落成代码,先解决另一个绕不开的问题——tensor 不连续怎么办。
五、非连续 Tensor:stride 与 broadcast
1. 把线性 index 变成多维 offset
上面的 kernel 都假设 x、b、y 是同形状、连续的一维数组。实际调用 x + b 时,x 可能是转置后的 view(stride 不连续),b 可能是形状 [1, d] 的 bias 要广播到 [m, d]。kernel 需要 stride 信息。
一个 tensor 的元素 \((i_0, i_1, \ldots, i_{k-1})\) 在存储中的位置是:
\[\text{offset} = \sum_{d} i_d \cdot \text{stride}_d\]kernel 里每个线程拿到的是一个线性 index \(i \in [0, n)\),需要先按输出形状拆成多维坐标(从最内维开始连续做除法与取模),再用每个输入各自的 stride 算出各自的 offset:
// 二维情形:输出形状 [size0, size1],行主序
int64_t i0 = i / size1;
int64_t i1 = i - i0 * size1;
int64_t off_x = i0 * xs0 + i1 * xs1;
int64_t off_b = i0 * bs0 + i1 * bs1;
输出 y[m=3, d=4],线程拿到线性 i = 6 → i0 = 6 / 4 = 1,i1 = 6 % 4 = 2
x 连续 [3,4],stride (4, 1) off_x = 1·4 + 2·1 = 6 ┌ x00 x01 x02 x03 ┐
│ x10 x11 [x12] x13│
└ x20 x21 x22 x23 ┘
b 广播 [1,4]→[3,4],stride (0, 1) off_b = 1·0 + 2·1 = 2 [ b0 b1 [b2] b3 ] 三行读同一段
x 转置 view,stride (1, 3) off_x = 1·1 + 2·3 = 7 相邻线程 i1 差 1 → 地址差 3 个元素:不再合并
broadcast 就是 stride 为 0:b 的形状 [1, d] 扩展到 [m, d],第 0 维的 stride 设为 0,所有行读同一段内存。不需要物化任何数据。
两个性能提示。第一,只要最内维 stride 为 1,相邻线程仍访问相邻地址,合并不受影响;但如果最内维 stride 不是 1(如转置后的 x.t(),最内维 stride 是 4096 个元素 = 8 KiB),每个线程独占一个 sector,BF16 的效率只剩 \(2 / 32 = 6.25\%\)。这种情况下通常先 contiguous()(一次 transpose kernel 的代价通常小于低效访问——但它本身是一读一写,是否划算要按整条流水线算:TensorIterator 会按 stride 重排迭代顺序,很多情况下不必真的 copy),或者让 kernel 用 shared memory 做 tile 转置——那是第四篇的话题。第二,64 位整数除法在 GPU 上很慢(几十条指令),ATen 的做法是把除数预处理成”魔数 + 移位”(IntDivider),用乘法代替除法,且用 32 位 index。
2. TensorIterator 在 host 侧做了什么
PyTorch 的 elementwise 算子并不直接把 sizes/strides 传给 kernel,而是先经过 TensorIterator。它在 host 侧完成:形状广播、dtype 推断与类型提升、把可以合并的维度合并(例如 [m, d] 两维连续就当作一维 [m·d])、按 stride 重排维度让最内维是访问最密的、判断所有操作数是否连续并检查 32 位索引是否够用——然后把一个”已经整理好的迭代空间”交给 CUDA 端。这样 kernel 只需要处理”连续一维”和”带 OffsetCalculator 的一般情况”两种形态。本文不展开它,只需要知道下一章读到的 iter.is_contiguous()、iter.strides(i) 这些信息就来自这里。
六、读 ATen 的 elementwise 实现
有了以上概念,读 PyTorch 的实现就很直接了。源码版本为 v2.10.0,路径在 aten/src/ATen/native/cuda/ 下,主要是三个头文件:Loops.cuh(入口 gpu_kernel)、CUDALoops.cuh(kernel 与 launch)、MemoryAccess.cuh(向量化加载与 policy)。
1. 入口与路径选择
算子实现(如 ActivationSiluKernel.cu)用 gpu_kernel(iter, lambda) 描述”对每个元素做什么”,其余全部交给框架。gpu_kernel 经过 32 位索引检查后到 gpu_kernel_impl_nocast(CUDALoops.cuh):
// aten/src/ATen/native/cuda/CUDALoops.cuh(节选)
template <typename func_t>
void gpu_kernel_impl_nocast(TensorIteratorBase& iter, const func_t& f) {
using traits = function_traits<func_t>;
using arg0_t = typename traits::result_type;
constexpr int ntensors = traits::arity + 1;
// ...
std::array<char*, ntensors> data;
for (int i = 0; i < ntensors; i++) {
data[i] = (char*)iter.data_ptr(i);
}
int64_t numel = iter.numel();
bool contiguous = iter.is_contiguous();
if (contiguous) {
return launch_vectorized_kernel(numel, f, data);
}
auto offset_calc = ::make_offset_calculator<traits::arity + 1>(iter);
#ifndef USE_ROCM
constexpr int unroll_factor = sizeof(arg0_t) >= 4 ? 2 : 4;
launch_legacy_kernel<128, unroll_factor>(numel, [=] GPU_LAMBDA(int idx) {
auto offsets = offset_calc.get(idx);
arg0_t* out = (arg0_t*)(data[0] + offsets[0]);
*out = invoke(f, &data[1], &offsets[1], 1);
});
#endif
}
路径选择只有一个分支:全部操作数连续 → 向量化路径;否则 → 带 OffsetCalculator 的通用路径。data 是一个 char* 数组,data[0] 是输出、其后是输入;lambda f 的参数类型(通过 function_traits 提取)决定了每个操作数的元素类型。
从 gpu_kernel 到最终落地的三个 kernel,一共经过两次判断;后面 2–4 节逐个读它们,这里先把整条决策链画出来:
%% 图:gpu_kernel 的路径选择:是否连续、能否向量化两次判断,落到 vectorized / unrolled / 带 offset 计算的三个 kernel
flowchart TB
classDef host fill:#f1f5f9,stroke:#475569
classDef dec fill:#fef3c7,stroke:#b45309
classDef k fill:#dbeafe,stroke:#1d4ed8
OP["算子实现:gpu_kernel(iter, lambda)<br/>AT_DISPATCH 已把运行期 dtype 变成 scalar_t"]:::host
IMPL["gpu_kernel_impl_nocast<br/>data(0) = 输出,data(1..) = 输入"]:::host
Q1{"iter.is_contiguous()<br/>所有操作数都连续?"}:::dec
LV["launch_vectorized_kernel<br/>vec_size = min(16 / sizeof, can_vectorize_up_to)<br/>非 sm_90 / sm_100 再压到 ≤ 4"]:::host
Q2{"vec_size ≥ 2?<br/>输出与每个输入指针都对齐"}:::dec
KV["vectorized_elementwise_kernel(vec_size)<br/>128 线程 × 8 元素 = 1024 元素 / block<br/>只有最后一个 block 走带边界检查的 unroll"]:::k
KU["unrolled_elementwise_kernel<br/>标量加载,每线程 4 元素<br/>TrivialOffsetCalculator + 每元素边界检查"]:::k
OC["make_offset_calculator(iter)<br/>逐维 divmod × stride,IntDivider 魔数除法"]:::host
KL["elementwise_kernel(128, 2 或 4)(legacy)<br/>标量加载 + OffsetCalculator::get(idx)"]:::k
OP --> IMPL --> Q1
Q1 -- "是" --> LV --> Q2
Q2 -- "是(2 / 4 / 8)" --> KV
Q2 -- "否(= 1,对齐不够)" --> KU
Q1 -- "否(stride / broadcast)" --> OC --> KL
2. launch_vectorized_kernel 与 can_vectorize_up_to
连续路径先决定向量宽度(CUDA 分支,去掉 ROCm 部分):
// aten/src/ATen/native/cuda/CUDALoops.cuh(节选,CUDA 分支)
template <typename func_t, typename array_t>
static inline void launch_vectorized_kernel(int64_t N, const func_t& f, array_t data) {
using traits = function_traits<func_t>;
constexpr auto io_size = calc_io_size<func_t>();
auto stream = at::cuda::getCurrentCUDAStream();
using cpp_type = typename function_traits<func_t>::result_type;
const uint16_t max_vec_size = memory::can_vectorize_up_to<func_t>(data);
uint16_t vec_size = 16 / static_cast<uint16_t>(sizeof(cpp_type));
vec_size = std::min<uint16_t>(vec_size, max_vec_size);
cudaDeviceProp* p = at::cuda::getDeviceProperties(stream.device().index());
const int computeCapability = p->major * 10 + p->minor;
if (computeCapability != 90 && computeCapability != 100) {
vec_size = std::min<uint16_t>(vec_size, 4);
}
if constexpr (sizeof(cpp_type) < 2) {
vec_size = std::min<uint16_t>(vec_size, 4);
}
int tws = elems_per_thread<io_size>();
int bws = tws * num_threads();
int64_t grid = (N + bws - 1) / bws;
switch (vec_size) {
case 8: vectorized_elementwise_kernel<8, func_t, array_t>
<<<grid, num_threads(), 0, stream>>>(N, f, data); break;
case 4: vectorized_elementwise_kernel<4, func_t, array_t>
<<<grid, num_threads(), 0, stream>>>(N, f, data); break;
case 2: vectorized_elementwise_kernel<2, func_t, array_t>
<<<grid, num_threads(), 0, stream>>>(N, f, data); break;
case 1: { /* 退回 unrolled_elementwise_kernel,见下节 */ }
}
}
逐行对应前面的概念:
vec_size = 16 / sizeof(cpp_type):目标是 16 字节一次加载。BF16 得 8,FP32 得 4。can_vectorize_up_to<func_t>(data):对输出和每个输入指针检查对齐,取最小值(MemoryAccess.cuh):
// aten/src/ATen/native/cuda/MemoryAccess.cuh(节选,CUDA 分支)
template<typename scalar_t>
inline C10_HOST_DEVICE int can_vectorize_up_to(const char *pointer) {
uint64_t address = reinterpret_cast<uint64_t>(pointer);
constexpr int vec2_alignment = std::alignment_of_v<aligned_vector<scalar_t, 2>>;
constexpr int vec4_alignment = std::alignment_of_v<aligned_vector<scalar_t, 4>>;
constexpr int vec8_alignment = std::alignment_of_v<aligned_vector<scalar_t, 8>>;
if (address % vec8_alignment == 0) {
return 8;
} else if (address % vec4_alignment == 0) {
return 4;
} else if (address % vec2_alignment == 0) {
return 2;
}
return 1;
}
aligned_vector<scalar_t, N> 是一个 alignas(sizeof(scalar_t) * N) 的结构体(scalar_t val[N]),它的对齐要求就是向量的字节宽度。这正是第三章说的”host 侧对齐检查”。
computeCapability != 90 && != 100时把vec_size压到 4:在 2.10 中,8 元素向量只在 Hopper/Blackwell 上启用——所以 A100 上 BF16 的add实际用的是 4 × 2 B = 8 字节(64-bit)加载,H100 上是 16 字节。源码注释说明这是为了规避一个 NVCC 数值问题,并控制二进制体积(vec8 实例只为 sm_90/sm_100 编译)。elems_per_thread<io_size>():io_size是所有输入与输出元素大小之和(BF16 二元 op 为 6);它等于 1 时每线程 16 个元素,否则 8 个。num_threads()是 128(thread_constants.h中定义为C10_WARP_SIZE * 4)。所以一个 block 处理 \(128 \times 8 = 1024\) 个元素,grid = \(\lceil N / 1024 \rceil\)——不是 grid-stride。
3. vectorized_elementwise_kernel 的结构
// aten/src/ATen/native/cuda/CUDALoops.cuh(节选,vec_size != 8 分支)
template <int vec_size, typename func_t, typename array_t>
C10_LAUNCH_BOUNDS_1(num_threads())
__global__ void vectorized_elementwise_kernel(int N, func_t f, array_t data) {
using traits = function_traits<func_t>;
constexpr auto io_size = calc_io_size<func_t>();
int remaining = N - io_block_work_size<io_size>() * blockIdx.x;
if (remaining < io_block_work_size<io_size>()) {
// 最后一个不满的 block:退回带边界检查的 unroll policy
auto input_calc = TrivialOffsetCalculator<traits::arity>();
auto output_calc = TrivialOffsetCalculator<1>();
auto loader = memory::LoadWithoutCast();
auto storer = memory::StoreWithoutCast();
auto policy = memory::policies::unroll<array_t, decltype(input_calc),
decltype(output_calc), memory::LoadWithoutCast,
memory::StoreWithoutCast, elems_per_thread<io_size>()>(
data, remaining, input_calc, output_calc, loader, storer);
elementwise_kernel_helper(f, policy);
} else {
// 满 block:向量化加载,不做任何边界检查
elementwise_kernel_helper(
f, memory::policies::vectorized<vec_size, array_t,
elems_per_thread<io_size>()>(data));
}
}
结构非常清楚:尾部处理是按 block 而不是按元素做的——只有最后一个 block 走带边界检查的慢路径,其余 block 完全不做 if (i < n)。elementwise_kernel_helper(Loops.cuh)是通用骨架:policy.load(args, blockIdx.x) 把本 block 的 1024 个元素装进每线程 8 个寄存器槽,循环调用 f,再 policy.store。
vectorized policy 的 load_single_arg 是向量化加载的核心(MemoryAccess.cuh):
// aten/src/ATen/native/cuda/MemoryAccess.cuh(节选)
template<typename accessor_t, typename scalar_t>
__device__ inline void load_single_arg(accessor_t to, scalar_t *from) {
int thread_idx = threadIdx.x;
#pragma unroll
for (int i = 0; i < loop_size; i++) { // loop_size = elems_per_thread / vec_size
int index = thread_idx + i * num_threads();
auto v = load_vector<vec_size>(from, index); // 一次 aligned_vector 加载
#pragma unroll
for (int j = 0; j < vec_size; j++) {
to(vec_size * i + j) = v.val[j];
}
}
}
注意 index = thread_idx + i * num_threads():第 i 次迭代时,线程 t 读第 \(t + 128 i\) 个向量——相邻线程读相邻向量,每次迭代 warp 覆盖连续的 \(32 \times \text{vec\_size} \times \text{sizeof}\) 字节。BF16、vec 4 时一个 warp 一条指令 256 B、2 条 cache line;每线程 2 次迭代(8 元素 / 4)。这是第二章”连续对齐”与第三章”向量化”在源码里的直接体现。
一个 block(128 线程 t)处理 1024 个 BF16;vec_size = 4(A100)→ 每线程 2 次迭代
迭代 i=0 向量 idx = t t=0 t=1 t=2 … t=127
元素 e0-3 e4-7 e8-11 … e508-511 连续 1024 B
迭代 i=1 向量 idx = t + 128 t=0 t=1 t=2 … t=127
元素 e512-5 e516-9 e520-3 … e1020-1023 连续 1024 B
线程 t 的 8 个寄存器槽 to(4·i + j): e[4t..4t+3],e[512+4t..512+4t+3]
一个 warp 一次迭代:32 线程 × 4 元素 × 2 B = 256 B = 2 条完整 cache line
最后一个 block:remaining = N - 1024·blockIdx.x < 1024 → 整个 block 改走 unroll
4. 非向量化路径:unrolled_elementwise_kernel 与 elementwise_kernel
vec_size == 1(指针对齐不够)时走:
// aten/src/ATen/native/cuda/CUDALoops.cuh(节选)
template <typename func_t, typename array_t, int elems_per_thread,
typename inp_calc_t, typename out_calc_t,
typename loader_t, typename storer_t>
C10_LAUNCH_BOUNDS_1(num_threads())
__global__ void unrolled_elementwise_kernel(int N, func_t f, array_t data,
inp_calc_t ic, out_calc_t oc, loader_t l, storer_t s) {
int remaining = N - elems_per_thread * num_threads() * blockIdx.x;
auto policy = memory::policies::unroll<array_t, inp_calc_t, out_calc_t,
loader_t, storer_t, elems_per_thread>(data, remaining, ic, oc, l, s);
elementwise_kernel_helper(f, policy);
}
这里 elems_per_thread 传入 elementwise_thread_work_size() = 4(源码注释说 8 在 CUDA 上反而退化),block 处理 512 个元素。unroll policy 对每个元素做标量加载、用 remaining 做边界检查,并通过 inp_calc_t 计算 offset——连续时是 TrivialOffsetCalculator(offset = index),非连续时是 OffsetCalculator<N>。
而非连续 tensor 的通用路径(第 1 节 launch_legacy_kernel<128, unroll_factor>)用的是更朴素的 elementwise_kernel:
// aten/src/ATen/native/cuda/CUDALoops.cuh(节选)
template <int nt, int vt, typename func_t>
C10_LAUNCH_BOUNDS_2(nt, 4)
__global__ void elementwise_kernel(int N, func_t f) {
int tid = threadIdx.x;
int nv = nt * vt;
int idx = nv * blockIdx.x + tid;
#pragma unroll
for (int i = 0; i < vt; i++) {
if (idx < N) {
f(idx);
idx += nt;
}
}
}
nt = 128 线程、每线程 vt 个元素(4 字节以上类型取 2,更小的取 4)。idx += nt 保持相邻线程访问相邻 index,展开 vt 次给出 ILP。传入的 lambda 用 OffsetCalculator::get(idx) 把线性 index 拆成各操作数的字节 offset——它内部就是第五章描述的”逐维 divmod × stride”,只是用 IntDivider 的魔数除法代替了真除法,最多支持 25 维(MAX_DIMS)。
三个 kernel 对比一下:
kernel block 每线程元素 加载方式 边界检查 适用
vectorized_elementwise_kernel 128 8 (io=1:16) aligned_vector 只在最后 block 连续且对齐
unrolled_elementwise_kernel 128 4 标量 每元素 连续但未对齐
elementwise_kernel (legacy) 128 2 或 4 标量+OffsetCalc 每元素 非连续/广播
5. AT_DISPATCH:运行期 dtype 到编译期类型
gpu_kernel 的 lambda 是模板化的——scalar_t 必须在编译期确定。而 iter.dtype() 是运行期的枚举。桥梁是 AT_DISPATCH_* 宏族(aten/src/ATen/Dispatch.h)。以 SiLU 的反向为例(ActivationSiluKernel.cu):
AT_DISPATCH_FLOATING_TYPES_AND2(
at::ScalarType::Half, at::ScalarType::BFloat16,
iter.dtype(), "silu_backward_cuda", [&]() {
gpu_kernel(iter, [] GPU_LAMBDA(scalar_t dy, scalar_t x) -> scalar_t {
using opmath_t = at::opmath_type<scalar_t>;
const opmath_t dy_acc = static_cast<opmath_t>(dy);
const opmath_t x_acc = static_cast<opmath_t>(x);
const opmath_t s_acc =
opmath_t(1) / (opmath_t(1) + c10::cuda::compat::exp(-x_acc));
return dy_acc * s_acc * (opmath_t(1) + x_acc * (opmath_t(1) - s_acc));
});
});
宏展开的骨架(Dispatch.h 与 torch/headeronly/core/Dispatch.h):
#define AT_DISPATCH_FLOATING_TYPES_AND2(SCALARTYPE1, SCALARTYPE2, TYPE, NAME, ...) \
AT_DISPATCH_SWITCH(TYPE, NAME, \
AT_DISPATCH_CASE_FLOATING_TYPES_AND2(SCALARTYPE1, SCALARTYPE2, __VA_ARGS__))
#define AT_DISPATCH_CASE_FLOATING_TYPES_AND2(SCALARTYPE1, SCALARTYPE2, ...) \
AT_DISPATCH_CASE(at::ScalarType::Double, __VA_ARGS__) \
AT_DISPATCH_CASE(at::ScalarType::Float, __VA_ARGS__) \
AT_DISPATCH_CASE(SCALARTYPE1, __VA_ARGS__) \
AT_DISPATCH_CASE(SCALARTYPE2, __VA_ARGS__)
// 每个 case 展开为(THO_PRIVATE_CASE_TYPE_USING_HINT_TMPL):
// case enum_type: {
// using scalar_t = ScalarTypeToCPPTypeT<enum_type>;
// return __VA_ARGS__();
// }
// AT_DISPATCH_SWITCH 展开为:
// [&] { switch (::detail::scalar_type(TYPE)) { <cases> default: TORCH_CHECK_NOT_IMPLEMENTED(...) } }()
所以这段代码最终是一个 switch (dtype),每个 case 里 using scalar_t = double / float / c10::Half / c10::BFloat16;,然后调用传入的 lambda。lambda 体在每个 case 里被实例化一次,gpu_kernel 里的 GPU_LAMBDA 随之实例化出 4 份不同 scalar_t 的 vectorized_elementwise_kernel——运行期的一个枚举值,选中编译期已经生成好的一份模板实例。代价是二进制体积:每多支持一种 dtype、每多一种 vec_size,就多一份 kernel。这也是为什么 launch_vectorized_kernel 要费心限制 vec8 只为 sm_90/sm_100 生成。
opmath_type<scalar_t> 对 Half/BFloat16 给出 float,对 float/double 给出自身——这正是”低精度存储、float 计算”约定的框架级实现。
七、融合:90% 之后
1. 三个 kernel 与一个 kernel
回到核心问题。假设 add 已经跑到 90% 带宽,要做的运算是一个典型的 MLP 尾部:
分成三个 elementwise 算子执行:
kernel 1: t1 = x + b 读 x, b 写 t1
kernel 2: t2 = silu(t1) 读 t1 写 t2
kernel 3: out = t2 * y 读 t2, y 写 out
每个元素共 5 次读、3 次写,BF16 下 \(8 \times 2 = 16\) 字节。三个 kernel 各自都可以做到 90% 带宽,但总字节数是 16 B/元素。
融合成一个 kernel:读 x、b、y,算完写 out。3 次读、1 次写,\(4 \times 2 = 8\) 字节。总时间减半,而三个中间量 t1、t2 根本不需要存在。
%% 图:三个 kernel 与一个融合 kernel:中间量 t1、t2 从 HBM 挪到寄存器,每元素字节数从 16 B 降到 8 B
flowchart TB
classDef hbm fill:#fee2e2,stroke:#b91c1c
classDef k fill:#dbeafe,stroke:#1d4ed8
classDef reg fill:#dcfce7,stroke:#15803d
subgraph A["三个独立 kernel:每个元素 5 读 3 写 = 16 B"]
direction LR
x1["x, b"]:::hbm --> k1["add"]:::k --> t1["t1(HBM)"]:::hbm --> k2["silu"]:::k --> t2["t2(HBM)"]:::hbm --> k3["mul"]:::k --> o1["out"]:::hbm
y1["y"]:::hbm --> k3
end
subgraph B["融合 kernel:每个元素 3 读 1 写 = 8 B"]
direction LR
x2["x, b, y"]:::hbm --> kf["add → silu → mul<br/>t1、t2 只在寄存器里"]:::reg --> o2["out"]:::hbm
end
A ~~~ B
读 写 字节/元素(BF16) 相对时间
三个独立 kernel 5 3 16 1.0
融合 kernel 3 1 8 0.5
如果中间 tensor 小于 L2(A100 40 MB),t1 的写和随后的读可能命中 L2,不走 HBM;但 LLM 激活值随便就是几十到几百 MB,不能指望。此外每个 kernel 还有 launch 开销(几微秒)和启动/收尾阶段带宽利用不足的时间,小 tensor 时这部分比例更高。
2. 这就是 Inductor 融合的收益来源
torch.compile 的 Inductor 后端对 pointwise 算子做的最主要优化,就是把这一串融合成一个 Triton kernel。它的收益不来自任何单个算子”更快”——add 的 Triton 版本和 ATen 版本都是 90% 带宽——而来自字节数减少。用 Roofline 的语言:memory-bound 区域里,kernel 已经贴在带宽斜线上,往上走的唯一办法是把点向右移(提高算术强度),融合就是把多个 1 FLOP/6 B 的点合并成一个几 FLOP/6 B 的点(relu、乘、加各算 1 FLOP 只是记账口径,SiLU 这类含 exp 的函数走 SFU、不能按 1 FLOP 塞进同一个屋顶)。
从这个角度重新审视 elementwise 优化的边界:
- 单个 elementwise kernel 的上限是带宽,可优化空间就是从 60% 到 90% 这段,手写与框架实现差距通常不超过 1.5 倍;
- 一旦到了 90%,唯一有意义的优化就是让它消失:融合进邻居(Inductor、手写 fused kernel),或融合进 GEMM 的 epilogue(CUTLASS 的 epilogue fusion,第六篇)、attention 的输出阶段(第八篇);
- “少做”还包括:不物化 mask 而在 kernel 里按坐标判断、不物化 broadcast、用 in-place 减少一次分配、把 dtype 转换合并进相邻算子。
关于 in-place 需要澄清一点:x.add_(b) 与 y = x + b 的字节数是一样的(都是 2 读 1 写),in-place 省的是显存分配与 caching allocator 的开销,不是带宽。真正减少字节的是”不要把中间结果写出去再读回来”。类似地,x.to(torch.float32) 后再做一次 FP32 的 elementwise,比直接在 kernel 里”读 BF16、float 计算、写 BF16”多了一读一写 FP32(8 B/元素),是常见的隐性浪费——本文所有 kernel 内部都用 float 计算,正是为了不需要这一步。
融合并非没有代价。融合后的 kernel 寄存器更多、模板实例更多,Inductor 需要为每一种算子组合生成并编译一个新 kernel;对手写 kernel 而言,每个融合模式都是一份要维护、要测试的代码。所以融合的优先级应该由 profile 决定:先看时间线上哪些 elementwise kernel 相邻且合计占比高,再决定融合哪一段。第十篇会回到这个方法论。
这条边界也是本系列后面篇章的组织逻辑:reduction、GEMM、attention 之所以值得单独写 kernel,是因为它们的上限不再是”读一遍写一遍”,而有更多结构可以利用。
八、实践:把 BF16 add 推到 90%
1. 理论下界
统一测试规模 \(n = 2^{28}\)(每个 tensor 512 MiB):
\[\text{字节数} = 3 \times 2^{28} \times 2\ \text{B} = 1.5\ \text{GiB} \approx 1.61\ \text{GB},\qquad t_{\min} = \frac{1.61\ \text{GB}}{2.0\ \text{TB/s}} \approx 0.81\ \text{ms}\]任何一个版本的实测时间除以这个数,就是”达到了理论带宽的百分之几”。选这么大的规模是为了让 L2(40 MB)的影响可以忽略,同时让 launch 开销(微秒级)小于 1%。
2. 版本一:naive,每线程一个元素
#include <cuda_bf16.h>
#include <cstdint>
__global__ void add_bf16_naive(const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ b,
__nv_bfloat16* __restrict__ y,
int64_t n) {
int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
float xv = __bfloat162float(x[i]);
float bv = __bfloat162float(b[i]);
y[i] = __float2bfloat16(xv + bv);
}
}
// launch: block = 256, grid = (n + 255) / 256
访存是合并的(相邻线程相邻地址,2 B × 32 = 64 B/warp,2 个 sector 全部有效),但每条加载只带回 64 B,每个线程 3 条访存指令只搬 6 字节。__restrict__ 告诉编译器三个指针不重叠,允许它把加载提前、并对只读数据用 ld.global.nc(只读缓存路径)。
3. 版本二:向量化,每线程 8 个元素
__device__ __forceinline__ int4 add_bf16x8(int4 xa, int4 ba) {
const __nv_bfloat162* x2 = reinterpret_cast<const __nv_bfloat162*>(&xa);
const __nv_bfloat162* b2 = reinterpret_cast<const __nv_bfloat162*>(&ba);
int4 ya;
__nv_bfloat162* y2 = reinterpret_cast<__nv_bfloat162*>(&ya);
#pragma unroll
for (int k = 0; k < 4; ++k) {
float2 xf = __bfloat1622float2(x2[k]);
float2 bf = __bfloat1622float2(b2[k]);
y2[k] = __floats2bfloat162_rn(xf.x + bf.x, xf.y + bf.y);
}
return ya;
}
// 要求 x、b、y 均 16 字节对齐(host 侧检查)
__global__ void add_bf16_vec8(const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ b,
__nv_bfloat16* __restrict__ y,
int64_t n) {
const int64_t n_vec = n / 8; // 完整的 16 字节块数
const int4* x4 = reinterpret_cast<const int4*>(x);
const int4* b4 = reinterpret_cast<const int4*>(b);
int4* y4 = reinterpret_cast<int4*>(y);
int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x;
if (i < n_vec) {
y4[i] = add_bf16x8(x4[i], b4[i]);
}
// 尾部 n % 8 个元素:由 block 0 的前 (n % 8) 个线程标量处理
const int tail = (int)(n & 7);
if (blockIdx.x == 0 && (int)threadIdx.x < tail) {
int64_t j = n_vec * 8 + threadIdx.x;
y[j] = __float2bfloat16(__bfloat162float(x[j]) + __bfloat162float(b[j]));
}
}
// launch: block = 256, grid = max(1, (n_vec + 255) / 256)
检查几个容易出错的地方:n_vec * 8 + tail == n,尾部索引 j 严格小于 n;tail < 8 ≤ 256,block 0 一定有足够的线程;n < 8 时 n_vec = 0,主体一个线程都不进,只有尾部起作用,grid 用 max(1, …) 保证至少有 block 0。三个数组各一次 int4 访问,一个 warp 一条指令 512 B、4 条完整 cache line。
4. 版本三:向量化 + grid-stride
__global__ void __launch_bounds__(256)
add_bf16_vec8_gs(const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ b,
__nv_bfloat16* __restrict__ y,
int64_t n) {
const int64_t n_vec = n / 8;
const int4* x4 = reinterpret_cast<const int4*>(x);
const int4* b4 = reinterpret_cast<const int4*>(b);
int4* y4 = reinterpret_cast<int4*>(y);
const int64_t stride = (int64_t)gridDim.x * blockDim.x;
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x;
i < n_vec; i += stride) {
y4[i] = add_bf16x8(x4[i], b4[i]);
}
const int tail = (int)(n & 7);
if (blockIdx.x == 0 && (int)threadIdx.x < tail) {
int64_t j = n_vec * 8 + threadIdx.x;
y[j] = __float2bfloat16(__bfloat162float(x[j]) + __bfloat162float(b[j]));
}
}
// launch: block = 256, grid = num_SMs * blocks_per_SM * 2(下面的 host 代码计算)
与版本二的差别只有循环。想再多要一点 ILP,可以把循环体展开两次:先发出两组 int4 加载再做两组计算与存储,让每个线程同时有 4 条加载在飞(2 个输入 × 2 组);编译器在 #pragma unroll 2 下通常会自动做这个调度。
5. 通用版本:2D stride 与 broadcast
输出 y 为连续的 [size0, size1],输入 x、b 可以是任意 stride(含 0,即 broadcast):
__global__ void __launch_bounds__(256)
add_bf16_strided2d(const __nv_bfloat16* __restrict__ x,
const __nv_bfloat16* __restrict__ b,
__nv_bfloat16* __restrict__ y,
int64_t size0, int64_t size1,
int64_t xs0, int64_t xs1, // x 的 stride(元素单位)
int64_t bs0, int64_t bs1) { // b 的 stride,broadcast 维为 0
const int64_t n = size0 * size1;
const int64_t stride = (int64_t)gridDim.x * blockDim.x;
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x;
i < n; i += stride) {
const int64_t i0 = i / size1;
const int64_t i1 = i - i0 * size1;
const float xv = __bfloat162float(x[i0 * xs0 + i1 * xs1]);
const float bv = __bfloat162float(b[i0 * bs0 + i1 * bs1]);
y[i] = __float2bfloat16(xv + bv);
}
}
这个版本没有向量化:一般 stride 下无法保证 8 个相邻元素在内存里连续。它的性能取决于 xs1、bs1:等于 1 时访存合并,能接近版本一;x 是转置 view(xs1 = size0)时效率掉到 6.25%,慢一个数量级——这时正确的做法是先 contiguous()。i / size1 是 64 位除法,每次约几十条指令,在 memory-bound 下通常被访存延迟掩盖;追求极致时改用 ATen 的 IntDivider 思路预计算魔数。ATen 的 TensorIterator 还会把 size1 连续的情况直接合并为一维、走向量化路径,我们这里为了展示 stride 机制没有做。
6. host 侧 launch 与 load_inline 测试
把四个 kernel 放进一个 CUDA 源码字符串,用 torch.utils.cpp_extension.load_inline 编译成 Python 可调用的函数:
import torch
from torch.utils.cpp_extension import load_inline
cuda_src = r"""
#include <torch/types.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <cuda_bf16.h>
#include <cstdint>
#include <algorithm>
// ---- 此处粘贴第 2–5 节的四个 kernel 与 add_bf16x8 ----
static inline bool aligned16(const void* p) {
return (reinterpret_cast<uintptr_t>(p) % 16) == 0;
}
// 四个 wrapper 共用的入口检查:都在 CUDA 上、同一张卡,并把当前 device 切过去——
// 少了这一步,CPU tensor 的指针会被当成 device 指针解引用,多卡时会在错误的卡上 launch
#define CHECK_SAME_CUDA(x, b) \
TORCH_CHECK((x).is_cuda() && (b).is_cuda(), "inputs must be CUDA tensors"); \
TORCH_CHECK((x).device() == (b).device(), "inputs must be on the same device"); \
const c10::cuda::CUDAGuard guard((x).device())
static inline const __nv_bfloat16* bf(const at::Tensor& t) {
return reinterpret_cast<const __nv_bfloat16*>(t.data_ptr<at::BFloat16>());
}
static inline __nv_bfloat16* bf_mut(at::Tensor& t) {
return reinterpret_cast<__nv_bfloat16*>(t.data_ptr<at::BFloat16>());
}
at::Tensor add_naive(at::Tensor x, at::Tensor b) {
CHECK_SAME_CUDA(x, b);
TORCH_CHECK(x.is_contiguous() && b.is_contiguous() && x.sizes() == b.sizes());
TORCH_CHECK(x.scalar_type() == at::kBFloat16 && b.scalar_type() == at::kBFloat16);
auto y = at::empty_like(x);
const int64_t n = x.numel();
if (n == 0) return y;
const int block = 256;
const int64_t grid = (n + block - 1) / block;
add_bf16_naive<<<grid, block, 0, at::cuda::getCurrentCUDAStream()>>>(
bf(x), bf(b), bf_mut(y), n);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
at::Tensor add_vec8(at::Tensor x, at::Tensor b) {
CHECK_SAME_CUDA(x, b);
TORCH_CHECK(x.is_contiguous() && b.is_contiguous() && x.sizes() == b.sizes());
auto y = at::empty_like(x);
TORCH_CHECK(aligned16(x.data_ptr()) && aligned16(b.data_ptr()) && aligned16(y.data_ptr()),
"add_vec8 requires 16-byte aligned pointers");
const int64_t n = x.numel();
if (n == 0) return y;
const int block = 256;
const int64_t grid = std::max<int64_t>(1, (n / 8 + block - 1) / block);
add_bf16_vec8<<<grid, block, 0, at::cuda::getCurrentCUDAStream()>>>(
bf(x), bf(b), bf_mut(y), n);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
at::Tensor add_vec8_gs(at::Tensor x, at::Tensor b) {
CHECK_SAME_CUDA(x, b);
TORCH_CHECK(x.is_contiguous() && b.is_contiguous() && x.sizes() == b.sizes());
auto y = at::empty_like(x);
TORCH_CHECK(aligned16(x.data_ptr()) && aligned16(b.data_ptr()) && aligned16(y.data_ptr()));
const int64_t n = x.numel();
if (n == 0) return y;
const int block = 256;
int num_sms = at::cuda::getCurrentDeviceProperties()->multiProcessorCount;
int blocks_per_sm = 0;
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocks_per_sm, add_bf16_vec8_gs, block, 0);
int64_t grid = (int64_t)num_sms * std::max(blocks_per_sm, 1) * 2;
grid = std::max<int64_t>(1, std::min<int64_t>(grid, (n / 8 + block - 1) / block));
add_bf16_vec8_gs<<<grid, block, 0, at::cuda::getCurrentCUDAStream()>>>(
bf(x), bf(b), bf_mut(y), n);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
// x: [size0, size1] 任意 stride;b: 可 broadcast 到同形状(用 expand 得到 stride 0)
at::Tensor add_strided2d(at::Tensor x, at::Tensor b) {
CHECK_SAME_CUDA(x, b);
TORCH_CHECK(x.dim() == 2 && b.dim() == 2 && x.sizes() == b.sizes());
auto y = at::empty(x.sizes(), x.options()); // 连续输出
const int64_t n = x.numel();
if (n == 0) return y;
const int block = 256;
int num_sms = at::cuda::getCurrentDeviceProperties()->multiProcessorCount;
int64_t grid = std::max<int64_t>(1, std::min<int64_t>((int64_t)num_sms * 16,
(n + block - 1) / block));
add_bf16_strided2d<<<grid, block, 0, at::cuda::getCurrentCUDAStream()>>>(
bf(x), bf(b), bf_mut(y), x.size(0), x.size(1),
x.stride(0), x.stride(1), b.stride(0), b.stride(1));
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
"""
cpp_src = """
at::Tensor add_naive(at::Tensor x, at::Tensor b);
at::Tensor add_vec8(at::Tensor x, at::Tensor b);
at::Tensor add_vec8_gs(at::Tensor x, at::Tensor b);
at::Tensor add_strided2d(at::Tensor x, at::Tensor b);
"""
ext = load_inline(
name="ew_add_bf16",
cpp_sources=cpp_src,
cuda_sources=cuda_src,
functions=["add_naive", "add_vec8", "add_vec8_gs", "add_strided2d"],
extra_cuda_cflags=["-O3"],
verbose=False,
)
data_ptr<at::BFloat16>() 同时做了 dtype 检查;at::BFloat16 与 __nv_bfloat16 都是 2 字节、位模式一致,reinterpret_cast 是安全的。load_inline 默认按当前 GPU 的架构编译(可用环境变量 TORCH_CUDA_ARCH_LIST=8.0 固定)。
计时脚手架与第二篇一致:bench(fn, warmup=10, iters=100, flush_l2=True),cudaEvent 计时,两次计时之间用一个 128 MB(≥ 2 × A100 L2)的缓冲区 memset 冲掉 L2,取中位数毫秒:
_flush_buf = None
def bench(fn, warmup=10, iters=100, flush_l2=True):
global _flush_buf
if flush_l2 and _flush_buf is None:
_flush_buf = torch.empty(128 * 1024 * 1024, dtype=torch.uint8, device="cuda")
for _ in range(warmup):
fn()
times = []
for _ in range(iters):
if flush_l2:
_flush_buf.zero_()
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
fn()
end.record()
end.synchronize()
times.append(start.elapsed_time(end))
times.sort()
return times[len(times) // 2]
HBM_TBPS = 2.0 # A100 标称值;H100 改为 3.35
def report(name, ms, nbytes):
tbps = nbytes / (ms * 1e-3) / 1e12
print(f"{name:14s} {ms:8.3f} ms {tbps:5.2f} TB/s {100 * tbps / HBM_TBPS:5.1f}% of peak")
n = 1 << 28
x = torch.randn(n, device="cuda").to(torch.bfloat16)
b = torch.randn(n, device="cuda").to(torch.bfloat16)
ref = x + b
nbytes = 3 * n * 2
for name, fn in [("torch.add", lambda: x + b),
("naive", lambda: ext.add_naive(x, b)),
("vec8", lambda: ext.add_vec8(x, b)),
("vec8+gs", lambda: ext.add_vec8_gs(x, b))]:
torch.testing.assert_close(fn(), ref) # BF16 默认 rtol=1.6e-2, atol=1e-5
report(name, bench(fn), nbytes)
# 尾部与小规模的正确性
for m in [1, 7, 8, 9, 1000, 4097]:
xs, bs = x[:m].clone(), b[:m].clone()
torch.testing.assert_close(ext.add_vec8_gs(xs, bs), xs + bs)
# stride 与 broadcast
x2 = torch.randn(4096, 4096, device="cuda").to(torch.bfloat16)
bias = torch.randn(1, 4096, device="cuda").to(torch.bfloat16).expand(4096, 4096) # stride (0, 1)
torch.testing.assert_close(ext.add_strided2d(x2, bias), x2 + bias)
xt = x2.t() # stride (1, 4096)
torch.testing.assert_close(ext.add_strided2d(xt, bias), xt + bias)
report("strided (x)", bench(lambda: ext.add_strided2d(x2, bias)), 3 * x2.numel() * 2)
report("strided (x.t)", bench(lambda: ext.add_strided2d(xt, bias)), 3 * x2.numel() * 2)
结果与 x + b 逐位相同是可以期待的:两边都是”转 float、加、舍入回 BF16”,assert_close 的默认容差只是保险。
7. 读者应看到的量级,以及为什么最后 8–10% 拿不到
没有实测数字可以照抄——不同型号、频率、功耗墙下带宽都不同——但按文献与经验,A100 上 \(n = 2^{28}\) 的结果大致落在这些区间:
版本 有效带宽占标称峰值 特征
naive(2 B/线程) 约 60–80% 合并但每指令 64 B,LSU 与指令发射成瓶颈
vec8(16 B/线程) 约 85–92% 每 warp 一条指令 512 B
vec8 + grid-stride 约 85–92% 与 vec8 相当或略好 1–3 个百分点
torch.add (ATen) 约 85–92% A100 上实际为 vec4(8 B/线程)+ 每线程 8 元素
strided, xs1 = 1 接近 naive
strided, x.t() 远低于 10% 最内维 stride 4096,每元素独占一个 sector
如果 naive 版本就跑到了 85% 以上,大概率是 L2 没冲干净或 tensor 太小;如果 vec8 版本低于 80%,先检查 nvcc 有没有真的生成 128-bit 加载(cuobjdump -sass 里找 LDG.E.128),再看是否触发了对齐回退。
那最后 8–10% 去了哪里?
- 标称带宽是接口峰值,不是可达带宽。HBM 有刷新周期、bank 冲突、读写方向切换(bus turnaround)的开销,读写混合的负载通常只能做到标称的 90–93%,纯读能高一点。
add是 2 读 1 写,天生有方向切换。 - 启动与收尾。kernel 开始时 warp 逐渐被调度、请求逐渐填满管道;结束时最后一批 block 收尾,SM 逐渐空闲。这两段带宽利用不足,对 0.8 ms 的 kernel 大约占 1–2%。
- L2 与 DRAM 页局部性。写回策略、L2 分片之间的交叉带宽、DRAM 页打开/关闭,都不是 kernel 能控制的。
- 功耗与频率。带宽压满时 HBM 与 SM 的功耗都高,GPU 可能降频,标称值对应的是理想条件。
这些因素合起来就是那道 90% 的墙。在它面前,继续调 block 大小、展开因子、grid 倍数,收益都在噪声范围内。此时应该做的事在第七章已经说过:融合,或者少做。
九、本文小结
这一篇围绕一个理论下界(BF16 add:6 B/元素,\(n = 2^{28}\) 时 0.81 ms)讨论了 elementwise kernel 的全部工程要点:
- 合并:warp 的 32 个地址落在多少个 32 B sector 里决定效率;连续对齐 100%,跨步 2 只剩 50%,跨步 ≥ 32 B 只剩 12.5%,未对齐多付一个 sector;AoS 是隐藏的跨步。
- 向量化:16 字节/线程的
int4/float4加载让每条指令搬 512 B,指令数减到 1/8(BF16),要求 16 字节对齐,尾部用标量补。 - 在飞请求:Little’s law 给出 A100 需要 ~1.2 MB 在飞、每 SM ~11 KB、128-bit 加载下至少 ~22 个 warp;memory-bound 同样需要占用率与 ILP。
- grid-stride:解耦 grid 与 \(n\),摊薄固定开销,提供 ILP;ATen 没有用它,也能到 90%。
- stride 与 broadcast:线性 index 逐维 divmod 乘 stride;broadcast 即 stride 0;最内维 stride 不为 1 时应先
contiguous()。 - ATen 实现:
gpu_kernel→ 连续则launch_vectorized_kernel(can_vectorize_up_to查对齐,16 / sizeof定宽度,A100 上 BF16 压到 vec4)→vectorized_elementwise_kernel(128 线程、每线程 8 元素、只有最后 block 做边界检查);非连续走elementwise_kernel+OffsetCalculator;AT_DISPATCH_*把运行期 dtype 展开成switch,每个case里using scalar_t = ...实例化一份模板。 - 融合:三个 elementwise 分开 16 B/元素,融合后 8 B/元素,这是 Inductor 融合的全部收益来源;90% 带宽之后,唯一的优化是让 kernel 消失。
数字汇总:
访存模式(32 线程 × 4 B) sector 效率
连续、128 B 对齐 4 100%
连续、起点偏移 4 B 5 80%
跨步 2 8 50%
跨步 4 16 25%
跨步 ≥ 8(≥ 32 B) 32 12.5%
AoS 12 B 结构体只读一个字段 12 33%
Little's law(A100)
在飞字节 = 2.0 TB/s × 600 ns ≈ 1.2 MB;每 SM ≈ 11 KB
128-bit 加载:512 B/warp → ≥ 22 warp/SM;32-bit 加载:128 B/warp → ≈ 88 warp/SM(超上限 64)
三版 BF16 add kernel(n = 2^28,理论下界 0.81 ms @ 2.0 TB/s)
版本 每线程元素 每 warp 每指令字节 grid 常见带宽占比
naive 1 64 B n/256 60–80%
vec8 8 512 B n/8/256 85–92%
vec8 + gs 8 × k 512 B SM × blk × 2 85–92%
融合(BF16,silu(x + b) * y)
三个 kernel:5 读 3 写 = 16 B/元素;融合:3 读 1 写 = 8 B/元素 → 时间减半
ATen(v2.10.0)elementwise 配置
num_threads = 128;vectorized 每线程 8 元素(io_size = 1 时 16);vec_size = min(16/sizeof, 对齐),非 sm_90/100 上 ≤ 4
unrolled 每线程 4 元素;legacy elementwise_kernel<128, 2 或 4> + OffsetCalculator(MAX_DIMS = 25)
下一篇进入需要线程之间协作的 kernel。softmax、LayerNorm、RMSNorm 都要对一行做归约,而归约的结果要被同一行的所有元素使用——这需要 shared memory、warp shuffle 与 __syncthreads(),也需要 online softmax 把三遍读变成一遍。它们的理论下界仍然是”读一遍写一遍”,但实现的自由度和陷阱都比 elementwise 多得多。
十、自测
-
warp 内 32 个线程各读一个
float,地址连续且 128 字节对齐——需要几个 32 B sector?改成 stride 为 2 个 float 呢?起始地址偏移 4 字节呢?答案
4 个 sector,100% 效率;stride 2 要 8 个 sector,只用了一半数据,50%;未对齐 128 B 跨 5 个 sector,多付 25%。
-
AoS
struct {float x, y, z;} p[n]只读所有的x,合并效率是多少?怎么修?答案
stride 12 字节,每 32 B sector 只用 4 字节,效率约 1/3(12.5%–33%);改成 SoA(三个独立数组)或一次把整个 struct 读进寄存器再选用。
-
float4加载要求什么对齐?一个n = 1000003的数组怎么处理尾部?答案
16 字节对齐(指针地址是 16 的倍数,
cudaMalloc保证首地址,但x + 1这样的偏移不保证);主循环按 4 个一组处理 1000000 个,最后 3 个用标量补,或让最后一个 block 走带边界检查的标量路径(ATen 的做法)。 -
Little’s law 怎么给出“A100 需要约 1.2 MB 在飞”?这对 memory-bound kernel 的占用率意味着什么?
答案
在飞字节 = 带宽 × 延迟 ≈ 2 TB/s × 600 ns ≈ 1.2 MB;除以 108 个 SM 每 SM 约 11 KB,每线程 16 B 加载时需要约 700 个线程 ≈ 22 个 warp 同时有请求在飞。memory-bound 同样需要足够的占用率或 ILP,否则带宽压不满。
-
y = relu(x) * 2 + b三个算子分开与融合后,每个元素各读写多少字节(BF16)?带宽利用率相同时快多少?答案
分开:每个算子读 2 写 2,三个共 12 B(
+ b多读 2 B,共 14 B);融合:读 x 2 B、读 b 2 B、写 2 B = 6 B。字节少一半以上;带宽利用率相同时时间也少一半以上——实际还要看 launch 开销、cold/warm cache 与指令数,融合的收益不只是字节。这是 90% 之后最主要的优化。
下一篇
共享内存与 reduction:softmax、LayerNorm 与 online softmax
-
先看那 10% 能不能拿:DRAM 可达带宽约为标称的 85–92%,90% 已经贴着物理上限,kernel 内部几乎没有余地(第二章)。真正的优化在两个方向。一是确认没有隐藏的浪费:合并访存是否 100%(warp 的 32 个地址落在最少的 32 B sector 里;AoS 布局、非 1 的最内维 stride、未对齐都会多付 sector);是否向量化到 16 字节 / 线程;在飞请求是否够——Little’s law 要求 A100 约 1.2 MB 在飞(第二至五章)。ATen 的
gpu_kernel→launch_vectorized_kernel已经把这些做了,所以它能到 90%(第六章)。二是让 kernel 消失:三个 elementwise 分开执行是每元素 16 B,融合成一个是 8 B——90% 带宽之后唯一的办法是减少总字节数,把相邻的 elementwise 合进一个 kernel、或合进前后 GEMM / reduction 的 epilogue,这是 Inductor 融合全部收益的来源(第七章)。 ↩
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/memory-coalescing-and-elementwise-kernels.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。