CUDA 算子工程:手写 FlashAttention v2 之路
第 8 章 Element-wise 与算子融合
一个没融合的 element-wise kernel,把带宽浪费了两次:写出去一次,再读回来一次。 而在 LLM 推理里,带宽往往是最先用完的资源。
8.1 LLM 中的 element-wise 算子盘点
打开任何一个现代 LLM(LLaMA、Qwen、Mistral)的 forward 代码,统计一下出现的 element-wise 算子:
# LLaMA-style decoder block (简化版)
def decoder_block(x, ...):
# Attention 子层
h = rms_norm(x) # element-wise (per row)
h = qkv_proj(h) # GEMM (大算子)
q, k = rope(q, k) # element-wise (旋转)
h = attention(q, k, v) # 大算子
h = o_proj(h) # GEMM
x = x + h # element-wise (残差加)
# FFN 子层
h = rms_norm(x) # element-wise
g = gate_proj(h) # GEMM
u = up_proj(h) # GEMM
h = silu(g) * u # element-wise (激活 + 乘)
h = down_proj(h) # GEMM
x = x + h # element-wise
return x
仔细数一下,一个 decoder block 里的 element-wise 操作有:
- 2 次 RMSNorm(attention 前 + FFN 前)
- 1 次 RoPE 旋转(per-head 的 element-wise 复数乘法)
- 1 次 SiLU + 1 次 element-wise 乘(FFN 中的 SwiGLU)
- 2 次残差加(attention 后 + FFN 后)
每个算子都需要遍历整个 hidden state(B × H 大小)。如果每个算子都是独立 kernel,整个 block 仅 element-wise 部分就要 6 次完整的 HBM 往返——每次按 FP32 粗算为 B × H × 4 字节读 + B × H × 4 字节写。
对于 7B LLaMA, B=1(单 token decoding), H=4096:
- 一次完整 element-wise pass = 32 KB(不大)
- 但要乘以 32 层 × 6 次 = 192 次完整 pass = 6 MB HBM 流量
- 按每次 launch 约 5μs 估算(量级估计,实际随 GPU、驱动和 CUDA Graph 与否而变):5μs × 6 × 32 ≈ 1ms 仅仅是 launch 开销
而单 token decoding 的总延迟也就是毫秒量级——光把 7B 模型的 FP16 权重(约 13.5 GB)从 HBM 读一遍,按 H100 SXM5 的 3.35 TB/s 算就要约 4ms。按这个估算,仅仅 element-wise kernel 的 launch 开销就能占到一成以上——这是不可接受的。
所以算子融合是 LLM 推理中除了 attention、GEMM 优化之外的又一大主题。
8.2 算子融合的三个层次
按融合的"激进程度",可以分成三个层次:
flowchart TB
subgraph L1 [Level 1 · 同类 element-wise 串接]
L1A[add + scale + add] --> L1B[一个 kernel: y = scale*a + b + c]
end
subgraph L2 [Level 2 · element-wise + reduce 融合]
L2A[add + rms_norm] --> L2B[一个 kernel: residual add 的结果不落地为中间张量,<br/>直接接 RMS 计算]
end
subgraph L3 [Level 3 · 跨异构算子融合]
L3A[GEMM + bias + GeLU] --> L3B["GEMM epilogue 直接出 GeLU(out + bias)"]
end
每一个层次的难度和收益都更大。
8.3 Level 1:纯 element-wise 串接
最简单的融合:把多个 element-wise 操作写在一个 kernel 里,输入数据从 HBM 读一次,所有算完之后写一次。
// 反例: 三个独立 kernel
add_kernel(a, b, tmp1); // tmp1 = a + b
scale_kernel(tmp1, alpha, tmp2); // tmp2 = alpha * tmp1
add_kernel(tmp2, c, out); // out = tmp2 + c
// 正例: 一个 fused kernel
__global__ void fused(const float* a, const float* b, const float* c,
float alpha, float* out, int N) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < N) {
out[tid] = alpha * (a[tid] + b[tid]) + c[tid];
}
}
这种 fusion 的收益分析:
- HBM 流量:3 个独立 kernel 是 5 次读(a、b、tmp1、tmp2、c)+ 3 次写(tmp1、tmp2、out)= 8N×4B;融合后是 3 次读 + 1 次写 = 4N×4B。正好减半。
- launch 开销:3 次 → 1 次,按 8.1 节每次约 5μs 的估算,省约 10μs。
- 代码改动:~10 行。
PyTorch 2.0 引入的 torch.compile 最基本的就是这个层次的 fusion——通过 TorchInductor 把多个 element-wise op 编译成一个 Triton kernel;它也能把逐元素算子接在归约后面一起融合(例如 LayerNorm 生成一个 Welford 归约加归一化 epilogue 的 kernel),那就已经是下一节的 Level 2 了。
8.4 Level 2:Element-wise + Reduce 融合
更有价值的融合是 element-wise 算子和 reduce 算子(LayerNorm/RMSNorm/Softmax)的组合。比如 vLLM 中的 fused_add_rms_norm:
# 反例: 两个独立 kernel
residual = residual + hidden # 残差加 (element-wise)
hidden = rms_norm(residual) # RMSNorm (有 reduce)
# 正例: 一个 kernel
hidden, residual = fused_add_rms_norm(residual, hidden)
# 内部: 把 residual+hidden 累加到 SMEM, 直接在 SMEM 上做 RMS 计算
完整 kernel:
template <int BLOCK_SIZE = 512>
__global__ void fused_add_rms_norm(
float* __restrict__ residual, // [B, H], in/out
float* __restrict__ hidden, // [B, H], in/out
const float* __restrict__ gamma, // [H]
int H,
float eps
) {
int row = blockIdx.x;
int tid = threadIdx.x;
float* res_row = residual + row * H;
float* hid_row = hidden + row * H;
// Phase 1: 残差加 + 累积平方和 (一遍同时做两件事)
extern __shared__ float smem[];
float ss = 0.0f;
for (int i = tid; i < H; i += BLOCK_SIZE) {
float r = res_row[i];
float h = hid_row[i];
float v = r + h; // 残差加
smem[i] = v; // 暂存到 SMEM (避免再读 HBM)
res_row[i] = v; // 同时把更新的 residual 写回 (下一层用)
ss += v * v; // 累积平方和
}
// Block reduce ss(block_reduce_sum 即第 5 章 warp_reduce + 每 warp 部分和写 SMEM 再归约的写法, 结果至少在 tid 0 上有效)
ss = block_reduce_sum(ss);
__shared__ float rms;
if (tid == 0) rms = rsqrtf(ss / H + eps);
__syncthreads();
// Phase 2: 归一化 + scale (从 SMEM 读, 不再读 HBM)
for (int i = tid; i < H; i += BLOCK_SIZE) {
hid_row[i] = smem[i] * rms * gamma[i];
}
}
关键点:
- HBM 往返少一趟:原来 add 要读 2 次写 1 次、rms_norm 要读 1 次写 1 次,共 5 份流量;融合后读 2 份(res+hid)写 2 份(res+hid_norm),共 4 份——省下的是 20% 的流量,外加一整次 kernel launch 和一个中间张量。
- SMEM 起到中间缓冲作用:残差加的结果暂存到 SMEM,rms_norm 不需要再从 HBM 读。代价是这个 block 要吃
H * 4字节的动态 SMEM——H=8192 时是 32 KB,超过 48 KB(H > 12288)就得走cudaFuncSetAttributeopt-in,甚至根本放不下(见下面 vLLM 的做法)。 - Pre-LN 残差也写回:下一层(attention 或 FFN)的输入需要更新后的 residual,所以要写回 HBM 一份。
vLLM 的 fused_add_rms_norm_kernel(vllm-0.8.5/csrc/layernorm_kernels.cu:52)就是这个思路,但有一处值得注意的差别:它并不把中间结果 stage 在 SMEM 里。它在第一遍就把 input + residual 直接写回 residual(HBM),第二遍再从 residual 读回来做归一化。源码没有写明这样取舍的理由,合理的解释是:一行 hidden state 不大(FP16 下 H=8192 也才 16 KB),第二遍读时大概率还在 L2 里,重读的代价主要落在 L2 而不是 HBM;不占动态 SMEM,也就不受 SMEM 容量对 H 的限制。它的两个工程细节值得抄:
// vllm-0.8.5/csrc/layernorm_kernels.cu:67 起,fused_add_rms_norm_kernel 的 width > 0 特化(节选:省略 weight_v 声明与第二遍循环)
auto* __restrict__ input_v = reinterpret_cast<_f16Vec<scalar_t, width>*>(input);
auto* __restrict__ residual_v = reinterpret_cast<_f16Vec<scalar_t, width>*>(residual);
for (int idx = threadIdx.x; idx < vec_hidden_size; idx += blockDim.x) {
int id = blockIdx.x * vec_hidden_size + idx;
_f16Vec<scalar_t, width> temp = input_v[id];
temp += residual_v[id]; // 残差加
variance += temp.sum_squares(); // 同一遍里累积平方和
residual_v[id] = temp; // 更新后的 residual 写回, 下一层要用
}
using BlockReduce = cub::BlockReduce<float, 1024>;
__shared__ typename BlockReduce::TempStorage reduceStore;
variance = BlockReduce(reduceStore).Reduce(variance, cub::Sum{}, blockDim.x);
另外注意:摘录里的 cub::Sum{} 忠于 vLLM v0.8.5 源码,但 CUDA 13 附带的 CCCL 已移除 cub::Sum,用新工具链自行编译这段时要换成 cuda::std::plus<>{}。
一是用 _f16Vec<scalar_t, width> 做向量化读写,二是 block 内归约直接用 cub::BlockReduce(:82)而不是手写——省事且不会写错。width 的挑选在 host 侧(:193-198):input / residual / weight 三个指针都 16 字节对齐、且 hidden_size % 8 == 0 才走 width = 8(8 个 FP16/BF16 正好 16 字节),否则退回 width = 0 的通用标量特化(:104)——只要有一个条件不满足,向量化就整个关掉,没有"退而求其次取 width=4"这种中间档。
8.4.1 多个 fusion 模式
LLM 中常见的融合模式(前四行是 element-wise + reduce,后两行已属下一节的 GEMM epilogue 融合):
| 融合 | 出现位置 | 省下什么 |
|---|---|---|
| add + RMSNorm | 残差 → RMS | 一个中间张量的读写 + 一次 launch |
| add + LayerNorm | 同上(旧模型) | 同上 |
| Softmax + Mask | Attention 内(QKᵀ 之后) | 整个 mask 中间矩阵不落地 |
| Softmax + Dropout | Training 时 | dropout mask 与 softmax 输出各一次往返 |
| Linear + bias + GeLU | FFN 中 | GEMM 输出不落 HBM,直接在寄存器里过 epilogue |
| GEMM + LoRA | LoRA 推理 | LoRA 分支的中间结果不落地 |
8.5 Level 3:跨异构算子融合(GEMM Epilogue)
最深度的融合是把 element-wise 算子直接嵌入到 GEMM 的 epilogue(输出阶段)。这需要硬件友好的实现,CUTLASS 提供了完善的支持。
GEMM 的标准 epilogue 是 D = alpha * (A @ B) + beta * C。CUTLASS Epilogue 让你可以自定义这个最终阶段:
// 伪代码:GEMM + bias + ReLU
template <typename ElementOutput>
struct EpilogueOpBiasReLU {
ElementOutput bias;
__device__ ElementOutput operator()(ElementOutput accumulator) {
ElementOutput biased = accumulator + bias;
return biased > 0 ? biased : ElementOutput(0);
}
};
这个 epilogue 在 GEMM kernel 的最后阶段执行——accumulator 还在寄存器里时,就直接加 bias、过 ReLU、写出去。完全没有中间 HBM 写。
效果:
对比写法 | HBM 流量 | 性能
─────────────────────────────────────────────────────────
GEMM + 单独的 add+ReLU kernel | 3 次 D 矩阵 HBM | 基线
GEMM(epilogue=add+ReLU) | 1 次 D 矩阵 HBM | 少 2/3 的 D 矩阵流量
CUTLASS 的 epilogue API 在 3.x 版本里被设计成了 CollectiveEpilogue——常见组合(如 bias 加激活的 LinCombPerRowBiasEltAct)在 cutlass::epilogue::fusion 里有现成的,不够用还可以用 EVT(Epilogue Visitor Tree)把多个 element-wise 操作拼成一棵算子树。第 13 章会详细讲 CUTLASS 设计哲学。
8.5.1 推理引擎里的常见 epilogue
工业级推理引擎里高频出现的 epilogue:
- Linear + bias + activation:FFN 的标配。
- Linear + LoRA add:LoRA 推理。
- Linear + scale + add residual:有的推理实现把 residual add 直接 fuse 到 GEMM。
- Linear + 反量化 scale:W8A8 推理时 INT8/FP8 GEMM 的累加结果要乘回 per-token / per-channel scale(可再加 bias),vLLM 把这一步放进 CUTLASS epilogue(
vllm-0.8.5/csrc/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp里的ScaledEpilogue一族,用 EVT 拼成)。
这些 epilogue 一般用 CUTLASS 写。手写 CUDA C++ 也行,但代码量大。
8.6 RoPE:一个特殊的 element-wise 算子
RoPE(Rotary Position Embedding)值得单独说一下。它是现代 LLM(LLaMA、Qwen 等)的位置编码,本质是对 Q 和 K 做按位置依赖的旋转:
其中 依赖于位置 和维度 。
RoPE 的算术强度不到 1(每对元素 4 乘 2 加共 6 次浮点运算,FP16 下读 4 字节、写 4 字节,还没算 cos/sin 表),是带宽 bound。但它有几个特点让它特别适合融合:
- per-head 的局部性:旋转操作只在每 (q_head, dim_pair) 内进行。
- cos/sin 表可以预计算(用 max_seq_len × head_dim 大的 lookup table)。
- 可以和 QKV projection 的 epilogue 融合:
qkv_proj输出后直接 RoPE。
vLLM v0.8.5 中 RoPE 的 CUDA 实现是单独的 kernel(vllm-0.8.5/csrc/pos_encoding_kernels.cu:71 的 rotary_embedding_kernel,原地改写 Q/K);把它 fuse 到 QKV projection 的 epilogue 里,省下的就是 Q/K 各一次完整的 HBM 往返。
8.7 TMA 在 Element-wise Kernel 中的应用
到这里读者可能会问:第 4 章那么强调 TMA,element-wise kernel 用得上吗?
用得上,但收益不像 GEMM 那么大。
Element-wise kernel 的主要瓶颈是 HBM 带宽,不是指令带宽——TMA 对带宽本身没有提升(HBM 物理带宽是固定的)。但 TMA 的几个特性还是有帮助:
- 省去地址计算:32 线程的 vectorized load 需要每线程算地址;TMA 一条指令搞定,省下指令带宽给真正的算术用。
- Async 提供更多 ILP:TMA 是异步的,可以在拷贝时同时算下一组。但 element-wise 算术开销极低,收益不明显。
- 二维数据布局更优雅:处理 [B, H] 矩阵时,二维 TMA 比手写 stride 计算更干净。
归根到底,element-wise kernel 的天花板是 HBM 带宽,而 TMA 改善的是指令带宽和地址计算——它在 GEMM/FA 这种"算多、tile 大、要和 Tensor Core 抢发射端口"的场景里是必需品,在 element-wise 上则只是锦上添花。所以工业级实现里:
- GEMM、FA:Hopper 上的高性能实现(CUTLASS 3.x 的 sm90 kernel、FA3)都用 TMA。
- 大型 fused 算子(如 fused_add_rms_norm):可以上 TMA,但注意主流实现并没有——上面那段 vLLM 的代码就是普通的向量化 load/store,因为它的瓶颈早就是 HBM 带宽而不是发射端口。
- 简单 element-wise(比如
y = a * x + b):用普通的 vectorized load,TMA 收益不值得复杂度。
8.8 一个完整案例:SwiGLU FFN 融合
LLaMA / Mistral 的 FFN 是 SwiGLU:
朴素实现:
g = gate_proj(x) # GEMM: [B, H] -> [B, F]
u = up_proj(x) # GEMM: [B, H] -> [B, F]
m = silu(g) * u # element-wise: [B, F]
y = down_proj(m) # GEMM: [B, F] -> [B, H]
注意 silu(g) * u 这一步:
// 朴素 element-wise
__global__ void silu_mul(const float* g, const float* u, float* m, int N) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < N) {
float gv = g[tid];
float silu_g = gv * (1.0f / (1.0f + expf(-gv))); // SiLU
m[tid] = silu_g * u[tid];
}
}
可以做的优化:
A. Fuse SiLU 到 gate_proj 的 epilogue:
g_silu = gate_proj_with_silu_epilogue(x) # 一个 kernel
u = up_proj(x)
m = g_silu * u
y = down_proj(m)
B. 进一步 fuse 上面三步:
m = gemm_silu_mul(x, gate_proj.weight, up_proj.weight)
# 一个 kernel 内: gate_proj 与 up_proj 两个 GEMM -> SiLU -> 相乘
y = down_proj(m)
C. 极致情况:把 down_proj 的 prologue 也融合:
y = full_swiglu_fused(x, gate_w, up_w, down_w)
# 一个 kernel: gate -> silu -> mul -> down -> output
C 这种程度的融合在通用框架里很少见,代价是代码复杂度和编译时间。
做到 B 这种程度的融合,相对 A 省掉的是 g_silu 和 u 这两个 [B, F] 中间张量各一次写 + 一次读——F 通常是 H 的 2.7 倍(LLaMA-7B 是 11008 vs 4096),这份流量并不小。vLLM v0.8.5 的 LLaMA 走的是介于 A 和 B 之间的路子:gate_proj 与 up_proj 合并成一个 gate_up_proj GEMM(vllm-0.8.5/vllm/model_executor/models/llama.py:70),再由一个 kernel 同时做 SiLU 和相乘(vllm-0.8.5/csrc/activation_kernels.cu:22 的 act_and_mul_kernel)。
8.9 Fusion 的边界与陷阱
不是融合越多越好。Fusion 有几个隐藏成本,前三条是硬约束——踩到了 kernel 直接变慢或编不出来;后两条是工程成本,不影响正确性,但会在排查问题时向你收费。
寄存器压力。 把多个算子塞到一个 kernel 里,每线程需要的中间寄存器变多。Hopper 单线程 register 上限 255 个,超过就 spill。寄存器压力大会让 occupancy 降低,反而拖慢整体。
SMEM 容量。 Fused kernel 经常需要在 SMEM 里 stage 中间数据。但 H100 每 SM 的 SMEM 总共 228 KB(单个 block 最多 227 KB),过度融合会撑爆。
编译时间爆炸。 CUTLASS 的模板化 fused kernel,编译可以慢到几分钟一个 kernel。生产环境一定要 cache 编译产物。
调试难度。 朴素拆分的 kernel 每一步可以单独打印中间结果;fused kernel 的中间结果在寄存器里,调试需要技巧(用 conditional 写回 HBM 一段查看)。
错误传播。 Fused kernel 的一个 bug 可能影响多个 op 的正确性。强烈推荐:fused 版本和拆分版本同时存在,CI 测试都跑,互相验证数值一致。
8.10 这一章的小结与下一章
这一章建立了 LLM kernel 优化的"融合直觉":
- Element-wise 算子在 LLM 中无处不在:每个 decoder block 都有 4-6 个,是 HBM 带宽的纯消费者。
- 三个融合层次:纯 element-wise 串接 → element-wise + reduce → 跨异构(GEMM epilogue)。每深一层收益和复杂度都更大。
- vLLM 的
fused_add_rms_norm是 LLM 推理引擎的"小招牌":典型的 element-wise + reduce 融合,省一个中间张量、一次 launch,并用_f16Vec+cub::BlockReduce把两件事压进一遍循环。 - CUTLASS Epilogue 是工业级 GEMM fusion 的事实标准:第 13 章会详细讲。
- 过度融合有反效果:寄存器压力、SMEM 容量、编译时间、调试难度都是隐藏成本。
第 9 章我们继续往 LLM 推理的深处走——讲 量化 Kernel。INT8 / FP8 / INT4 量化是 LLM 推理性能跃迁的另一个关键,但 dequantize(解码)这一步本身又是一个"小算子",需要特别的设计才能高效。读完第 9 章读者会理解为什么 Marlin 的 INT4 GEMM 在小 batch 下能比 cuBLAS 的 FP16 GEMM 还快。
本章动手练习:
- 实现 add + rms_norm 的拆分版和 fused 版,对比 H=8192 时的延迟;再用 Nsight Compute 看
dram__bytes.sum,验证流量确实从 5 份降到了 4 份(B 要取得让张量远大于 50 MB 的 L2,否则中间张量会直接命中 L2,DRAM 计数反映不出差别)。- 用 PyTorch 的
torch.compile编译一个 LLM block,看 TorchInductor 生成的 Triton kernel——观察它做了哪些 element-wise fusion。- 思考:如果 GEMM 的输出是 INT8(量化推理),怎么把 dequantize(INT8 → FP16)和后续 RMSNorm 融合起来?