NLP & LLM 课程学习笔记

Lecture 06: GPT 系列

核心主题:GPT 架构实现、从零预训练、Flash Attention、分布式训练 (DDP)、HellaSwag 评测

1. GPT 模型家族

模型 发布时间 论文/公告 参数量 关键特点
GPT-1 2018.06 Improving Language Understanding by Generative Pre-Training 117M 首次验证 "预训练 + 微调" 范式
GPT-2 2019.02 Language Models are Unsupervised Multitask Learners 1.5B Zero-shot 多任务,不公开最大模型
GPT-3 2020.05 Language Models are Few-Shot Learners 175B In-context learning, few-shot prompting
GPT-4 2023.03 GPT-4 Technical Report 未公开 (传闻 MoE ~1.8T) 多模态,RLHF 对齐

1.1 GPT-2 模型规格

模型 参数量 层数 (L) 隐藏维度 (d) 注意力头数 (H)
GPT-2 Small 124M 12 768 12
GPT-2 Medium 350M 24 1024 16
GPT-2 Large 774M 36 1280 20
GPT-2 XL 1558M 48 1600 25
参数量估算:$\text{Params} \approx 12 \cdot L \cdot d^2$(忽略 embedding 层)

2. 文本生成策略

策略 原理 优点 缺点
Greedy 每步选概率最大 token 确定性,速度快 重复、缺乏多样性
Beam Search 维护 top-k 候选序列 全局更优 仍偏保守,计算量大
Temperature $p_i = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}$ 控制随机性 需要手动调整 T
Top-k 仅从概率最高的 k 个 token 采样 避免低概率噪声 k 固定不够灵活
Top-p (Nucleus) 从累积概率 $\geq p$ 的最小 token 集合采样 自适应候选数量 实现稍复杂

2.1 最佳实践配置

推荐配置
generation_config = {
    "top_k": 50,
    "top_p": 0.95,
    "temperature": 0.7,
    "repetition_penalty": 1.2,
}

通常 Top-k + Top-p + Temperature 组合使用,兼顾质量和多样性。

3. GPT-2 架构实现 ⭐⭐

3.1 与原始 Transformer 的区别

特性 原始 Transformer GPT-2
架构 Encoder-Decoder Decoder-only
LayerNorm 位置 Post-Norm (sublayer 之后) Pre-Norm (sublayer 之前)
位置编码 固定正弦/余弦 可学习位置嵌入 (Learned PE)
激活函数 ReLU GELU
最终 LayerNorm 在最后一个 block 之后添加 LN
Pre-Norm 优势:梯度流动更稳定,训练深层网络更容易收敛;残差连接直接传递未归一化信号。

3.2 核心组件代码

GPTConfig 配置

from dataclasses import dataclass

@dataclass
class GPTConfig:
    block_size: int = 1024      # 最大序列长度
    vocab_size: int = 50257     # GPT-2 词汇表大小
    n_layer: int = 12           # Transformer 层数
    n_head: int = 12            # 注意力头数
    n_embd: int = 768           # 嵌入维度

CausalSelfAttention (含 Flash Attention)

import torch
import torch.nn as nn
import torch.nn.functional as F

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        assert config.n_embd % config.n_head == 0
        # Q, K, V 投影合并为一个线性层
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)
        # 输出投影
        self.c_proj = nn.Linear(config.n_embd, config.n_embd)
        self.c_proj.NANOGPT_SCALE_INIT = 1  # 残差流缩放标记
        self.n_head = config.n_head
        self.n_embd = config.n_embd

    def forward(self, x):
        B, T, C = x.size()  # batch, seq_len, embedding_dim
        # 计算 Q, K, V
        qkv = self.c_attn(x)
        q, k, v = qkv.split(self.n_embd, dim=2)
        # 重塑为多头: (B, n_head, T, head_dim)
        q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        # Flash Attention (PyTorch 2.0+)
        y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
        # 合并多头
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        # 输出投影
        y = self.c_proj(y)
        return y

Block (Pre-Norm 结构)

class MLP(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd)
        self.gelu = nn.GELU(approximate='tanh')
        self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd)
        self.c_proj.NANOGPT_SCALE_INIT = 1

    def forward(self, x):
        x = self.c_fc(x)
        x = self.gelu(x)
        x = self.c_proj(x)
        return x

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = MLP(config)

    def forward(self, x):
        # Pre-Norm: LN → Sublayer → Residual Add
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlp(self.ln_2(x))
        return x

GPT 模型

class GPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),   # token embedding
            wpe = nn.Embedding(config.block_size, config.n_embd),   # position embedding
            h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
            ln_f = nn.LayerNorm(config.n_embd),  # 最终 LayerNorm
        ))
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
        # Weight Tying: 共享 token embedding 和 output 权重
        self.transformer.wte.weight = self.lm_head.weight
        # 初始化参数
        self.apply(self._init_weights)

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            std = 0.02
            # 残差流路径上的投影层缩放初始化
            if hasattr(module, 'NANOGPT_SCALE_INIT'):
                std *= (2 * self.config.n_layer) ** -0.5
            torch.nn.init.normal_(module.weight, mean=0.0, std=std)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, idx, targets=None):
        B, T = idx.size()
        assert T <= self.config.block_size
        # 前向传播
        pos = torch.arange(0, T, dtype=torch.long, device=idx.device)
        tok_emb = self.transformer.wte(idx)      # (B, T, n_embd)
        pos_emb = self.transformer.wpe(pos)      # (T, n_embd)
        x = tok_emb + pos_emb
        # Transformer blocks
        for block in self.transformer.h:
            x = block(x)
        x = self.transformer.ln_f(x)
        logits = self.lm_head(x)                 # (B, T, vocab_size)
        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
        return logits, loss

4. 关键训练技巧

4.1 Weight Tying

核心思想:共享 token embedding 矩阵 ($W_e$) 和 LM head 矩阵 ($W_{out}$)。

原理

参数节省

$$\text{节省参数} = V \times d = 50257 \times 768 \approx 38.6M$$

占 GPT-2 Small (124M) 总参数的约 31%

# Weight Tying 实现
self.transformer.wte.weight = self.lm_head.weight
# 反向传播时,两处梯度会自动累加到同一张量

4.2 残差流缩放初始化 (Residual Stream Scaling)

残差路径上的投影层(c_proj)使用缩放标准差初始化:

$$\sigma = \frac{0.02}{\sqrt{2 \cdot N_{layers}}}$$

动机:每个 block 有 2 条残差路径(attention + MLP),共 $2N$ 次累加。缩放确保残差流的方差不会随深度爆炸。

# 标记残差路径上的投影层
self.c_proj.NANOGPT_SCALE_INIT = 1

# 初始化时应用缩放
if hasattr(module, 'NANOGPT_SCALE_INIT'):
    std *= (2 * self.config.n_layer) ** -0.5

4.3 Vocab Size 对齐

GPT-2 原始词汇表大小为 50257(不是 2 的幂次),修改为 50304

$$50304 = 128 \times 393$$
属性 50257 50304
是否为 128 的倍数
GPU Tensor Core 对齐 否(需 padding) 是(无浪费)
CUDA kernel 效率 较低 最优
原因:GPU Tensor Core 以 8/16/32/64/128 为单位执行矩阵乘法。维度对齐后可完全利用硬件,避免 padding 浪费。额外 47 个 token 的 embedding 参数可忽略不计。

5. Flash Attention

5.1 标准注意力的问题

标准自注意力需要显式计算并存储完整的注意力矩阵:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V$$

内存瓶颈

IO 瓶颈

5.2 Flash Attention 优势

核心思想:IO-Aware,在 SRAM(片上高速缓存)中完成尽可能多的计算,避免反复读写 HBM。
特性 标准 Attention Flash Attention
内存复杂度 $O(T^2)$ $O(T)$
是否物化注意力矩阵 是(完整 $T \times T$ 矩阵) 否(分块计算,不存储完整矩阵)
HBM 访问次数 多次(Q, K, V, Attn, Output) 少(一次加载 tiles 到 SRAM)
实际速度 基准 2-4x 加速

关键技术

  1. Tiling (分块):将 Q, K, V 分成小块,逐块在 SRAM 中计算
  2. Kernel Fusion (算子融合):将 matmul → scale → mask → softmax → matmul 融合为单一 CUDA kernel
  3. Online Softmax:增量计算 softmax,无需完整行数据
  4. Recomputation:反向传播时重新计算注意力(trade compute for memory)
# PyTorch 2.0+ 原生支持 Flash Attention
# 自动根据硬件选择最优实现 (FlashAttention-2, Memory-Efficient, Math)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
# 等价于手动实现但速度提升 2-4x,显存节省 5-20x

6. 完整预训练配置 (FineWeb-Edu 10B)

6.1 超参数表

超参数 说明
Total batch size 524,288 tokens $= 2^{19}$,GPT-3 论文设置
Micro batch size 64 单 GPU 单次前向的样本数
Sequence length 1024 GPT-2 上下文窗口
Gradient accumulation steps $\frac{524288}{64 \times 1024 \times \text{n\_gpu}}$ 梯度累积步数
Max learning rate 6e-4 GPT-3 中 124M 模型的 LR
Min learning rate 6e-5 max_lr 的 10%
Warmup steps 715 约 375M tokens
Max steps 19,073 $\approx \frac{10B}{524288}$
Weight decay 0.1 仅对 2D 参数(权重矩阵)
Optimizer AdamW $\beta_1=0.9, \beta_2=0.95, \epsilon=10^{-8}$
Grad clip 1.0 全局梯度范数裁剪

6.2 Cosine LR with Warmup

import math

def get_lr(step, warmup_steps, max_steps, max_lr, min_lr):
    # 1) 线性 warmup 阶段
    if step < warmup_steps:
        return max_lr * (step + 1) / warmup_steps
    # 2) 训练结束后保持最小学习率
    if step > max_steps:
        return min_lr
    # 3) Cosine decay 阶段
    decay_ratio = (step - warmup_steps) / (max_steps - warmup_steps)
    assert 0 <= decay_ratio <= 1
    coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))  # 从1衰减到0
    return min_lr + coeff * (max_lr - min_lr)
Cosine Schedule:$\text{lr}(t) = \text{lr}_{min} + \frac{1}{2}(\text{lr}_{max} - \text{lr}_{min})\left(1 + \cos\left(\frac{\pi \cdot t}{T}\right)\right)$

6.3 优化器配置 (Weight Decay 分离)

def configure_optimizers(model, weight_decay, learning_rate, device):
    # 收集所有需要梯度的参数
    param_dict = {pn: p for pn, p in model.named_parameters() if p.requires_grad}
    # 区分需要 weight decay 的参数 (2D 权重矩阵) 和不需要的 (bias, LayerNorm)
    decay_params = [p for n, p in param_dict.items() if p.dim() >= 2]
    nodecay_params = [p for n, p in param_dict.items() if p.dim() < 2]
    optim_groups = [
        {"params": decay_params, "weight_decay": weight_decay},
        {"params": nodecay_params, "weight_decay": 0.0},
    ]
    num_decay = sum(p.numel() for p in decay_params)
    num_nodecay = sum(p.numel() for p in nodecay_params)
    print(f"decay params: {num_decay:,}, no-decay params: {num_nodecay:,}")
    # 使用 fused AdamW (更快的 CUDA 实现)
    use_fused = 'cuda' in device
    optimizer = torch.optim.AdamW(
        optim_groups, lr=learning_rate,
        betas=(0.9, 0.95), eps=1e-8,
        fused=use_fused
    )
    return optimizer
Weight Decay 分离原则

6.4 训练优化清单

# 优化项 效果 代码/设置
1 BF16 混合精度 显存减半,计算加速 torch.autocast(device, dtype=torch.bfloat16)
2 TF32 Tensor Core FP32 运算自动加速 torch.set_float32_matmul_precision('high')
3 Flash Attention 注意力 2-4x 加速 F.scaled_dot_product_attention(is_causal=True)
4 Gradient Accumulation 模拟大 batch 累积 N 步后统一 optimizer.step()
5 DDP 分布式 多 GPU 线性加速 torchrun --nproc_per_node=N
6 Fused AdamW 优化器 kernel 融合 torch.optim.AdamW(..., fused=True)
7 torch.compile 计算图编译优化 model = torch.compile(model)

6.5 DDP 分布式训练 (Distributed Data Parallel)

启动命令

# 单机 8 GPU
torchrun --standalone --nproc_per_node=8 train_gpt2.py

# 多机 (2 nodes x 8 GPUs)
torchrun --nproc_per_node=8 --nnodes=2 --node_rank=0 \
         --master_addr=host0 --master_port=29500 train_gpt2.py

DDP 初始化与梯度同步

import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP

# 初始化进程组
dist.init_process_group(backend='nccl')
ddp_rank = int(os.environ['RANK'])
ddp_local_rank = int(os.environ['LOCAL_RANK'])
ddp_world_size = int(os.environ['WORLD_SIZE'])
device = f'cuda:{ddp_local_rank}'
torch.cuda.set_device(device)
master_process = (ddp_rank == 0)  # 主进程负责 logging/checkpoint

# 包装模型
model = GPT(GPTConfig(vocab_size=50304))
model = model.to(device)
model = DDP(model, device_ids=[ddp_local_rank])
raw_model = model.module  # 访问原始模型

# 训练循环中的梯度同步控制
for micro_step in range(grad_accum_steps):
    # 最后一步才同步梯度,前面的步骤不需要 all-reduce
    if micro_step < grad_accum_steps - 1:
        model.require_backward_grad_sync = False
    else:
        model.require_backward_grad_sync = True
    with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
        logits, loss = model(x, y)
    loss = loss / grad_accum_steps  # 归一化
    loss.backward()
DDP 原理:每个 GPU 持有模型完整副本,数据并行分片。前向独立计算,反向时通过 NCCL All-Reduce 同步梯度,确保所有 GPU 参数一致更新。

7. HellaSwag 评测

7.1 任务描述

HellaSwag (Harder Endings, Longer contexts, and Low-shot Activities for Situations With Adversarial Generations) 是一个常识推理 benchmark:

7.2 评测方法代码

def get_most_likely_row(tokens, mask, logits):
    """
    评测逻辑:计算每个候选续写的平均 token 对数概率,
    选择概率最高的作为模型预测。
    """
    # 将 logits 向左移一位对齐 (next token prediction)
    shift_logits = (logits[..., :-1, :]).contiguous()
    shift_tokens = (tokens[..., 1:]).contiguous()
    # 计算每个位置的 log probability
    flat_shift_logits = shift_logits.view(-1, shift_logits.size(-1))
    flat_shift_tokens = shift_tokens.view(-1)
    shift_losses = F.cross_entropy(
        flat_shift_logits, flat_shift_tokens, reduction='none'
    )
    shift_losses = shift_losses.view(tokens.size(0), -1)
    # 只关注续写部分 (mask=1 的位置)
    shift_mask = (mask[..., 1:]).contiguous()
    masked_shift_losses = shift_losses * shift_mask
    # 计算每个候选的平均 loss
    sum_loss = masked_shift_losses.sum(dim=1)
    avg_loss = sum_loss / shift_mask.sum(dim=1)
    # loss 最小 = 概率最大 → 模型的选择
    pred_idx = avg_loss.argmin().item()
    return pred_idx

7.3 结果对比

模型 参数量 HellaSwag 准确率 备注
GPT-2 (124M) 官方 124M 29.55% HuggingFace 权重
GPT-2 (350M) 官方 350M 37.41% -
GPT-2 (774M) 官方 774M 43.69% -
GPT-2-XL (1558M) 官方 1558M 48.93% -
GPT-3 (175B) 175B 78.9% 论文报告
我们复现 (124M) 124M ~30% 10B tokens 训练
观察:HellaSwag 准确率随参数量和训练数据量的增加而稳定提升,体现了 scaling law 的趋势。124M 模型在 10B tokens 上训练即可接近官方水平。

8. 从验证到全流程

8.1 单 batch 过拟合验证

目的:在正式训练前验证模型实现的正确性。

# 验证流程:用单个 batch 反复训练,loss 应降到接近 0
# 初始 loss 应接近 -ln(1/50257) ≈ 10.82
# Step 0: loss = 10.96 (随机初始化,略高于理论值)
# Step 1: loss = 9.22
# ...
# Step 50: loss = 0.01 (成功过拟合 → 模型实现正确)
训练步 Loss 状态
0 10.96 随机初始化(预期 $\approx -\ln(1/V) \approx 10.82$)
1 9.22 开始学习
10 2.48 快速下降
50 0.01 成功过拟合 (验证通过)
理论初始 loss:如果模型完全随机,对均匀分布 $\text{loss} = -\ln\frac{1}{V} = \ln(50257) \approx 10.82$。实际值 10.96 略高是因为初始权重非完美均匀。

8.2 随机模型 vs 预训练模型对比

指标 随机初始化模型 预训练模型 (GPT-2 124M)
初始 val loss ~10.96 ~3.29
HellaSwag ~25% (接近随机) ~29.55%
生成质量 无意义 token 序列 基本通顺的文本

验证流程总结

  1. 检查初始 loss $\approx \ln(V)$,确认初始化正确
  2. 单 batch 过拟合,确认前向/反向/优化器工作正常
  3. 对比 HuggingFace 预训练权重,确认架构兼容
  4. 启动完整训练,观察 loss 曲线和 HellaSwag 提升

核心要点总结

  1. GPT = Decoder-only Transformer:Pre-Norm + Learned PE + GELU,结构简洁但效果强大
  2. Weight Tying 共享 embedding 和 LM head,节省 ~31% 参数且提升泛化
  3. Flash Attention 通过 IO-aware tiling 将注意力内存从 $O(T^2)$ 降至 $O(T)$,实际加速 2-4x
  4. 训练优化组合拳:BF16 + TF32 + Flash + Gradient Accumulation + DDP + Fused AdamW + torch.compile
  5. Cosine LR + Warmup 是标准训练策略,warmup 避免初期不稳定,cosine 平滑衰减
  6. Weight Decay 只应用于权重矩阵,不应用于 bias 和 LayerNorm 参数
  7. HellaSwag 是评估语言模型常识推理能力的有效 benchmark,体现 scaling law
  8. 验证三步法:初始 loss 检查 → 单 batch 过拟合 → 对比预训练权重,确保实现正确后再全量训练