局部梯度相乘
正向传播
ANDREA-120M 的正向传播将输入依次通过一系列操作:
x = embed(token_ids) # token embeddings
for layer in 12_layers:
x = x + attn(LN(x)) # 注意力子层
x = x + mlp(LN(x)) # MLP 子层
logits = LN(x) @ embed.T # 共享输出投影
loss = cross_entropy(logits, targets)
每个操作读取输入张量并产生输出张量。正向传播以单个标量结束:该批次的交叉熵损失。
反向传播
训练会按照减少损失的方向更新权重。为了获得更新方向,引擎需要:
模型中每个可学习 W 的 dL/dW
链式法则提供了这个。对于链 loss = f(g(h(x))):
dL/dx = (dL/df) * (df/dg) * (dg/dh) * (dh/dx)
每个因子都是一个局部梯度:当输入小幅变化时,一个操作的输出如何变化。将局部梯度向后通过图相乘,将损失信号传播到每个权重。
反向模式微分
反向传播以逆序计算梯度:从 dL/dlogits = 1 开始,然后向后遍历交叉熵、输出投影、层归一化、十二个 Transformer 块,然后是嵌入。在每一步,将传入梯度乘以局部雅可比矩阵。
当输出是一个单一标量(损失)且有许多输入(权重)时,反向模式是高效的。一次向后传播为模型中的每个权重生成梯度。前向模式需要为每个权重进行一次传播;对于具有 ~120M 权重的 ANDREA-120M,前向模式是不可行的。
为什么使用反向模式
每个前向操作都有一个反向双生
配对规则
microgpt_cuda.cu 为每个操作提供两个 CUDA 内核:一个计算前向输出,一个在给定输出梯度的情况下计算输入梯度。配对是一对一的:
| 前向内核 | 后向内核 | 操作 |
|---|---|---|
k_embed_fwd | k_embed_bwd | 令牌嵌入查找 |
k_layernorm_fwd | k_layernorm_bwd | 层归一化 |
k_attn_qkv_fwd | k_attn_qkv_bwd | Q、K、V 投影 |
k_attn_fwd | k_attn_bwd | 缩放点积注意力 |
k_attn_out_fwd | k_attn_out_bwd | 输出投影 W_O |
k_mlp_fwd | k_mlp_bwd | MLP(带 GELU) |
k_residual_add | k_residual_add_bwd | 残差连接 |
k_loss_fwd | k_loss_bwd | 交叉熵损失 |
八个操作对覆盖了完整的 transformer。还有几个实用内核:k_grad_norm_partial、k_grad_norm_final、k_grad_scale 用于梯度裁剪(参见活动 75)。
反向内核的工作
给定从后续层流入的梯度(grad_output),反向内核计算:
1. grad_input:相对于操作输入张量的梯度。这会进一步向后传递。
2. grad_weight:相对于操作中可学习参数的梯度。这会进入优化器状态。
两者都在单个内核启动中计算。CUDA 线程并行协作于梯度张量的瓦片。
保存的张量
反向计算通常需要前向传递中的值。例如,k_layernorm_bwd 需要前向计算的均值和方差;k_mlp_bwd 需要 GELU 预激活。训练引擎在前向过程中将这些存储在专用缓冲区中,然后在反向过程中读取它们。
内存成本:与前向输出的形状大致相同,每个保存的张量。对于 ANDREA-120M,batch=8, seq=1024, d_model=768,一个保存的张量是 8 × 1024 × 768 × 4 bytes = 25 MB。跨越 12 层 & 每层多个保存的张量,激活值在训练期间主导 VRAM(~5-10 GB 在 24 GB 显卡上)。
追踪一个反向步骤
梯度在内存中的存储位置
每个权重张量对应一个梯度张量
ANDREA-120M 中的每个可学习权重张量都有一个形状完全相同的匹配梯度张量。对于每个块:
W_Q [768, 768] ↔ grad_W_Q [768, 768]
W_K [768, 768] ↔ grad_W_K [768, 768]
W_V [768, 768] ↔ grad_W_V [768, 768]
W_O [768, 768] ↔ grad_W_O [768, 768]
W_1 [768, 3072] ↔ grad_W_1 [768, 3072]
W_2 [3072, 768] ↔ grad_W_2 [3072, 768]
LN1.gamma [768] ↔ grad_LN1.gamma [768]
LN1.beta [768] ↔ grad_LN1.beta [768]
LN2.gamma [768] ↔ grad_LN2.gamma [768]
LN2.beta [768] ↔ grad_LN2.beta [768]
加上令牌嵌入、位置嵌入和最终层归一化。梯度缓冲区总内存与权重内存匹配:~120M 个浮点数,在 FP32 下 ~480 MB,在 FP16 下 ~240 MB。
跨微批次的累积
ANDREA 的 batch_size = 8 在 FP16 下适合 VRAM。更大的有效批次需要梯度累积:在小批次上运行多个前向+反向传播,将梯度累加到同一个缓冲区中,然后执行一次优化器步骤。
for microbatch in range(n_microbatches):
forward(微批次)
backward() # 添加到梯度缓冲区,不会覆盖
scale_grads(1.0 / n_microbatches) # 跨微批次平均
optimizer_step()
zero_grads() # 为下一个训练步骤重置
反向传播内核使用 += 语义,而不是 =。每次调用都会将梯度贡献添加到现有缓冲区;缓冲区保持运行总和,直到 zero_grads() 清空它。
优化器状态
AdamW(活动 73)为每个权重额外持有两个缓冲区:一阶矩 m 和二阶矩 v。训练时总内存:
权重: 1× 权重数量
梯度: 1× 权重数量
Adam m: 1× 权重数量
Adam v: 1× 参数数量
保存的激活: ~2-4× 取决于层数和批次
──────────────────────────────────────────
总计: ~6-8× 参数数量
ANDREA-120M 在 FP16: ~240 MB × 4 个缓冲区 (权重、梯度、m、v) + ~5-10 GB 激活 = ~10-12 GB 总计。舒适地低于 RTX 4090 的 24 GB 上限。ANDREA-12M 在 1.4 GB 中训练;10× 参数缩放带来 ~10× 内存。
梯度缓冲区大小
完全控制内存与精度
通用框架的代价
PyTorch 和 JAX 让自动求导变得方便:编写 Python 代码,自动获得梯度。代价是:在你的代码与 CUDA 之间有一个通用的分发层。每个操作都要经过 Python 解释器开销、框架簿记和动态内核选择。对于在一张 GPU 上训练小型语言模型,这种开销很重要。
ANDREA 避免的具体成本:
1. Python 解释器延迟。 每个 PyTorch 操作都会跨越 Python/C++ 边界。对于每个训练步骤约 100 个内核启动,每分钟约 9 个步骤,那就是每分钟约 900 次边界跨越。C 级调度消除了这一点。
2. 框架分配器不可预测性。 PyTorch 的缓存分配器平均吞吐量良好,但峰值内存不可预测。ANDREA 的训练引擎在启动时预分配每个缓冲区;训练期间无重新分配、无碎片化、无 100K 步骤时的意外 OOM。
3. 通用内核选择。 PyTorch 通过启发式在运行时选择内核。ANDREA 在编译时选择内核,针对 RTX 4090 张量核心瓦片大小进行调优。
4. 混合精度管道。 ANDREA-120M 的 FP16 cuBLAS 路径 & ANDREA 的 FP8 E4M3 张量核心实验需要精确控制哪些张量处于哪种精度。通用框架通过分层 API 暴露这种控制;自定义 CUDA 直接写入它。
权衡取舍
自定义 CUDA 的成本:需要编写更多代码,发现更多 bug,没有社区生态系统。ANDREA 的 microgpt_cuda.cu 是约 6000 行手写 CUDA,调试花了数月时间。每个新操作都需要编写前向核、后向核和测试。
ANDREA 获得的好处:
- 完全可重现性。 训练管道仅由一个 C 二进制文件和一个 Python 代理组成。无 PyTorch 版本漂移,无与框架 wheel 的 CUDA 版本不匹配。
- 位精确恢复。 SIGTERM 触发检查点写入,精确捕获 GPU 所见的每个张量。恢复时拾取运行时的相同损失轨迹。
- 可预测内存。 ANDREA-120M 在 200K 步训练中无 OOM。内存在引擎启动时已核算。
- 直接硬件访问。 张量核心瓦片大小、FP8 E4M3 设置、异步内存拷贝:均可在 CUDA 中直接寻址,在通用框架中不透明。
可重现性即使命
ANDREA 白皮书第 9 节列出了完整的可重现性堆栈:
训练引擎:microgpt/microgpt_cuda.cu
训练代理:microgpt/training_proxy.py
实验配置:experiments/ANDREA-*-TRAIN.json
数据管道:scripts/pull-hermes3.py, scripts/prep-megachat.py
仪表板:scripts/live-loss-dashboard.html
Bandit 规范:docs/FIREHOSE-BANDIT.md
模型文档:docs/ANDREA.md
硬件要求:一台配备 ≥8 GB VRAM 的 NVIDIA GPU(RTX 3060 或更好)。任何人都可以从这些工件中重现 ANDREA-12M。自定义 CUDA 路径就是原因之一:没有框架版本冻结,没有五年后依赖惊喜。
信号与检查点
CUDA 训练循环响应两种 POSIX 信号:
- SIGTERM:立即写入检查点,然后退出。用于干净地停止训练。
- SIGUSR1:写入即时检查点,继续训练。在 v3 的 polish pivot 期间使用,以捕获状态而不中断运行。
检查点格式:[int32 step][int32 n_params][n_params × float32 weights][n_params × float32 m][n_params × float32 v]。步骤计数器、权重计数,然后是权重后跟 Adam 动量。精确位恢复。代理在 polish 上单独归档 .samples.json & .state.json;.loss.json 从不归档(它累积完整的训练历史)。