CUDA4_使用CUDA实现Transformer结构
CUDA(四):使用 CUDA 实现 Transformer 结构
作者: 紫气东来
原文链接: https://zhuanlan.zhihu.com/p/694416583
发布日期: 2024年
文章概述
本文是 CUDA 系列的第四篇,聚焦于使用 CUDA 从零实现 Transformer 架构。作者基于 OpenAI 科学家 Andrej Karpathy 开源的 llm.c 项目,详细解析了如何脱离深度学习框架,直接使用 CUDA 构建 LLM 的算子模块、训练过程和计算优化。这是一次深入底层的技术探索,旨在帮助开发者理解 Transformer 的实现细节和性能优化技巧。
核心价值:
- 摆脱框架依赖,深入理解 LLM 底层实现
- 掌握关键算子的 CUDA 优化技术
- 学习完整的训练流程搭建
- 了解 cuDNN 加速库的实际应用
一、关键算子及模块的实现
作者将算子和模块的实现分为基础算子和核心模块两大类,每个部分都有独立的详细文章。
1.1 基础算子的实现
1. LayerNorm 算子
- 原理: Layer Normalization 对每个样本的特征维度进行归一化
- 实现要点:
- 前向传播:计算均值和方差,进行归一化
- 反向传播:计算梯度回传
- 优化策略:使用 Welford 算法进行在线计算,减少内存访问
- 参考文章: https://zhuanlan.zhihu.com/p/694974164
2. Softmax 算子
- 原理:
- Safe Softmax: 减去最大值防止数值溢出
- Online Softmax: 流式计算,适合大规模数据
- 实现与优化:
- 基础实现:两次遍历(找最大值 + 计算 softmax)
- 优化版本:使用 warp-level 原语减少同步开销
- 高阶优化:融合 kernel、向量化访问
- 参考文章: https://zhuanlan.zhihu.com/p/695307283
3. Cross Entropy 损失函数
- 前向过程: 计算交叉熵损失
- 与 Softmax 的融合反向:
- 数学简化:softmax + cross entropy 的梯度可以简化为
pred - target - 性能优势:减少中间结果存储,提高计算效率
- 数学简化:softmax + cross entropy 的梯度可以简化为
- 参考文章: https://zhuanlan.zhihu.com/p/695594396
4. AdamW 优化器
- 原理剖析:
- 一阶动量(momentum):指数移动平均梯度
- 二阶动量(variance):指数移动平均梯度平方
- 权重衰减(weight decay):直接在参数上应用衰减
- 偏差修正(bias correction):修正初始阶段的估计偏差
- 实现细节:
- 维护两个状态缓冲区(m 和 v)
- 使用 master weights(FP32)存储精确参数
- 参考文章: https://zhuanlan.zhihu.com/p/695611950
5. GELU 激活函数与残差连接
- GELU 原理:
- 平滑的非线性激活函数
- 公式:
GELU(x) = x * Φ(x),其中 Φ 是标准正态分布的累积分布函数 - 近似实现:使用 tanh 近似加速计算
- 残差连接:
- 缓解梯度消失问题
- 实现:
output = input + F(input)
- 参考文章: https://zhuanlan.zhihu.com/p/695703671
6. 矩阵乘法(GEMM)
- 基础实现: 朴素三重循环
- 优化策略:
- Tiling(分块):提高缓存命中率
- 共享内存:减少全局内存访问
- 寄存器优化:最大化寄存器利用
- Warp-level 优化:利用 tensor core
- 参考文章: https://zhuanlan.zhihu.com/p/657632577
1.2 核心模块的实现
1. Embedding 层与 LM Head 层
- Embedding 层:
- 功能:将 token ID 映射为稠密向量
- 实现:查表操作,从权重矩阵中提取对应行
- 位置编码:可学习的位置嵌入
- LM Head 层:
- 功能:将隐藏状态映射回词表空间
- 实现:线性变换 + softmax
- 权重共享:通常与 embedding 层共享权重
- 参考文章: https://zhuanlan.zhihu.com/p/695785781
2. Self-Attention 机制
-
实现流程:
- 计算 Q、K、V:通过线性变换得到查询、键、值
- 计算注意力分数:
scores = Q @ K^T / sqrt(d_k) - 应用 Softmax:归一化注意力权重
- 加权求和:
output = softmax(scores) @ V - 多头拼接:合并多个注意力头的输出
-
优化技术:
- Flash Attention:减少 HBM 访问,使用分块计算
- Kernel 融合:减少中间结果的读写
- 使用 cuDNN 加速库
-
反向传播:
- 梯度计算复杂,需要保存前向的中间结果
- 使用 recomputation 技术平衡内存和计算
-
参考文章:
二、训练过程的实现
2.1 训练前的准备工作
2.1.1 训练数据下载并分词
使用 Python 脚本进行数据准备:
python prepro_tinyshakespeare.py
执行操作:
- 数据下载: 从 GitHub 下载 Tiny Shakespeare 数据集
- 分词处理: 使用 GPT-2 tokenizer 进行分词
- 数据集划分: 分为训练集和验证集
输出结果:
Saved 32768 tokens to data/tiny_shakespeare_val.bin
Saved 305260 tokens to data/tiny_shakespeare_train.bin
2.1.2 标准模型转换
主要操作:
- 加载预训练模型: 加载标准的 GPT-2 模型
- 格式转换:
- 保存为 FP32 和 BF16 两个版本的二进制文件
- 词表大小从 50257 padding 到 50304(提高矩阵乘法效率)
- Tokenizer 转换: 将分词器保存为二进制格式
- 调试状态保存: 保存输入和中间状态用于验证
输出文件:
gpt2_tokenizer.bin: 分词器gpt2_124M.bin: FP32 模型权重gpt2_124M_bf16.bin: BF16 模型权重gpt2_124M_debug_state.bin: 调试状态
性能基准(PyTorch 版本):
iteration 1, loss: 5.2700, time: 69.203ms, tok/s: 3699.27
iteration 2, loss: 4.0597, time: 48.847ms, tok/s: 5240.88
...
iteration 10, loss: 0.3765, time: 62.727ms, tok/s: 4081.15
final 9 iters avg: 58.621ms
peak memory consumption: 2392 MiB
2.2 训练过程
2.2.1 训练过程的实现
核心代码结构:
// 1. 构建 GPT-2 模型
GPT2 model;
gpt2_build_from_checkpoint(&model, load_filename);
// 2. 初始化分词器
Tokenizer tokenizer;
tokenizer_init(&tokenizer, "gpt2_tokenizer.bin");
// 3. 初始化数据加载器
DataLoader train_loader, val_loader;
dataloader_init(&train_loader, &multi_gpu_config, train_tokens_filename, B, T);
dataloader_init(&val_loader, &multi_gpu_config, val_tokens_filename, B, T);
// 4. 训练循环
for (int step = 0; step < max_steps; step++) {
dataloader_next_batch(&train_loader);
gpt2_forward(&model, train_loader.inputs, train_loader.targets, B, T, false);
gpt2_zero_grad(&model);
gpt2_backward(&model);
gpt2_update(&model, learning_rate, 0.9f, 0.999f, 1e-8f, 0.0f, step+1);
}
编译与运行:
make train_gpt2cu
./train_gpt2cu
训练配置:
| Parameter | Value |
+-----------------------+----------------------------------------------------+
| batch size B | 4 |
| sequence length T | 1024 |
| learning rate | 3.000000e-04 |
| device | NVIDIA GeForce RTX 4090 |
| precision | BF16 |
| num_layers L | 12 |
| num_heads NH | 12 |
| channels C | 768 |
| num_parameters | 124475904 |
内存分配:
- 模型参数: 237 MiB
- 激活值: 2853 MiB
- 参数梯度: 237 MiB
- 激活梯度: 126 MiB
- AdamW 状态 m: 474 MiB
- AdamW 状态 v: 474 MiB
- Master weights: 474 MiB
训练性能(纯 CUDA 实现):
step 1/74: train loss 4.364526 (43.898880 ms, 93305.343750 tok/s)
step 2/74: train loss 4.501646 (41.996288 ms, 97532.406250 tok/s)
...
step 20/74: train loss 3.743915 (42.046463 ms, 97677.789062 tok/s)
val loss 3.702019
生成示例:
Nay, thou amongst I: he never came to hear this, that I shall not entertain
the will of myself. Call to play, Gregg! Take W.L<|endoftext|>Your pleas
about my ignorance, Oh, move your way, charge, plunder: plan for This bloody
Staff expedition:
2.2.2 使用 cuDNN 模块加速
编译与运行:
make train_gpt2cu USE_CUDNN=1
./train_gpt2cu
性能提升:
- 单卡性能提升约 30%
- 从 ~97K tok/s 提升到 ~127K tok/s
cuDNN 版本训练日志:
step 1/74: train loss 4.370480 (362.220551 ms, 11308.027344 tok/s) # 首次编译
step 2/74: train loss 4.505543 (32.261120 ms, 126963.945312 tok/s)
step 3/74: train loss 4.421638 (32.068607 ms, 127354.804688 tok/s)
...
step 20/74: train loss 3.746799 (32.408577 ms, 127089.820312 tok/s)
val loss 3.702071
内存优化:
- 激活值内存从 2853 MiB 降至 1703 MiB
- 激活梯度从 126 MiB 降至 30 MiB
- cuDNN 通过 kernel 融合和内存复用实现优化
2.2.3 更大规模训练示例
使用 TinyStories 数据集进行训练:
# 数据准备
python prepro_tinystories.py
# 训练(更大的 batch size)
./train_gpt2cu -i data/TinyStories -v 250 -s 250 -g 144 -o stories.log -b 32
训练配置:
- Batch size: 32(8倍增加)
- 训练批次: 28248
- 生成长度: 144 tokens
性能表现:
step 1/28248: train loss 2.386457 (780.998657 ms, 41956.539062 tok/s)
step 2/28248: train loss 3.283106 (215.723007 ms, 151898.468750 tok/s)
step 3/28248: train loss 2.365078 (219.082748 ms, 150703.875000 tok/s)
...
step 20/28248: train loss 1.869874 (220.104706 ms, 149131.281250 tok/s)
损失下降趋势:
- 初始损失: ~2.38
- 20 步后: ~1.87
- 收敛速度快,数据质量好
三、环境配置要求
该项目对软件版本有较高要求,推荐配置:
OS: Ubuntu 22.04
Driver: 550.54.15
CUDA: 12.4
PyTorch: 2.4.0.dev20240513+cu121
cuDNN: 8.9.7.29
cudnn-frontend: 1.4.0
关键依赖:
- CUDA 12.4+:支持最新的 GPU 特性
- cuDNN 8.9+:提供高性能的深度学习原语
- cudnn-frontend:简化 cuDNN API 调用
四、性能分析与优化总结
4.1 性能对比
| 实现方式 | 吞吐量 (tok/s) | 相对提升 | 内存占用 (激活) |
|---|---|---|---|
| PyTorch | ~4,000 | 基准 | 未知 |
| 纯 CUDA | ~97,000 | 24x | 2,853 MiB |
| CUDA + cuDNN | ~127,000 | 32x | 1,703 MiB |
4.2 关键优化技术
1. 算子层面优化
- Kernel 融合: 减少内存访问次数
- 向量化访问: 使用 float4 等向量类型
- 共享内存: 利用片上高速缓存
- Warp-level 原语: 减少同步开销
2. 内存优化
- BF16 精度: 减少内存占用和带宽需求
- Master weights: 保持 FP32 精度用于参数更新
- Activation checkpointing: 用计算换内存
- 内存池管理: 减少分配开销
3. 计算优化
- cuBLAS: 高性能矩阵乘法
- cuDNN: 优化的卷积和归一化操作
- Flash Attention: 减少 attention 的内存访问
- Tensor Core: 利用专用硬件加速
4. 系统层面优化
- 异步执行: 重叠计算和数据传输
- 流水线: 多个 kernel 并行执行
- 多 GPU: 数据并行和模型并行
4.3 性能瓶颈分析
内存带宽瓶颈:
- LayerNorm、Softmax 等算子受内存带宽限制
- 优化策略:kernel 融合、向量化访问
计算瓶颈:
- 矩阵乘法是主要计算量
- 优化策略:使用 cuBLAS、Tensor Core
同步开销:
- 频繁的 kernel 启动和同步
- 优化策略:kernel 融合、异步执行
五、实践建议与最佳实践
5.1 开发建议
- 从简单开始: 先实现正确的版本,再进行优化
- 逐步优化: 每次优化后验证正确性和性能提升
- 使用 profiler: 使用 Nsight Systems/Compute 分析性能
- 参考标准实现: 对比 PyTorch 的输出验证正确性
- 保存中间状态: 便于调试和验证
5.2 调试技巧
- 数值验证: 与 PyTorch 实现对比中间结果
- 梯度检查: 使用数值梯度验证反向传播
- 单元测试: 为每个算子编写测试
- 可视化: 使用 TensorBoard 监控训练过程
- 日志记录: 详细记录损失、梯度范数等指标
5.3 性能调优
- Profile 驱动: 先分析瓶颈再优化
- 内存优化优先: GPU 通常受内存带宽限制
- 批量大小调整: 找到最佳的 batch size
- 混合精度: 使用 BF16/FP16 加速训练
- 库函数优先: 优先使用 cuBLAS、cuDNN 等优化库
六、技术亮点与创新
6.1 项目特色
- 完全脱离框架: 不依赖 PyTorch/TensorFlow
- 教育价值高: 代码清晰,注释详细
- 性能优秀: 接近或超过框架性能
- 模块化设计: 易于理解和扩展
- 渐进式优化: 展示从基础到高级的优化过程
6.2 学习价值
- 深入理解 Transformer: 从底层实现理解每个组件
- 掌握 CUDA 编程: 学习高性能 GPU 编程技巧
- 优化思维培养: 理解性能优化的思路和方法
- 工程实践: 学习大型项目的组织和管理
七、总结与展望
7.1 核心要点
- 算子实现是基础: LayerNorm、Softmax、Attention 等算子的高效实现是关键
- 优化是系统工程: 需要从算法、实现、硬件多个层面综合考虑
- cuDNN 加速显著: 使用优化库可以获得 30% 以上的性能提升
- 内存管理重要: 合理的内存分配和复用可以支持更大的模型和 batch size
7.2 未来方向
- 更多优化技术: Flash Attention 2、PagedAttention 等
- 分布式训练: 多 GPU、多节点训练支持
- 更大模型: 支持 GPT-3、LLaMA 等更大规模模型
- 推理优化: 量化、剪枝、知识蒸馏等技术
- 新硬件支持: 适配 H100、A100 等新一代 GPU
7.3 参考资源
- 项目地址: https://github.com/karpathy/llm.c
- 学习资源: https://github.com/ifromeast/cuda_learning
- 相关论文: Attention Is All You Need, Flash Attention 等
附录:关键代码片段
A.1 前向传播核心流程
void gpt2_forward(GPT2 *model, int* inputs, int* targets, int B, int T, bool training) {
// 1. Token embedding + Position embedding
encoder_forward(model->acts.encoded, inputs, model->params.wte,
model->params.wpe, B, T, C);
// 2. Transformer blocks
for (int l = 0; l < L; l++) {
// LayerNorm 1
layernorm_forward(ln1, encoded, ln1w, ln1b, B, T, C);
// Multi-head attention
attention_forward(atty, att, ln1, l0, l1, B, T, C, NH);
// Residual connection 1
residual_forward(residual, encoded, atty, B*T*C);
// LayerNorm 2
layernorm_forward(ln2, residual, ln2w, ln2b, B, T, C);
// MLP
matmul_forward(fch, ln2, fcw, B, T, C, 4*C);
gelu_forward(fch_gelu, fch, B*T*4*C);
matmul_forward(fcproj, fch_gelu, fcprojw, B, T, 4*C, C);
// Residual connection 2
residual_forward(encoded, residual, fcproj, B*T*C);
}
// 3. Final LayerNorm
layernorm_forward(model->acts.lnf, encoded, lnfw, lnfb, B, T, C);
// 4. LM head
matmul_forward(model->acts.logits, model->acts.lnf,
model->params.wte, B, T, C, V);
// 5. Loss calculation
if (targets != NULL) {
softmax_crossentropy_forward(model->acts.losses, model->acts.logits,
targets, B, T, V);
}
}
A.2 反向传播核心流程
void gpt2_backward(GPT2 *model) {
// 1. Loss gradient
softmax_crossentropy_backward(dlogits, model->acts.losses,
model->acts.logits, targets, B, T, V);
// 2. LM head backward
matmul_backward(dlnf, dwte, dlogits, model->acts.lnf,
model->params.wte, B, T, C, V);
// 3. Final LayerNorm backward
layernorm_backward(dresidual, dlnfw, dlnfb, dlnf, residual, lnfw, B, T, C);
// 4. Transformer blocks backward (reverse order)
for (int l = L-1; l >= 0; l--) {
// Residual 2 backward
residual_backward(dresidual2, dfcproj, dresidual, B*T*C);
// MLP backward
matmul_backward(dfch_gelu, dfcprojw, dfcproj, fch_gelu,
fcprojw, B, T, 4*C, C);
gelu_backward(dfch, fch, dfch_gelu, B*T*4*C);
matmul_backward(dln2, dfcw, dfch, ln2, fcw, B, T, C, 4*C);
// LayerNorm 2 backward
layernorm_backward(dresidual, dln2w, dln2b, dln2, residual, ln2w, B, T, C);
// Residual 1 backward
residual_backward(dencoded, datty, dresidual, B*T*C);
// Attention backward
attention_backward(dln1, dl0, dl1, datty, ln1, l0, l1, B, T, C, NH);
// LayerNorm 1 backward
layernorm_backward(dencoded, dln1w, dln1b, dln1, encoded, ln1w, B, T, C);
}
// 5. Embedding backward
encoder_backward(dwte, dwpe, dencoded, inputs, B, T, C);
}
A.3 AdamW 优化器更新
void adamw_update(float* params, float* grads, float* m, float* v,
int n, float lr, float beta1, float beta2,
float eps, float weight_decay, int t) {
for (int i = 0; i < n; i++) {
// Update biased first moment estimate
m[i] = beta1 * m[i] + (1.0f - beta1) * grads[i];
// Update biased second raw moment estimate
v[i] = beta2 * v[i] + (1.0f - beta2) * grads[i] * grads[i];
// Compute bias-corrected first moment estimate
float m_hat = m[i] / (1.0f - powf(beta1, t));
// Compute bias-corrected second raw moment estimate
float v_hat = v[i] / (1.0f - powf(beta2, t));
// Update parameters with weight decay
params[i] = params[i] * (1.0f - lr * weight_decay)
- lr * m_hat / (sqrtf(v_hat) + eps);
}
}
诗句引用:
浅水池塘莲叶香,红尘道路柳阴长。 —— 彭汝砺 《和初夏》
本文档基于 llm.c 项目整理,详细代码请参考原项目仓库。