---
title: "第 14 课:混合精度与综合面试"
jupyter: python3
---
- 对应官方章节:Mixed precision training、Conclusion(+ 第 1 课账本回收)
- 前置:全部课程
- 状态:未开始
## 本课目标
完成后你需要能够:
- 说出 FP32/FP16/BF16/FP8(e4m3, e5m2) 的位布局、范围与精度(epsilon),解释"范围"与"精度"的取舍。
- 解释 FP16 训练失败的机制与三个修复:FP32 master weights、loss scaling、FP32 累积。
- 说明 FP8 训练的做法(per-tile 量化)、难点(稳定性)与各方案的显存账本。
- 独立完成一道"训练系统设计 + 精度选择"综合面试题。
## 核心概念
### 1. 现代浮点格式拓扑与动态范围 vs 精度权衡(如**图 14-1** 所示)
```{=html}
<div class="fd fd-flow" role="img"><div class="fd-stack"><div class="fd-comp fd-td"><div class="fd-sg" data-nid="FloatFormats" data-depth="0"><div class="fd-sg-title">主流深度学习浮点格式位宽构成</div><div class="fd-stack"><div class="fd-comp fd-td"><div class="fd-rank fd-few"><div class="fd-node fd-box" data-nid="FP32"><b>FP32 (单精度 32-bit)</b><br>1 符号 + 8 指数 + 23 尾数<br>动态范围 10^-38 ~ 10^38 (基准)</div><div class="fd-node fd-box" data-nid="FP16"><b>FP16 (半精度 16-bit)</b><br>1 符号 + 5 指数 + 10 尾数<br>范围仅 65504 (梯度易下溢)</div><div class="fd-node fd-box" data-nid="BF16"><b>BF16 (Brain Float 16-bit)</b><br>1 符号 + 8 指数 + 7 尾数<br>动态范围与 FP32 相同 (主流)</div><div class="fd-node fd-box" data-nid="FP8"><b>FP8 (极窄位宽 8-bit)</b><br>E4M3 / E5M2 两种微结构<br>Hopper/Blackwell 计算新宠</div></div></div></div></div></div></div></div>
```
<p class="caption" align="center"><em>图 14-1:现代深度学习主流浮点数值格式位宽与动态范围对比</em></p>
在现代大模型软硬件协同体系中,数值精度的选择直接决定了矩阵乘法单元(Tensor Core)的计算吞吐量与显存占用上限。
IEEE 754 标准浮点数由三个字段定义:**符号位(Sign Bit $s$)**、**指数位(Exponent $e$)**与**尾数位(Mantissa / Fraction $m$)**:
$$v = (-1)^s \times 2^{e - \text{bias}} \times \left(1 + \sum_{i=1}^m b_i 2^{-i}\right)$$
各主流精度的位布局与关键数值特性如下表所示:
| 格式名称 | 总位宽 | 符号位 | 指数位 ($e$) | 尾数位 ($m$) | 最大表示范围 ($V_{\max}$) | 最小正规格数 ($V_{\min}$) | 精度刻度(1.0 处的 ULP / $\epsilon$) |
|---|---|---|---|---|---|---|---|
| **FP32 (单精度)** | 32 位 (4B) | 1 | 8 位 | 23 位 | $\approx 3.4 \times 10^{38}$ | $\approx 1.18 \times 10^{-38}$ | $2^{-23} \approx 1.19 \times 10^{-7}$ |
| **FP16 (半精度)** | 16 位 (2B) | 1 | 5 位 | 10 位 | $\mathbf{65,504}$ | $\approx 6.10 \times 10^{-5}$ | $2^{-10} \approx 9.77 \times 10^{-4}$ |
| **BF16 (Brain Float)** | 16 位 (2B) | 1 | 8 位 | 7 位 | $\approx 3.39 \times 10^{38}$ | $\approx 1.18 \times 10^{-38}$ | $2^{-7} \approx 7.81 \times 10^{-3}$ |
| **FP8 E4M3** | 8 位 (1B) | 1 | 4 位 | 3 位 | $448$ (E4M3FN) | $2^{-6} \approx 1.56 \times 10^{-2}$(最小次正规数 $2^{-9} \approx 1.95 \times 10^{-3}$) | $2^{-3} = 0.125$ |
| **FP8 E5M2** | 8 位 (1B) | 1 | 5 位 | 2 位 | $57,344$ | $\approx 6.10 \times 10^{-5}$ | $2^{-2} = 0.25$ |
#### 深度工程洞见:为什么工业界全面拥抱 BF16 而淘汰原生 FP16?
- **FP16 的梯度下溢问题**:FP16 仅有 5 位指数,其最小正规化数仅为 $6.1 \times 10^{-5}$。但在百亿/千亿大模型反向传播中,深层参数的梯度方差通常在 $10^{-6} \sim 10^{-8}$ 之间。这导致梯度在链式法则回传时极易直接截断下溢为零(Underflow),导致网络前浅层梯度更新完全消失。
- **BF16 的取舍哲学**:Google 提出的 BF16 牺牲了 3 位尾数精度,直接借用了 **FP32 完整的 8 位指数域**。这意味着 BF16 的动态范围与 FP32 丝毫不差(覆盖超过 80 个数量级)。
在预训练中,梯度下溢和溢出的概率大幅降低,通常不再需要损失缩放(Loss Scaling),训练更稳定。
### 2. 混合精度训练的三大支柱与动态损失缩放
虽然低精度能带来巨大的速度加成,但直接在全网采用 16 位浮点更新必然导致训练发散崩溃。经典的 Mixed Precision 训练架构依靠三大支柱确保数值收敛与精度无损:
#### 支柱一:FP32 主权重镜像(Master Weights)消灭更新吞噬(ULP Swallowing)
这是混合精度训练中最经典的物理现象:
- 设当前参数权重量级为 $w = 1.0$。在 BF16 格式下,1.0 处末位有效数字的单位精度(Unit in the Last Place, ULP)为 $\Delta_{\text{ULP}} = 2^{-7} \approx 7.81 \times 10^{-3}$。
- 设当前学习率 $\eta = 10^{-4}$,优化器计算出的反向梯度为 $g = 0.5$。单步实际参数更新量应为:
$$\Delta w = -\eta \cdot g = -5 \times 10^{-5}$$
- 若直接在 BF16 上就地累加:
$$w_{\text{new}} = 1.0 - 0.00005 \xrightarrow{\text{BF16 舍入}} 1.0$$
因为更新量 $\Delta w$ 远小于 BF16 当前精度的半个 ULP,**更新值在浮点舍入时被吸收**。若每步都如此,参数不会变化,损失无法下降。
- **解决方案**:必须在优化器显存中常驻一份 **FP32 Master 权重**。前向与反向计算使用 BF16 算子跑高速 GEMM,但在 `optimizer.step()` 时,将梯度累加到 FP32 Master 权重上。FP32 的 ULP 高达 $1.19 \times 10^{-7}$,足以精准记录微小的参数累积演化,步结时再截断投影回 BF16 供下一步使用。
#### 支柱二:动态损失缩放(Dynamic Loss Scaling,如**图 14-2** 所示)
```{=html}
<div class="fd fd-seq" role="img"><div class="sd-grid" style="--n:5"><div class="sd-life" style="grid-column:1;grid-row:1/12"></div><div class="sd-life" style="grid-column:2;grid-row:1/12"></div><div class="sd-life" style="grid-column:3;grid-row:1/12"></div><div class="sd-life" style="grid-column:4;grid-row:1/12"></div><div class="sd-life" style="grid-column:5;grid-row:1/12"></div><div class="sd-actor" style="grid-column:1;grid-row:1">计算 Loss</div><div class="sd-actor" style="grid-column:2;grid-row:1">损失缩放因子 (Scale S)</div><div class="sd-actor" style="grid-column:3;grid-row:1">反向梯度计算 (BF16/FP16)</div><div class="sd-actor" style="grid-column:4;grid-row:1">溢出检查 (Inf/NaN 检测)</div><div class="sd-actor" style="grid-column:5;grid-row:1">优化器更新 (FP32 主权重)</div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:1/3;grid-row:2;--k:2"><div class="sd-text"><span class="sd-num">1</span>乘以 Scale (放大梯度)</div><div class="sd-line"></div></div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:2/4;grid-row:3;--k:2"><div class="sd-text"><span class="sd-num">2</span>反向传播计算放大后的梯度 g_scaled</div><div class="sd-line"></div></div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:3/5;grid-row:4;--k:2"><div class="sd-text"><span class="sd-num">3</span>扫描梯度张量是否有 Inf/NaN</div><div class="sd-line"></div></div><div class="sd-frame" style="grid-column:1/6;grid-row:5"><span class="sd-kw">alt</span>存在溢出 (Overflow)</div><div class="sd-msg sd-r sd-h-arrow sd-dash" style="grid-column:4/6;grid-row:6;--k:2"><div class="sd-text"><span class="sd-num">4</span>跳过本步优化器更新 (Skip Step)</div><div class="sd-line"></div></div><div class="sd-msg sd-l sd-h-arrow" style="grid-column:2/5;grid-row:7;--k:3"><div class="sd-text"><span class="sd-num">5</span>动态减小 Scale (如 S = S / 2)</div><div class="sd-line"></div></div><div class="sd-frame-else" style="grid-column:1/6;grid-row:8"><span class="sd-kw">else</span>无溢出 (Finite)</div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:4/6;grid-row:9;--k:2"><div class="sd-text"><span class="sd-num">6</span>反缩放 g = g_scaled / S -> 正常更新参数</div><div class="sd-line"></div></div><div class="sd-msg sd-l sd-h-arrow" style="grid-column:2/5;grid-row:10;--k:3"><div class="sd-text"><span class="sd-num">7</span>连续 N 步正常 -> 放大 Scale (如 S = S * 2)</div><div class="sd-line"></div></div><div class="sd-frame-end" style="grid-column:1/6;grid-row:11"></div></div></div>
```
<p class="caption" align="center"><em>图 14-2:动态损失缩放(Dynamic Loss Scaling)溢出检测与自适应更新时序流</em></p>
在不得不使用 FP16 的老旧硬件(如 V100)上,必须引入自适应损失缩放器(Loss Scaler):
1. **反向前放大**:在反向传播启动前,将标量损失乘以一个巨大的缩放系数 $S$(如 $S = 2^{15} = 32768$):$\mathcal{L}_{\text{scaled}} = \mathcal{L} \times S$;
2. **链式法则伴随放大**:反向传播计算出的所有梯度张量被等比放大 $S$ 倍,强行推入 FP16 的正规表示区间,规避下溢;
3. **溢出检测与缩放退避**:在优化器执行更新前,检查全网梯度是否包含 `Inf` 或 `NaN`。若发生溢出,说明 $S$ 过大导致高位被击穿,立即放弃本步更新,并将 $S$ 减半($S \leftarrow S / 2$);若连续 $N$ 步无溢出,则尝试增大缩放因子($S \leftarrow S \times 2$)。
#### 支柱三:敏感算子的 FP32 强制累加与约简
Softmax 的分母指数累加 $\sum \exp(z_i)$、LayerNorm 的均值与方差统计量、以及梯度累积(Gradient Accumulation)跨步累加,必须强制升精至 FP32 计算,最后再 Cast 回低精度。
### 3. 前沿 FP8 训练范式与多精度显存账本(DeepSeek-V3 核心工程)
随着 NVIDIA Hopper(H100/H800)架构的普及,Tensor Core 提供了高达 BF16 两倍吞吐的 **FP8 GEMM 计算能力**。
#### A. FP8 混合搭配策略(E4M3 vs E5M2)
- **Transformer Engine 的 hybrid 方案**:前向 GEMM 的激活与权重用 **E4M3**(3 位尾数,精度更高);反向的梯度用 **E5M2**(5 位指数,范围更大,适合动态范围大的梯度)。
- **DeepSeek-V3 的方案**:所有 GEMM 输入(包括反向的梯度)统一使用 **E4M3**,用下面的细粒度分块缩放弥补动态范围不足,从而在所有张量上获得更高精度。
#### B. 细粒度分块量化(Tile-wise Fine-Grained Scaling)
FP8 的最大动态范围极窄(E4M3 仅 448)。若采用全张量(Tensor-wise)量化,异常离群点(Outliers)会使整张矩阵精度崩溃。
DeepSeek-V3 提出了**细粒度分块量化**:
- 对激活张量按 $1 \times 128$ 瓦片(Token 内部特征向量)独立求缩放系数;
- 对权重矩阵按 $128 \times 128$ 空间瓦片独立求缩放系数;
- 配合专用的 FP8 算子,在 Hopper 架构上实现了几乎无损的 671B 庞大参数预训练。
#### C. 主流混合精度方案静态显存账本对比(每参数字节数 Bytes/param)
| 训练方案与精度配置 | GEMM 精度 | Master 权重 | 累积梯度 | 存储权重 | 存储梯度 | 优化器状态 (AdamW) | 单参数静态总显存 |
|---|---|---|---|---|---|---|---|
| **传统 BF16(含 FP32 梯度累积)** | BF16 (2B) | FP32 (4B) | FP32 (4B) | BF16 (2B) | BF16 (2B) | FP32 $m$ + FP32 $v$ (8B) | **20 Bytes / param** |
| **标准 BF16(无 FP32 梯度累积)** | BF16 (2B) | FP32 (4B) | — (0B) | BF16 (2B) | BF16 (2B) | FP32 $m$ + FP32 $v$ (8B) | **16 Bytes / param** |
| **Transformer Engine FP8** | FP8 (1B) | — (0B) | — (0B) | FP32 (4B) | FP32 (4B) | FP32 $m$ + FP32 $v$ (8B) | **16 Bytes / param** |
| **DeepSeek-V3 工业级 FP8 方案** | FP8 (1B) | FP32 (4B) | FP32 (4B) | FP8 (1B) | BF16 (2B) | BF16 $m$ + BF16 $v$ (4B) | **15 Bytes / param** |
| **极致极简 FP8 (FP8-LM O3)** | FP8 (1B) | FP16 (2B) | FP16 (2B) | FP8 (1B) | FP8 (1B) | FP8 $m$ + FP16 $v$ (3B) | **9 Bytes / param** |
*核心启示*:**计算精度(GEMM Precision)与存储精度(Storage Precision)是两个完全解耦的维度**。FP8 训练的核心红利是直接将 Tensor Core 翻倍加速,而静态显存能否降低,完全取决于 Master 权重与优化器状态的格式抉择。
## 具体演示与数值推演
以一个在 BF16 精度下的模型参数更新步骤为例,设当前权重 $w = 1.0$,反向求导计算所得的更新量为 $\Delta = 1 \times 10^{-3}$(即 $0.001$):
- **在 BF16 下的舍入吸收**:
BF16 在基准点 $1.0$ 处的尾数有效位为 7 位,其最小分辨精度为:
$$\Delta_{\text{ULP}} = 2^{-7} = \frac{1}{128} \approx 0.0078125$$
由于更新量 $\Delta = 0.001 < \frac{1}{2} \Delta_{\text{ULP}} \approx 0.0039$,在浮点数四舍六入五成双(Round to Nearest Even)规则下:
$$\text{bf16}(1.0 + 0.001) \equiv 1.0$$
本次更新没有生效。
- **引入 FP32 Master 权重镜像**:
FP32 在 $1.0$ 处的末位精度为:
$$\Delta_{\text{ULP\_FP32}} = 2^{-23} \approx 1.192 \times 10^{-7}$$
更新量 $\Delta = 0.001$ 是其最小刻度的 $8388$ 倍。在 FP32 Master 权重上:
$$w_{\text{master}} = 1.0 + 0.001 = 1.001000047 \dots$$
单步更新无法在 BF16 中体现,但累积 4 步后 Master 权重为 $1.004$,超过 $1.0 + \frac{1}{2}\Delta_{\text{ULP}} = 1.00390625$,Cast 回 BF16 时按就近舍入得到 $1.0078125$。这就是混合精度中“低精度负责前向反向计算,高精度负责记账”的分工。
## 代码填空题
实现浮点格式属性计算 + 低精度更新吸收模拟 + 各方案显存账本。
```{python}
#| course_role: exercise
import torch
def float_props(exp_bits: int, mantissa_bits: int) -> tuple[float, float, float]:
"""
返回 (max_value, min_normal, epsilon)。
教学用 IEEE-like 常规格式(忽略 subnormal、NaN/Inf 编码与 E4M3FN 等具体 FP8 变体):
bias = 2^(exp_bits-1) - 1
max_value = (2 - 2^-mantissa_bits) * 2^bias
min_normal= 2^(1 - bias)
epsilon = 2^-mantissa_bits (1.0 之后第一个可表示数)
"""
bias = 2 ** (exp_bits - 1) - 1
max_value = ______ # 填空
min_normal = ______ # 填空
epsilon = ______ # 填空
return max_value, min_normal, epsilon
FORMATS = {
"fp32": (8, 23),
"fp16": (5, 10),
"bf16": (8, 7),
"fp8_e4m3": (4, 3),
"fp8_e5m2": (5, 2),
}
def show_formats():
print(f"{'format':10s} {'max':>12s} {'min_normal':>12s} {'epsilon':>12s}")
for name, (e, m) in FORMATS.items():
mx, mn, eps = float_props(e, m)
print(f"{name:10s} {mx:12.3e} {mn:12.3e} {eps:12.3e}")
def simulate_absorption(w: float, update: float, dtype: torch.dtype) -> tuple[float, float, bool]:
"""
模拟低精度存储下的权重更新:w += update。
返回 (更新前的低精度值, 更新后的低精度值, 是否被吸收)。
用 torch 的低精度 dtype 直接做舍入(CPU 也支持 fp16/bf16)。
"""
w_low = torch.tensor(w, dtype=dtype).float().item()
w_new = (torch.tensor(w, dtype=dtype) + torch.tensor(update, dtype=dtype)).float().item()
absorbed = ______ # 填空:更新后与更新前是否相同
return w_low, w_new, absorbed
def fp8_memory_ledger() -> dict[str, int]:
"""
教材表格:各方案每参数 bytes 的分量(顺序:master / 累积梯度 / 权重 / 梯度 / 优化器状态)。
BF16 基线(含 FP32 梯度累积)另有 GEMM 精度说明,这里只计存储分量。
返回 {方案名: 每参数总 bytes}。
"""
schemes = {
"bf16_mp_no_fp32acc": dict(master=4, acc_grad=0, w=2, g=2, opt=8), # = 16
"bf16_mp_fp32acc": dict(master=4, acc_grad=4, w=2, g=2, opt=8), # = 20
"transformer_engine": dict(master=0, acc_grad=0, w=4, g=4, opt=8), # = 16
"fp8lm_o3": dict(master=2, acc_grad=2, w=1, g=1, opt=3), # = 9
"deepseek_v3": dict(master=4, acc_grad=4, w=1, g=2, opt=4), # = 15
"nanotron_fp8": dict(master=2, acc_grad=4, w=1, g=1, opt=2), # = 10
}
totals = {}
for name, comp in schemes.items():
totals[name] = ______ # 填空:各分量求和
return totals
if __name__ == "__main__":
show_formats()
print("\n权重更新吸收演示(w=1.0):")
for dt, name in ((torch.float32, "fp32"), (torch.float16, "fp16"), (torch.bfloat16, "bf16")):
for upd in (1e-3, 1e-5):
before, after, absorbed = simulate_absorption(1.0, upd, dt)
print(f" {name:5s} update={upd:8.1e} -> w={after:.6f} 吸收={absorbed}")
print("\n各方案每参数 bytes:")
for name, total in fp8_memory_ledger().items():
print(f" {name:22s} {total} bytes")
```
## 三个问答题
### Q1
BF16 与 FP16 同为 16 位,为什么 BF16 成为预训练默认?请用"范围"与"精度"两个维度回答,并解释为什么"BF16 范围与 FP32 相同"不能推出"BF16 可以替代 FP32"(给出 epsilon 对比)。什么场景下 FP16 反而比 BF16 合适(提示:数值都小且需要精度时)?
### Q2
详细解释低精度权重更新被 ULP 吸收的机制,并说明为什么“变成 0 就永远回不来”并不准确。然后说明三个修复各自作用于链条的哪一环:FP32 master weights、loss scaling、FP32 累积。
### Q3
综合面试题(口述/写作):某公司要在 8×H100 节点上训练 30B 稠密模型,seq=8192,目标 MFU ≥ 40%,预算只允许 1 个节点。请给出:(1) 并行配置与理由;(2) 精度方案(BF16 还是 FP8)与显存账本估算;(3) 若改用 FP8,哪些组件必须保持高精度,为什么;(4) 一个你会在训练前做的 benchmark/验证实验。请用本课程所有相关结论作答。
## 检查与通过标准
总分 10 分:代码正确 4 分(格式属性、吸收判断、账本求和)、三题各 2 分、通过线 8 分。
一票否决项:
- 认为 BF16 精度与 FP32 相当。
- 说不清 master weights 解决的具体失败机制(更新被 ulp 吸收)。
- 认为 FP8 只是"更小的 BF16",不知道需要量化与稳定性问题。
- 综合题中给出自相矛盾的配置(如 TP 跨节点、gbs 公式错误、静态账本漏项)。
::: {.callout-tip collapse="true" title="💡 点击展开参考答案与详细代码实现" icon="false"}
## 参考答案(仅 answer 分支)
先完成题目再核对;核对后必须解释关键公式,并改一个规模重新计算。
```{python}
#| course_role: solution
import torch
def float_props(exp_bits: int, mantissa_bits: int) -> tuple[float, float, float]:
"""
返回 (max_value, min_normal, epsilon)。
教学用 IEEE-like 常规格式(忽略 subnormal、NaN/Inf 编码与 E4M3FN 等具体 FP8 变体):
bias = 2^(exp_bits-1) - 1
max_value = (2 - 2^-mantissa_bits) * 2^bias
min_normal= 2^(1 - bias)
epsilon = 2^-mantissa_bits (1.0 之后第一个可表示数)
"""
bias = 2 ** (exp_bits - 1) - 1
max_value = (2 - 2 ** (-mantissa_bits)) * 2 ** bias # 填空
min_normal = 2 ** (1 - bias) # 填空
epsilon = 2 ** (-mantissa_bits) # 填空
return max_value, min_normal, epsilon
FORMATS = {
"fp32": (8, 23),
"fp16": (5, 10),
"bf16": (8, 7),
"fp8_e4m3": (4, 3),
"fp8_e5m2": (5, 2),
}
def show_formats():
print(f"{'format':10s} {'max':>12s} {'min_normal':>12s} {'epsilon':>12s}")
for name, (e, m) in FORMATS.items():
mx, mn, eps = float_props(e, m)
print(f"{name:10s} {mx:12.3e} {mn:12.3e} {eps:12.3e}")
def simulate_absorption(w: float, update: float, dtype: torch.dtype) -> tuple[float, float, bool]:
"""
模拟低精度存储下的权重更新:w += update。
返回 (更新前的低精度值, 更新后的低精度值, 是否被吸收)。
用 torch 的低精度 dtype 直接做舍入(CPU 也支持 fp16/bf16)。
"""
w_low = torch.tensor(w, dtype=dtype).float().item()
w_new = (torch.tensor(w, dtype=dtype) + torch.tensor(update, dtype=dtype)).float().item()
absorbed = w_new == w_low # 填空:更新后与更新前是否相同
return w_low, w_new, absorbed
def fp8_memory_ledger() -> dict[str, int]:
"""
教材表格:各方案每参数 bytes 的分量(顺序:master / 累积梯度 / 权重 / 梯度 / 优化器状态)。
BF16 基线(含 FP32 梯度累积)另有 GEMM 精度说明,这里只计存储分量。
返回 {方案名: 每参数总 bytes}。
"""
schemes = {
"bf16_mp_no_fp32acc": dict(master=4, acc_grad=0, w=2, g=2, opt=8), # = 16
"bf16_mp_fp32acc": dict(master=4, acc_grad=4, w=2, g=2, opt=8), # = 20
"transformer_engine": dict(master=0, acc_grad=0, w=4, g=4, opt=8), # = 16
"fp8lm_o3": dict(master=2, acc_grad=2, w=1, g=1, opt=3), # = 9
"deepseek_v3": dict(master=4, acc_grad=4, w=1, g=2, opt=4), # = 15
"nanotron_fp8": dict(master=2, acc_grad=4, w=1, g=1, opt=2), # = 10
}
totals = {}
for name, comp in schemes.items():
totals[name] = sum(comp.values()) # 填空:各分量求和
return totals
if __name__ == "__main__":
show_formats()
print("\n权重更新吸收演示(w=1.0):")
for dt, name in ((torch.float32, "fp32"), (torch.float16, "fp16"), (torch.bfloat16, "bf16")):
for upd in (1e-3, 1e-5):
before, after, absorbed = simulate_absorption(1.0, upd, dt)
print(f" {name:5s} update={upd:8.1e} -> w={after:.6f} 吸收={absorbed}")
print("\n各方案每参数 bytes:")
for name, total in fp8_memory_ledger().items():
print(f" {name:22s} {total} bytes")
```
## 补全后的代码(关键填空处)
```python
def float_props(exp_bits, mantissa_bits):
bias = 2 ** (exp_bits - 1) - 1
max_value = (2 - 2 ** (-mantissa_bits)) * 2 ** bias
min_normal = 2 ** (1 - bias)
epsilon = 2 ** (-mantissa_bits)
return max_value, min_normal, epsilon
def simulate_absorption(w, update, dtype):
...
absorbed = (w_new == w_low) # 更新后被舍入回原值
def fp8_memory_ledger():
totals[name] = sum(comp.values()) # 每方案各分量求和
```
输出:
```python
format max min_normal epsilon
fp32 3.403e+38 1.175e-38 1.192e-07
fp16 6.550e+04 6.104e-05 9.766e-04
bf16 3.390e+38 1.175e-38 7.812e-03
fp8_e4m3 2.400e+02 1.562e-02 1.250e-01
fp8_e5m2 5.734e+04 6.104e-05 2.500e-01
权重更新吸收演示(w=1.0):
fp32 update=1e-3 → 1.001000 吸收=False
fp32 update=1e-5 → 1.000010 吸收=False
fp16 update=1e-3 → 1.000977 吸收=False(ulp≈9.8e-4,勉强留下)
fp16 update=1e-5 → 1.000000 吸收=True
bf16 update=1e-3 → 1.000000 吸收=True(ulp≈7.8e-3 > 1e-3)
bf16 update=1e-5 → 1.000000 吸收=True
各方案每参数 bytes:
bf16_mp_no_fp32acc 16
bf16_mp_fp32acc 20
transformer_engine 16
fp8lm_o3 9
deepseek_v3 15
nanotron_fp8 10
```
## 三个问答题要点
### Q1
- BF16 指数位与 FP32 相同(8 位)→ 范围相同;但尾数只有 7 位 → 精度 ~1e-2(epsilon≈7.8e-3),比 FP16 的 ~1e-3 差约 10 倍;
- 预训练默认 BF16 的原因:训练中梯度/权重动态范围大,FP16 范围窄(max 6.5e4、min_normal 6e-5)容易上下溢,需要 loss scaling 等额外机制;BF16 省掉范围问题,代价是精度——用 FP32 master weights + FP32 累积兜底;
- "范围相同"≠"精度足够":epsilon 决定小数分辨率,BF16 在 1 附近只能分辨 ~7.8e-3 的差异;
- FP16 更合适的场景:数值范围已知且较小、需要更高精度时(如某些激活/量化中间表示),FP16 尾数更多。
### Q2
- 机制:低精度格式下 w 的 ulp 随 |w| 增大(如 w=1.0 时 BF16 ulp≈7.8e-3);更新量 Δ < ulp/2 时 `w+Δ` 舍入后不变 → 更新被吸收;若更新小于当前量级的半个 ULP,`w+Δ` 会舍入回原值;0 本身并非不可恢复,只要后续更新在目标格式中可表示,就能变为非零;
- 修复:a) FP32 master weights:更新发生在 FP32(ulp~1e-7),再转回低精度用于前向——低精度只负责计算、FP32 负责记账;b) loss scaling:梯度远小于 1,先乘大系数再过 FP16 backward,之后除回——防梯度下溢;c) FP32 累积:求和/平均(loss、梯度累积、softmax 分母)在 FP32 做,防止中间溢出/精度损失。
### Q3(综合题要点)
- (1) 配置:单节点 8 卡有两种基本选择。a) TP=8(节点内 NVLink):此时 DP=1,ZeRO 没有 DP 维可分片,静态账本只靠 TP 切分,gbs 只能由 grad_acc 凑齐;b) DP=8 + ZeRO-3(FSDP),不用 TP:每层 All-Gather 参数,在 NVLink 上可用 prefetch 隐藏。也可取 TP=2/4 × DP=4/2 + ZeRO-3 折中。seq=8192 时 CP 会挤占 TP/DP 的卡数,单节点一般不开,激活靠 SP、重计算与 mbs=1 控制;
- (2) 精度与账本:BF16 + Adam 静态账本 30e9 × 16 B = 480 GB(参数 60 GB、梯度 60 GB、master + m + v 共 360 GB)。上述 a) 或 b) 都把它均分到 8 卡,约 60 GB/卡,剩约 20 GB 给激活、通信缓冲与运行时,必须开重计算且 mbs=1,余量很小;FP8 方案(DeepSeek-V3 式 15 B/参数)静态约 450 GB / 8 ≈ 56 GB/卡,只省约 4 GB,主要收益在 GEMM 吞吐。若仍放不下,可把优化器状态卸载到 CPU 或改用 8-bit 优化器(会影响速度或数值,需要验证);
- (3) FP8 必须保持高精度:master weights(FP32/BF16)、梯度累积(FP32)、部分归一化统计量;GEMM 输入/权重才用 FP8 并做 per-tile 量化;
- (4) 训练前实验:单卡 memory profile(max_memory_allocated 曲线)+ micro-benchmark 各配置的 step time 与 MFU,验证 t_comm/t_compute(本配置以 TP 为主,用 TP 公式预判)。
## 通过要点
- 混合精度的核心思想:低精度负责"计算"(快、省激活),高精度负责"记账"(master、累积、优化器状态);
- FP8 的难点不是精度本身,而是动态范围小 → 必须量化(per-tile scaling)+ 稳定性管理;
- 面试作答结构:范围/精度 → 失败机制 → 三个修复 → 账本 → 配置决策。
## 官方主参考
- [Ultra-Scale Playbook](https://huggingface.co/spaces/nanotron/ultrascale-playbook)
- [PyTorch distributed documentation](https://pytorch.org/docs/stable/distributed.html)
:::
::: {.course-pagination}
[**← 上一课**<br>第 13 课:GPU 内核与 FlashAtt...](lesson13.qmd){.pagination-link .pagination-prev}
[**下一课 →**<br>第 1 课:线程映射与 Grid-Stride Loop](../cuda/lesson01_vector_add.qmd){.pagination-link .pagination-next}
:::