> **本章定位**:第二部分「构建 (Build)」的起点。前面 10 章解决了「懂原理」,从本章开始解决「写得出来」。目标:读完本章,你能读懂并自己写出 small LLM 的全部代码。本章不引入新模型概念,只补齐动手所需的 PyTorch 工具箱。 :::note 代码类型约定(Build / Systems 章节统一) - 【Runnable】可直接运行(关键逻辑已在本课程验证脚本中实测) - 【Skeleton】工程骨架:逻辑完整,需要自备数据/环境/权重 - 【Pseudo-code】算法示意:用于解释思路,不保证可直接运行 ::: ## 11.1 为什么单独练 PyTorch :::unfold 先懂直觉 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 | :::note 「能读懂」的标准 看到任意一行 LLM 训练代码,你能说出:① 每个张量的 shape;② 这一行在公式上对应什么操作;③ 它跑在哪个 device、什么 dtype。做不到就先别急着读模型代码。 ::: ## 11.2 Tensor:shape / dtype / device :::unfold 先懂直觉 Tensor 就是「带类型的多维数组 + 住在某块设备上」。LLM 里所有东西——输入、参数、激活、梯度——都是 Tensor。读代码第一件事永远是问:**这个 tensor 的 shape、dtype、device 是什么?** ::: ```python 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 | :::warning 最常见的报错来源 **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 代码的重灾区) :::unfold 先懂直觉 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]` | ### 最小数值例子 ```python 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],但值不同! ``` :::math view 的准确条件:stride 兼容,而非「绝对连续」 `transpose`/`permute` 只改「步长」(stride),不搬数据。之后调用 `view` 何时会失败?**当新的形状无法用原 stride 表达时**——转置后的张量通常如此: ```python 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` 更常用,因为它会自动处理不满足条件的情况。 ::: :::fold 工程里怎么用(多头注意力的形状往返,第 7 章实现的完整版) ```python 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) ``` ::: :::shapeflow x [B, S, d] → view → [B, S, H, D] → transpose(1,2) → [B, H, S, D] Q@Kᵀ [B, H, S, S] → softmax → @V [B, H, S, D] → transpose(1,2) → [B, S, H, D] → view → [B, S, d] ::: ## 11.4 广播:不加循环做批量运算 :::unfold 先懂直觉 广播让不同形状的 tensor 直接做逐元素运算:小形状会自动「复制扩展」到大形状。理解它,就能看懂 `scores + mask`、`logits - logits.max(keepdim=True)` 这类常见写法。 ::: ### 三条规则 1. 从**最后一维**开始向左对齐; 2. 两维相等,或其中一维是 1 → 兼容; 3. 不兼容 → 报错。 ```python 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 三个经典用法 ```python # ① 因果掩码: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] ``` :::warning 高频错误 `keepdim=True` 忘写:`scores.max(dim=-1)` 得到 `[B, S]`,减去时会从**最后一维**对齐,变成减错维度(或直接报错)。凡是「按某维做归约、再减回原张量」的场景,几乎都要 `keepdim=True`。 ::: :::math 为什么广播不会浪费显存 PyTorch 的广播是**逻辑扩展**(stride 设为 0),不会真的复制数据。`[4] → [3,4]` 不会分配 3 倍内存。只有结果张量会占用新内存。 ::: ## 11.5 矩阵乘法:matmul / @ / einsum :::unfold 先懂直觉 LLM 就是一堆矩阵乘法。PyTorch 里表达矩阵乘法有三级工具:`@` 最直观、`matmul` 支持批量、`einsum` 能把复杂维度变换写成一行「下标方程」,特别适合检查 shape 是否写对。 ::: ### 三种写法对照 ```python 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 的神器) ```python # 第 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] ``` :::note einsum 的读法 `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` | :::warning 两个易错点 - **`*` 是逐元素乘,不是矩阵乘**:`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:组织模型 :::unfold 先懂直觉 `nn.Module` 就是一个「会记账的层」:任何赋值为属性的 `nn.Parameter` / 子模块都会被自动登记,于是 `model.parameters()` 能一次拿到全部参数交给优化器。LLM 就是把几十个这样的层拼起来。 ::: ### 手写一个 Linear(理解内部) ```python 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 不会被登记,也就不会被优化器更新 | :::fold 工程里怎么用(一个最小 Transformer Block 的骨架) ```python 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 章的手算交给框架 :::unfold 先懂直觉 第 1 章你手算过 $\partial L/\partial w = -12$。PyTorch 的做法是:给参数设 `requires_grad=True`,前向时框架默默记录计算图,调用 `.backward()` 时按链式法则自动算所有梯度,结果存在 `.grad` 里。 ::: ```python 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 ``` :::demo backprop 交互:前向/反向/更新三步走(第 1 章实现) 拖动 x、w、y,点「前向传播 → 反向传播 → 更新参数」,每一步显示具体数字——和 autograd 的结果一一对应。 ::: ### 三条必须记住的规则 | 规则 | 说明 | | --- | --- | | **梯度会累积** | `backward()` 是 `+=`,所以每步前要 `optimizer.zero_grad()` | | **只有叶子节点默认有 `.grad`** | `w.grad` 有值;中间张量要看 `retain_grad()` | | **想断开梯度** | `.detach()` 或 `with torch.no_grad():`(推理、生成标签、RL 采样都要用) | :::warning 训练里最常见的 3 个 autograd 事故 1. **忘记 `zero_grad()`** → 梯度越滚越大,loss 爆炸; 2. **在 `backward()` 后又对同一图调用一次** → `RuntimeError: Trying to backward through the graph a second time`(需要 `retain_graph=True` 或重建图); 3. **在 `no_grad` 区域里构建了需要梯度的计算** → 训练不更新(比如把模型输出存成了好几步之前的旧值)。 ::: ## 11.8 Dataset / DataLoader:喂数据 :::unfold 先懂直觉 LLM 的「数据集」其实很简单:一大串 token id。训练时随机切一段长度为 S 的窗口,喂给模型。(更复杂的文档边界、packing 在数据管线章节讲。) ::: ```python 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] ``` :::note 训练时标签是怎么来的 `y` 不是另存一份数据,而是 `x` **右移一位**:位置 t 的输入预测位置 t+1 的 token。这就是第 9 章讲的 shift 对齐。 ::: ## 11.9 训练循环:标准骨架 :::unfold 先懂直觉 所有 LLM 训练(从 0.1B 玩具到 GPT-4)都是同一个五步循环,多出来的只是分布式和调度细节。 ::: ```python 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 章 | :::fold 工程里怎么用(加学习率调度与梯度累积) ```python 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 :::unfold 先懂直觉 训练要梯度(占显存),推理不要。两行代码的区别能让推理显存降一半、速度快一截。 ::: | 用法 | 作用 | 什么时候用 | | --- | --- | --- | | `model.train()` | 开启 dropout 等训练行为 | 训练前 | | `model.eval()` | 关闭 dropout、BN 用 running stats | 验证/推理前 | | `torch.no_grad()` | 不建计算图、不存中间激活 | 验证/推理/生成标签 | | `torch.inference_mode()` | 更强版本(连版本计数都不记) | 纯推理 | ```python model.eval() with torch.no_grad(): logits = model(x_val) val_loss = F.cross_entropy(logits.view(-1, V), y_val.view(-1)) ``` :::warning eval() 不等于 no_grad() `eval()` 只改层的**行为**(dropout/BN),不省显存;`no_grad()` 才是不建图。验证循环两个都要。 ::: ## 11.11 device / dtype / autocast:进入 GPU 世界 ```python 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") ``` :::note 三个 dtype 何时出现 - **数据/参数**:训练默认 fp32(老代码)或 bf16(新代码); - **计算**:autocast 区域内自动用 bf16/fp16 做矩阵乘; - **token id / label**:必须是整数(int64),永远不要转成浮点。 ::: ## 11.12 state_dict / checkpoint:保存与续训 :::unfold 先懂直觉 `state_dict` 是一个字典:参数名 → 张量。保存它就是保存模型;加载它就是恢复模型。断点续训还要额外保存优化器状态和步数。 ::: ```python # 只保存模型权重(推理用) 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"] ``` :::warning 常见坑 - `map_location`:在 GPU 上存的权重,想在 CPU 上加载时必须写 `map_location="cpu"`; - 保存 `state_dict()` 而不是整个 `model` 对象(后者依赖类定义,跨项目会加载失败); - 恢复后别忘 `model.train()` 和把 `start_step` 接上——否则调度器会从头开始。 ::: ## 11.13 调试清单:shape 断言与最小复现 :::unfold 先懂直觉 LLM 代码的 bug 90% 是 shape 错、device 错、dtype 错。学会「三行断言 + 最小复现」,比会写代码更重要。 ::: ```python # ① 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 逐个核对 | :::key 本章必须记住 | 工具 | 一句话 | | --- | --- | | shape/dtype/device | 读代码第一问 | | view/reshape/transpose/permute | 多头注意力全靠它们 | | 广播 + keepdim=True | 归约后减回原张量 | | einsum 下标规则 | 出现即保留,消失即求和 | | nn.Module | 参数自动登记、forward 写计算 | | zero_grad/backward/step | 训练五件套中的三件 | | no_grad + eval | 验证/推理的标配 | | state_dict | 保存模型 = 保存参数字典 | ::: :::quiz 一行代码 `out = out.transpose(1, 2).contiguous().view(B, S, d)` 中,`contiguous()` 的作用是? A. 加快矩阵乘法 B. 把转置后的数据真正重排为连续内存,使 view 合法 C. 自动复制 batch 维 D. 转为半精度 答案: B 解析: transpose/permute 只改 stride 不搬数据,之后直接 view 会因内存不连续而报错;contiguous() 做一次真实拷贝使内存连续。 ::: :::quiz `scores.max(dim=-1, keepdim=True)` 中 keepdim=True 的作用是? A. 节省显存 B. 保留被归约的维度([B,S,1]),使其能正确广播回 [B,S,S] C. 只取每个 batch 的最大值 D. 加速计算 答案: B 解析: 归约后若不保留维度得到 [B,S],与 [B,S,S] 相减时会从最后一维对齐导致算错或报错;keepdim 保留为 [B,S,1] 才能按行广播。 ::: :::quiz 关于 `optimizer.zero_grad()`,说法正确的是? A. 可选操作,可省略 B. 必须每步调用,因为 PyTorch 的梯度是累加的 C. 只在第一个 step 需要 D. 它会重置模型参数 答案: B 解析: backward() 是梯度累加(+=),不清零会导致梯度滚雪球。梯度累积场景下是每 accum_steps 步清一次,但本质仍是要清零。 ::: :::quiz 为什么验证循环要同时用 `model.eval()` 和 `torch.no_grad()`? A. 两者功能相同,用哪个都行 B. eval() 改变层行为(关 dropout),no_grad() 不建计算图省显存,两者作用不同 C. eval() 更快 D. no_grad() 会关闭 dropout 答案: B 解析: eval() 只改行为(dropout/BN),不影响显存;no_grad() 不构建计算图、不存中间激活,才真正省显存。验证时两个都要。 ::: :::related 依赖 | 第 1 章 反向传播, 第 2 章 优化器, 第 7 章 Attention 用于 | 第 12 章 组件篇 Lab, 第 13 章 训练篇 Lab, 第 16 章 分布式训练 :::