PyTorch for LLM:动手之前的必备工具箱
shape 操作、广播、matmul/einsum、nn.Module、autograd、DataLoader、训练循环、checkpoint。
对应课件:工程前置(新增)
本章定位:第二部分「构建 (Build)」的起点。前面 10 章解决了「懂原理」,从本章开始解决「写得出来」。目标:读完本章,你能读懂并自己写出 small LLM 的全部代码。本章不引入新模型概念,只补齐动手所需的 PyTorch 工具箱。
- 【Runnable】可直接运行(关键逻辑已在本课程验证脚本中实测)
- 【Skeleton】工程骨架:逻辑完整,需要自备数据/环境/权重
- 【Pseudo-code】算法示意:用于解释思路,不保证可直接运行
11.1 为什么单独练 PyTorch
先懂直觉
LLM 的论文公式最终都要落成 PyTorch 代码。但初学者常见的困境是:公式看懂了,代码读不懂——transpose(-2,-1) 是转什么?keepdim=True 为什么必要?loss.backward() 到底写了什么?本章把这些问题一次性讲清,后面 12、13 章的 Lab 才能顺畅跟做。
结论先行——一个 LLM 工程师 90% 的时间只用这 12 个工具:
| 工具 | 一句话用途 | 本章小节 |
|---|---|---|
Tensor 的 shape/dtype/device |
一切数据的载体 | 11.2 |
reshape / view / transpose / permute |
形状变换(attention 的核心操作) | 11.3 |
| 广播机制 | 不加循环做批量运算 | 11.4 |
matmul / @ / einsum |
矩阵乘法与维度缩写 | 11.5 |
nn.Module / Parameter |
组织模型与参数 | 11.6 |
autograd(backward) |
自动求梯度 | 11.7 |
Dataset / DataLoader |
喂数据 | 11.8 |
| 训练循环五件套 | 训练的标准骨架 | 11.9 |
no_grad / eval |
推理模式 | 11.10 |
device / dtype / autocast |
GPU 与精度 | 11.11 |
state_dict / checkpoint |
保存与续训 | 11.12 |
| shape 断言与最小复现 | 调试 | 11.13 |
看到任意一行 LLM 训练代码,你能说出:① 每个张量的 shape;② 这一行在公式上对应什么操作;③ 它跑在哪个 device、什么 dtype。做不到就先别急着读模型代码。
11.2 Tensor:shape / dtype / device
先懂直觉
Tensor 就是「带类型的多维数组 + 住在某块设备上」。LLM 里所有东西——输入、参数、激活、梯度——都是 Tensor。读代码第一件事永远是问:这个 tensor 的 shape、dtype、device 是什么?
import torch
x = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # 从 Python 列表创建
print(x.shape) # torch.Size([2, 2])
print(x.dtype) # torch.float32
print(x.device) # cpu
y = torch.randn(2, 3) # 标准正态
z = torch.zeros(2, 3) # 全 0
w = torch.arange(0, 10, 2) # [0, 2, 4, 6, 8]
e = torch.empty(4, 4) # 未初始化(省时间,但值随机)
x_gpu = x.to("cuda") # 搬到 GPU
x_half = x.to(torch.bfloat16) # 改精度
x_reshaped = x.view(1, 4) # 改形状
三种最常打交道的 dtype
| dtype | 用途 | 显存/参数 |
|---|---|---|
torch.float32 (fp32) |
优化器状态、master weights | 4 bytes |
torch.bfloat16 (bf16) |
前向/反向计算(训练首选) | 2 bytes |
torch.float16 (fp16) |
推理/部分训练(需 loss scaling) | 2 bytes |
torch.int64 (long) |
token id、label、索引 | 8 bytes |
device 不匹配:Expected all tensors to be on the same device —— 模型在 GPU、输入在 CPU(或反过来)。规则:输入和模型必须在同一 device,通常做法是 batch = {k: v.to(device) for k, v in batch.items()}。
11.3 形状变换四件套(LLM 代码的重灾区)
先懂直觉
LLM 代码里 80% 的烧脑行都在做 shape 变换:把 [B, S, d] 拆成多头、把 [B, H, S, D] 转成 [B, S, H, D] 再拼回来。四个函数搞定:reshape、view、transpose、permute。
对比表(背下来)
| 操作 | 作用 | 是否拷贝 | 典型 LLM 用途 |
|---|---|---|---|
reshape |
按新形状重排(不满足 view 条件时自动拷贝) | 视情况(必要时拷贝) | 一般性形状重组 |
view |
要求张量的 stride 与新形状兼容(见下方说明);不满足则报错 | 不拷贝 | 展平 logits:[B,S,V] → [B*S,V] |
transpose(d1,d2) |
只交换两个维度 | 视图(不拷贝,但变不连续) | K.transpose(-2,-1) |
permute(...) |
任意重排所有维度 | 视图(不拷贝,但变不连续) | [B,H,S,D] → [B,S,H,D] |
最小数值例子
x = torch.arange(12).reshape(2, 3, 2) # [2, 3, 2]
print(x.shape) # torch.Size([2, 3, 2])
a = x.transpose(1, 2) # 交换第 1、2 维 → [2, 2, 3]
b = x.permute(2, 0, 1) # 任意重排 → [2, 2, 3]
c = x.reshape(2, 6) # → [2, 6]
d = x.view(6, 2) # → [6, 2](x 连续,可 view)
print(a.shape, b.shape) # 都是 [2, 2, 3],但值不同!
transpose/permute 只改「步长」(stride),不搬数据。之后调用 view 何时会失败?当新的形状无法用原 stride 表达时——转置后的张量通常如此:
x = torch.arange(12).reshape(2, 3, 2)
xt = x.transpose(1, 2) # [2, 2, 3],stride 已改变
xt.view(2, 6) # ❌ RuntimeError: view size is not compatible ...
xt.reshape(2, 6) # ✅ reshape 自动拷贝一份再变形
xt.contiguous().view(2, 6) # ✅ 显式拷贝,然后 view 合法
严格来说,view 要求的是「stride 与新形状兼容」(例如「是否连续」的特殊情况),而不是简单的一条「必须 contiguous」;但实操中记「转置/换维之后不要直接 view,改用 reshape 或先 contiguous」就够用了。LLM 代码里 reshape 更常用,因为它会自动处理不满足条件的情况。
工程里怎么用(多头注意力的形状往返,第 7 章实现的完整版)
B, S, d_model, H = x.size(0), x.size(1), x.size(2), 8
D = d_model // H
# 1) 投影 → [B, S, d_model],拆成多头 → [B, S, H, D]
q = Wq(x).view(B, S, H, D)
# 2) 把 head 维换到前面 → [B, H, S, D],方便批量算注意力
q = q.transpose(1, 2) # 或 permute(0, 2, 1, 3)
k = Wk(x).view(B, S, H, D).transpose(1, 2)
v = Wv(x).view(B, S, H, D).transpose(1, 2)
# 3) 注意力 → [B, H, S, S] @ [B, H, S, D] = [B, H, S, D]
attn = (q @ k.transpose(-2, -1)) / (D ** 0.5)
out = attn.softmax(-1) @ v
# 4) 拼回 → [B, S, d_model]
out = out.transpose(1, 2).contiguous().view(B, S, d_model)
out = Wo(out)
11.4 广播:不加循环做批量运算
先懂直觉
广播让不同形状的 tensor 直接做逐元素运算:小形状会自动「复制扩展」到大形状。理解它,就能看懂 scores + mask、logits - logits.max(keepdim=True) 这类常见写法。
三条规则
- 从最后一维开始向左对齐;
- 两维相等,或其中一维是 1 → 兼容;
- 不兼容 → 报错。
a = torch.randn(3, 4) # [3, 4]
b = torch.randn(4) # [4] → 广播成 [3, 4]
c = a + b # ✅
d = torch.randn(3, 1) # [3, 1] → 广播成 [3, 4]
e = a + d # ✅
f = torch.randn(3) # [3] → 与 [3, 4] 不兼容
# a + f → RuntimeError: The size of tensor a (4) ...
LLM 三个经典用法
# ① 因果掩码:scores [B, S, S] + mask [S, S]
scores = scores + causal_mask # 广播自动处理 B 维
# ② padding mask:scores [B, S, S] + mask [B, 1, S]
scores = scores.masked_fill(pad_mask[:, None, :] == 0, float("-inf"))
# ③ softmax 数值稳定:每行减最大值
scores = scores - scores.max(dim=-1, keepdim=True).values
# keepdim=True 保留 [B, S, 1] 的形状,才能正确广播回 [B, S, S]
keepdim=True 忘写:scores.max(dim=-1) 得到 [B, S],减去时会从最后一维对齐,变成减错维度(或直接报错)。凡是「按某维做归约、再减回原张量」的场景,几乎都要 keepdim=True。
PyTorch 的广播是逻辑扩展(stride 设为 0),不会真的复制数据。[4] → [3,4] 不会分配 3 倍内存。只有结果张量会占用新内存。
11.5 矩阵乘法:matmul / @ / einsum
先懂直觉
LLM 就是一堆矩阵乘法。PyTorch 里表达矩阵乘法有三级工具:@ 最直观、matmul 支持批量、einsum 能把复杂维度变换写成一行「下标方程」,特别适合检查 shape 是否写对。
三种写法对照
A = torch.randn(2, 3, 4) # [B=2, m=3, k=4]
B_ = torch.randn(2, 4, 5) # [B=2, k=4, n=5]
C1 = A @ B_ # [2, 3, 5](批量矩阵乘)
C2 = torch.matmul(A, B_) # 同上
C3 = torch.einsum("bmk,bkn->bmn", A, B_) # 同上,下标显式写出
# 只对最后两维做矩阵乘、前面维度广播:
A4 = torch.randn(2, 3, 4, 5)
B4 = torch.randn(4, 5, 6)
C4 = A4 @ B4 # [2, 3, 4, 6]
用 einsum 重写 Attention(检查 shape 的神器)
# 第 7 章的公式:Attention(Q,K,V) = softmax(QKᵀ/√d)V
# Q,K,V: [B, H, S, D]
scores = torch.einsum("bhsd,bhtd->bhst", Q, K) / (D ** 0.5) # [B,H,S,S]
attn = scores.softmax(dim=-1)
out = torch.einsum("bhst,bhtd->bhsd", attn, V) # [B,H,S,D]
bhsd,bhtd->bhst:两个输入的下标里,d 出现在两个输入中但不在输出里 → 沿 d 求和(点积);s 和 t 分别保留 → 输出 [b,h,s,t]。出现即保留,消失即求和,这就是 einsum 的全部规则。
表格:LLM 里常见的矩阵乘
| 场景 | 形状变化 | 写法 |
|---|---|---|
| 线性投影 | [B,S,d] @ [d,d'] → [B,S,d'] |
x @ W 或 nn.Linear |
| Attention 分数 | [B,H,S,D] @ [B,H,D,S] → [B,H,S,S] |
q @ k.transpose(-2,-1) |
| 加权求和 | [B,H,S,S] @ [B,H,S,D] → [B,H,S,D] |
attn @ v |
| LM Head | [B,S,d] @ [d,V] → [B,S,V] |
x @ W_lm |
| LoRA 低秩更新 | [d,r] @ [r,k] → [d,k] |
B @ A |
*是逐元素乘,不是矩阵乘:A * B要求形状可广播;矩阵乘必须@/matmul/einsum。- batch 维不参与矩阵乘:
@只对最后两维做矩阵乘、前面的维度按广播对齐。所以[B,H,S,D] @ [B,H,D,S]是对的,而[B,H,S,D] @ [D,S]也能工作(B、H 维广播)。
11.6 nn.Module 与 Parameter:组织模型
先懂直觉
nn.Module 就是一个「会记账的层」:任何赋值为属性的 nn.Parameter / 子模块都会被自动登记,于是 model.parameters() 能一次拿到全部参数交给优化器。LLM 就是把几十个这样的层拼起来。
手写一个 Linear(理解内部)
import torch, torch.nn as nn
import math
class MyLinear(nn.Module):
def __init__(self, in_features, out_features):
super().__init__() # 必须调用
self.weight = nn.Parameter(torch.empty(out_features, in_features))
self.bias = nn.Parameter(torch.zeros(out_features))
nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
def forward(self, x): # x: [..., in_features]
return x @ self.weight.t() + self.bias # [..., out_features]
lin = MyLinear(768, 256)
print(sum(p.numel() for p in lin.parameters())) # 768*256 + 256 = 196,864
nn.Module 的三条规则
| 规则 | 说明 |
|---|---|
__init__ 里定义参数和子模块 |
用 self.x = ... 赋值即可自动注册 |
计算写在 forward |
调用时用 model(x)(自动走 hooks),不要直接 .forward(x) |
参数用 nn.Parameter |
普通 tensor 不会被登记,也就不会被优化器更新 |
工程里怎么用(一个最小 Transformer Block 的骨架)
class Block(nn.Module):
def __init__(self, d, h):
super().__init__()
self.norm1 = nn.LayerNorm(d)
self.attn = nn.MultiheadAttention(d, h, batch_first=True)
self.norm2 = nn.LayerNorm(d)
self.ffn = nn.Sequential(
nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d)
)
def forward(self, x):
h = x + self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0]
out = h + self.ffn(self.norm2(h))
return out
model = nn.Sequential(*[Block(256, 8) for _ in range(4)])
print(sum(p.numel() for p in model.parameters()))
11.7 autograd:把第 1 章的手算交给框架
先懂直觉
第 1 章你手算过 。PyTorch 的做法是:给参数设 requires_grad=True,前向时框架默默记录计算图,调用 .backward() 时按链式法则自动算所有梯度,结果存在 .grad 里。
import torch
x = torch.tensor(2.0)
y = torch.tensor(5.0)
w = torch.tensor(1.0, requires_grad=True) # 需要求导
z = w * x # 前向(自动记录计算图)
L = (z - y) ** 2
L.backward() # 反向:自动微分
print(w.grad) # tensor(-12.) ← 与第 1 章手算完全一致
手动对照:计算图长这样
w ──×── z = wx ──(z−y)²── L
∂L/∂w ← ∂L/∂w = ∂L/∂z · ∂z/∂w
= 2(z−y) · x = (−6)·2 = −12
拖动 x、w、y,点「前向传播 → 反向传播 → 更新参数」,每一步显示具体数字——和 autograd 的结果一一对应。
三条必须记住的规则
| 规则 | 说明 |
|---|---|
| 梯度会累积 | backward() 是 +=,所以每步前要 optimizer.zero_grad() |
只有叶子节点默认有 .grad |
w.grad 有值;中间张量要看 retain_grad() |
| 想断开梯度 | .detach() 或 with torch.no_grad():(推理、生成标签、RL 采样都要用) |
- 忘记
zero_grad()→ 梯度越滚越大,loss 爆炸; - 在
backward()后又对同一图调用一次 →RuntimeError: Trying to backward through the graph a second time(需要retain_graph=True或重建图); - 在
no_grad区域里构建了需要梯度的计算 → 训练不更新(比如把模型输出存成了好几步之前的旧值)。
11.8 Dataset / DataLoader:喂数据
先懂直觉
LLM 的「数据集」其实很简单:一大串 token id。训练时随机切一段长度为 S 的窗口,喂给模型。(更复杂的文档边界、packing 在数据管线章节讲。)
from torch.utils.data import Dataset, DataLoader
class TokenDataset(Dataset):
"""data.bin:把整个语料存成一串 uint16 token id"""
def __init__(self, path, seq_len):
import numpy as np
self.data = np.memmap(path, dtype=np.uint16, mode="r")
self.seq_len = seq_len
def __len__(self):
# 合法起点 i 满足 i+1+seq_len <= N,即 i ∈ [0, N-seq_len-1],共 N-seq_len 个样本
return len(self.data) - self.seq_len
def __getitem__(self, i):
x = torch.from_numpy(self.data[i:i + self.seq_len].astype("int64"))
y = torch.from_numpy(self.data[i + 1:i + 1 + self.seq_len].astype("int64"))
return x, y # 输入与「右移一位」的标签
# 自测:N=10、S=4 时应恰好 6 个样本,最后一个样本是 x=data[5:9], y=data[6:10]
import numpy as np
arr = np.arange(10, dtype=np.uint16)
arr.tofile("/tmp/tiny.bin")
tiny = TokenDataset("/tmp/tiny.bin", seq_len=4)
assert len(tiny) == 6, len(tiny)
x_last, y_last = tiny[5]
assert x_last.tolist() == [5, 6, 7, 8] and y_last.tolist() == [6, 7, 8, 9]
ds = TokenDataset("data.bin", seq_len=512)
loader = DataLoader(ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)
x, y = next(iter(loader))
print(x.shape, y.shape) # [32, 512] [32, 512]
y 不是另存一份数据,而是 x 右移一位:位置 t 的输入预测位置 t+1 的 token。这就是第 9 章讲的 shift 对齐。
11.9 训练循环:标准骨架
先懂直觉
所有 LLM 训练(从 0.1B 玩具到 GPT-4)都是同一个五步循环,多出来的只是分布式和调度细节。
model.train()
for step, (x, y) in enumerate(loader):
x, y = x.to(device), y.to(device)
logits = model(x) # ① 前向: [B,S,V]
loss = F.cross_entropy( # ② 算损失
logits.view(-1, logits.size(-1)),
y.view(-1),
)
optimizer.zero_grad(set_to_none=True) # ③ 清梯度
loss.backward() # ④ 反向:算梯度
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪
optimizer.step() # ⑤ 更新参数
if step % 50 == 0:
print(f"step {step} loss {loss.item():.4f}")
五个零件各自的角色
| 零件 | 作用 | 相关章节 |
|---|---|---|
model(x) |
前向传播 | 第 9 章 |
F.cross_entropy |
next token 的损失 | 第 1 章 |
zero_grad |
清空上一步梯度(autograd 是累加的) | 第 2 章 |
backward |
自动微分 | 第 1 章 |
step |
优化器更新(AdamW) | 第 2 章 |
clip_grad_norm_ |
防梯度爆炸 | 第 4 章 |
工程里怎么用(加学习率调度与梯度累积)
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) # warmup+cosine
accum_steps = 8
for step, (x, y) in enumerate(loader):
logits = model(x)
loss = F.cross_entropy(logits.view(-1, V), y.view(-1)) / accum_steps
loss.backward() # 梯度在 .grad 里累积
if (step + 1) % accum_steps == 0: # 攒够 8 个 micro-batch 才更新
clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step()
optimizer.zero_grad(set_to_none=True)
梯度累积 = 用小显存模拟大 batch(见第 17 章)。
11.10 no_grad / eval / inference_mode
先懂直觉
训练要梯度(占显存),推理不要。两行代码的区别能让推理显存降一半、速度快一截。
| 用法 | 作用 | 什么时候用 |
|---|---|---|
model.train() |
开启 dropout 等训练行为 | 训练前 |
model.eval() |
关闭 dropout、BN 用 running stats | 验证/推理前 |
torch.no_grad() |
不建计算图、不存中间激活 | 验证/推理/生成标签 |
torch.inference_mode() |
更强版本(连版本计数都不记) | 纯推理 |
model.eval()
with torch.no_grad():
logits = model(x_val)
val_loss = F.cross_entropy(logits.view(-1, V), y_val.view(-1))
eval() 只改层的行为(dropout/BN),不省显存;no_grad() 才是不建图。验证循环两个都要。
11.11 device / dtype / autocast:进入 GPU 世界
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
# 混合精度:前向/反向用 bf16,主权重仍是 fp32(第 17 章细讲)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits = model(x)
loss = F.cross_entropy(logits.view(-1, V), y.view(-1))
loss.backward()
# 查看显存
print(torch.cuda.memory_allocated() / 1e9, "GB")
print(torch.cuda.max_memory_allocated() / 1e9, "GB")
- 数据/参数:训练默认 fp32(老代码)或 bf16(新代码);
- 计算:autocast 区域内自动用 bf16/fp16 做矩阵乘;
- token id / label:必须是整数(int64),永远不要转成浮点。
11.12 state_dict / checkpoint:保存与续训
先懂直觉
state_dict 是一个字典:参数名 → 张量。保存它就是保存模型;加载它就是恢复模型。断点续训还要额外保存优化器状态和步数。
# 只保存模型权重(推理用)
torch.save(model.state_dict(), "model.pt")
model.load_state_dict(torch.load("model.pt", map_location="cpu"))
# 断点续训 checkpoint(训练用)
ckpt = {
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"scheduler": scheduler.state_dict(),
"step": step,
}
torch.save(ckpt, f"ckpt_{step}.pt")
# 恢复
ckpt = torch.load("ckpt_1000.pt", map_location=device)
model.load_state_dict(ckpt["model"])
optimizer.load_state_dict(ckpt["optimizer"])
scheduler.load_state_dict(ckpt["scheduler"])
start_step = ckpt["step"]
map_location:在 GPU 上存的权重,想在 CPU 上加载时必须写map_location="cpu";- 保存
state_dict()而不是整个model对象(后者依赖类定义,跨项目会加载失败); - 恢复后别忘
model.train()和把start_step接上——否则调度器会从头开始。
11.13 调试清单:shape 断言与最小复现
先懂直觉
LLM 代码的 bug 90% 是 shape 错、device 错、dtype 错。学会「三行断言 + 最小复现」,比会写代码更重要。
# ① shape 断言:把期望写出来,让错误尽早暴露
assert x.shape == (B, S), f"expected [B,S], got {x.shape}"
assert q.shape == (B, H, S, D)
# ② device / dtype 断言
assert x.device == model.device or x.device.type == "cuda"
assert y.dtype == torch.long
# ③ 最小复现:造 2 个样本、3 个 token、8 维的小输入
x = torch.randint(0, 100, (2, 3)).to(device)
常见报错对照表(收藏)
| 报错 | 原因 | 修法 |
|---|---|---|
Expected all tensors on same device |
输入没 .to(device) |
统一 device |
view size is not compatible with stride |
转置后直接 view | 先 .contiguous() 或改 reshape |
Trying to backward through the graph a second time |
重复 backward | 重建前向或 retain_graph=True |
CUDA out of memory |
batch 太大 | 降 batch / 梯度累积 / 看第 17 章 |
Expected Long but got Float |
label 是浮点 | y.long() |
RuntimeError: mat1 and mat2 shapes cannot be multiplied |
矩阵乘维度不匹配 | 打印 shape 逐个核对 |
| 工具 | 一句话 |
|---|---|
| shape/dtype/device | 读代码第一问 |
| view/reshape/transpose/permute | 多头注意力全靠它们 |
| 广播 + keepdim=True | 归约后减回原张量 |
| einsum 下标规则 | 出现即保留,消失即求和 |
| nn.Module | 参数自动登记、forward 写计算 |
| zero_grad/backward/step | 训练五件套中的三件 |
| no_grad + eval | 验证/推理的标配 |
| state_dict | 保存模型 = 保存参数字典 |
out = out.transpose(1, 2).contiguous().view(B, S, d) 中,contiguous() 的作用是?显示答案与解析
正确答案:B
transpose/permute 只改 stride 不搬数据,之后直接 view 会因内存不连续而报错;contiguous() 做一次真实拷贝使内存连续。scores.max(dim=-1, keepdim=True) 中 keepdim=True 的作用是?显示答案与解析
正确答案:B
归约后若不保留维度得到 [B,S],与 [B,S,S] 相减时会从最后一维对齐导致算错或报错;keepdim 保留为 [B,S,1] 才能按行广播。optimizer.zero_grad(),说法正确的是?显示答案与解析
正确答案:B
backward() 是梯度累加(+=),不清零会导致梯度滚雪球。梯度累积场景下是每 accum_steps 步清一次,但本质仍是要清零。model.eval() 和 torch.no_grad()?显示答案与解析
正确答案:B
eval() 只改行为(dropout/BN),不影响显存;no_grad() 不构建计算图、不存中间激活,才真正省显存。验证时两个都要。