大模型推理中的算子融合优化:LayerNorm + Attention 的 CUDA Kernel 手写与验证

发布时间:2026/7/11 0:04:43
大模型推理中的算子融合优化:LayerNorm + Attention 的 CUDA Kernel 手写与验证
大模型推理中的算子融合优化LayerNorm Attention 的 CUDA Kernel 手写与验证一、GPU 利用率 30%分开的算子吃掉所有带宽推理服务的 GPU 利用率监测显示一个反直觉的现象计算核心SM利用率不到 30%但显存带宽HBM bandwidth利用率接近 95%。问题在于算子是彼此分离的——LayerNorm 写回 HBMAttention 再从 HBM 读入中间的数据搬运完全浪费了显存带宽。以 LLaMA 架构为例每个 Transformer Block 包含RMS Norm或 LayerNormQ/K/V 投影RoPE 位置编码Attention 计算O 投影残差连接第二个 RMS Norm FFN每个算子都独立执行中间结果全部写回 HBM。在 Decode 阶段每次只处理 1 个 token算子的启动开销远超实际计算量。将多个相邻算子融合为一个 CUDA Kernel消除中间的数据写回和重新加载可以将带宽利用率降低 40%~60%总体延迟减少 25%~40%。二、算子融合的访存模型与性能分析graph LR subgraph 融合前三次 HBM 读写 A1[HBM] --|读 input| B1[LayerNorm Kernel] B1 --|写 output| A2[HBM] A2 --|读 normed| B2[QKV Project Kernel] B2 --|写 QKV| A3[HBM] A3 --|读 QKV| B3[Attention Kernel] B3 --|写 result| A4[HBM] end subgraph 融合后一次 HBM 读 一次写 C1[HBM] --|读 input| D1[Fused Kernel:br/LayerNorm QKV Attention] D1 --|写 result| C2[HBM] end style A1 fill:#1a1a2e,stroke:#e94560,color:#fff style A4 fill:#1a1a2e,stroke:#e94560,color:#fff style C1 fill:#16213e,stroke:#0f3460,color:#fff style C2 fill:#16213e,stroke:#0f3460,color:#fff数据搬运的次数决定了延迟下限。在 A100 上HBM 带宽为 2TB/s但每次访问的延迟约为 300~400 个时钟周期。对于 head_dim128 的 Attention 计算实际计算量约 1000 FLOPS/元素而加载一个 FP16 元素需要约 1 个字节。算术强度FLOPS/byte约为 1000/2 500 FLOPS/byte。A100 的理论峰值算术强度为 312 TFLOPS / 2 TB/s 156 FLOPS/byte。500 156说明 Attention 是受限于带宽而非计算。融合后每个元素从 HBM 读取次数从 3 次降至 1 次等效算术强度提升 3 倍。三、融合 LayerNorm Attention 的 CUDA Kernel 实现// fused_attention.cu // 融合 LayerNorm QKV Projection Attention Score 的 Kernel // // 为什么需要融合这三个算子 // 1. Decode 阶段每步只处理 1 个 tokenkernel launch 开销 计算量 // 2. 三者共享同一个输入数据 (hidden_states) // 融合后每个线程 block 从 HBM 加载一次计算三次 // 3. 中间结果 (normed_hidden, Q, K, V) 全部在寄存器/共享内存中 // 不写回 HBM #include cuda_fp16.h #include cuda_bf16.h // 常量配置 constexpr int HEAD_DIM 128; constexpr int NUM_HEADS 32; constexpr int NUM_KV_HEADS 8; // GQA: group query attention constexpr int BLOCK_SIZE 256; /** * 融合 KernelLayerNorm QKV 投影 Attention Score 计算 * * param output [seq_len, num_heads, head_dim] FP16 输出 * param input [seq_len, hidden_dim] FP16 输入 (hidden_states) * param ln_weight [hidden_dim] LayerNorm 权重 * param ln_bias [hidden_dim] LayerNorm 偏置 * param q_weight [hidden_dim, num_heads * head_dim] Q 投影权重 * param k_weight [hidden_dim, num_kv_heads * head_dim] K 投影权重 * param v_weight [hidden_dim, num_kv_heads * head_dim] V 投影权重 * param k_cache [max_seq_len, num_kv_heads, head_dim] 累积的 K Cache * param v_cache [max_seq_len, num_kv_heads, head_dim] 累积的 V Cache * param current_pos 当前 token 在序列中的位置 * * 关键优化 * 1. 每个线程块处理一个 head利用共享内存做 Reduction * 2. LayerNorm 的均值和方差在寄存器内计算不写回显存 * 3. QKV 投影使用 warp-level 矩阵乘累加 */ __global__ void fused_rmsnorm_qkv_attention_kernel( half* __restrict__ output, const half* __restrict__ input, const half* __restrict__ ln_weight, const half* __restrict__ q_weight, const half* __restrict__ k_weight, const half* __restrict__ v_weight, half* __restrict__ k_cache, half* __restrict__ v_cache, const int hidden_dim, const int current_pos ) { // 每个线程块负责一个 attention head int head_idx blockIdx.x; int kv_head_idx head_idx / (NUM_HEADS / NUM_KV_HEADS); // GQA 映射 // 第一阶段RMS Normalization (在寄存器中完成) // 使用 warp reduce 计算平方和 // 为什么用 warp shuffle 而非共享内存 reduce // 1. warp shuffle 延迟 ~5 cycles共享内存 ~30 cycles // 2. LayerNorm 的 hidden_dim 通常 ≤ 4096 // 单 warp 32 threads × 多次迭代可覆盖 __shared__ float s_rms; // 全局 RMS 值只需一个 float float sum_sq 0.0f; for (int i threadIdx.x; i hidden_dim; i BLOCK_SIZE) { half val input[i]; float f_val __half2float(val); sum_sq f_val * f_val; } // Warp-level reduction // 为什么 Step 1 不直接用 __shfl_xor_sync // 线程块可能 32需要分层 reduce // warp 内部 reduce → 共享内存 → 总的 RMS for (int offset 16; offset 0; offset 1) { sum_sq __shfl_xor_sync(0xffffffff, sum_sq, offset); } if ((threadIdx.x 31) 0) { // warp 0 的线程写入共享内存 // 注意所有 warp 都需要写入这里是每个 warp 的 lane 0 } __syncthreads(); // 最终 RMS主线程计算 float rms; if (threadIdx.x 0) { rms sqrtf(sum_sq / hidden_dim 1e-6f); s_rms rms; } __syncthreads(); rms s_rms; // 第二阶段归一化 Q 投影 // 每个线程计算一部分 Q 值 // Q[head_idx][...] normed_input q_weight[:, head_idx * HEAD_DIM : (head_idx1) * HEAD_DIM] // 使用寄存器数组存储 Q 的部分结果 // 为什么 Q 用寄存器数组 // 1. Decode 阶段 Q 只有 1 个 token维度 HEAD_DIM128 // 2. 128 个 half 值 ≈ 256 bytes在现代 GPU 的寄存器文件中完全放得下 // 3. 寄存器访问延迟 0 cycles共享内存访问延迟 ~30 cycles half q_reg[HEAD_DIM / 32]; // 每个线程的 Q 部分32 个 half 64 bytes #pragma unroll for (int d 0; d HEAD_DIM / 32; d) { q_reg[d] __float2half(0.0f); } // 矩阵乘Q[d] sum_i( normed_input[i] * q_weight[i][d] ) for (int i 0; i hidden_dim; i) { // 加载并归一化 input half raw input[i]; float normed __half2float(raw) / rms; half normed_h __float2half(normed); // 乘以 ln_weightLayerNorm 的 affine transform normed_h __hmul(normed_h, ln_weight[i]); // 累加到 Q 投影 int base_col head_idx * HEAD_DIM; #pragma unroll for (int d 0; d HEAD_DIM / 32; d) { int col base_col d * 32 (threadIdx.x 31); half w q_weight[i * (NUM_HEADS * HEAD_DIM) col]; q_reg[d] __hfma(normed_h, w, q_reg[d]); } } // 应用 RoPE 位置编码简化版 // 生产代码中应使用更高效的 RoPE 实现 // rotate_half 操作在寄存器中完成 // 第三阶段K/V 投影 写入 Cache // 与 Q 投影类似省略具体实现结构与 Q 投影相同 // 关键点K/V 投影结果直接写入 global memory 中的 K/V Cache // 不经过中间缓冲区 // 第四阶段Attention Score 计算 // Q K^T只计算新 token 对所有历史 token 的 attention // 这里使用 GQA 的分组方式 attn_score q_reg k_cache[current_pos]; // 简化表示 }共享内存的 Bank Conflict 规避在 Attention 的 Q·K^T 阶段如果 K 矩阵的 layout 是[num_kv_heads, seq_len, head_dim]head_dim128 时32 个线程访问的 stride128 个 half 256 bytes不会造成 bank conflict每个 bank 4 bytes128 个 half 跨越 32 个 bank × 16 行。但如果在做 warp-level reduction 时访问模式是 strided如__shfl_xor_sync的 butterfly 模式则需要确保每次访问的地址在同一 bank 的不同行避免冲突。四、融合 Kernel 的正确性验证与精度边界与逐算子实现的数值差异融合 Kernel 中的计算顺序与原版可能不同。例如LayerNorm 的 affine transform 在原版中独立执行精度为 FP32CUDA 默认。融合后由于性能考虑可能使用 FP16——这在 99% 的推理场景下无影响但在数学运算密集型任务中可能产生可观测的 perplexity 偏差。验证方法逐元素对比融合前后输出的差异绝对值统计差异超过 1e-4 的比例应 0.01%在标准 benchmarkMMLU、HellaSwag上评估准确率差异应 0.1%不适用场景训练非推理训练需要反向传播融合破坏了计算图的中间结果Prefill 阶段首次推理时序列长度大 1 token各算子的计算量本身足够大kernel launch 开销占比小BF16 推理部分 GPU 的 BF16 vs FP16 在 LayerNorm 上的精度差异可能因为融合而被放大五、总结算子融合的核心收益是减少 HBM 读写次数——在 Decode 阶段HBM 带宽是主要瓶颈融合可降低 40%~60% 的带宽使用warp shuffle 的 5-cycle 延迟远低于共享内存的 30-cycle是 LayerNorm 这类 Reduction 操作的最佳实现方式Decode 阶段的 Q 值可以全部放在寄存器中128 × 2 bytes 256 bytes消除共享内存访问延迟融合 Kernel 的数值精度与逐算子实现存在微小差异 1e-4在推理场景中可忽略但需通过 benchmark 验证算子融合仅适用于 Decode 阶段的推理优化训练和 Prefill 阶段的收益不显著甚至为负