第 14 课:混合精度与综合面试

参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。

Important统一完成标准与及格线

代码 4 分、Q1~Q3 各 2 分,通过线 8/10。必须解释正确性边界、显存/通信公式中的单位与分片维度;未实际运行的内容只能标记为静态审查。

本课目标

完成后你需要能够:

  • 说出 FP32/FP16/BF16/FP8(e4m3, e5m2) 的位布局、范围与精度(epsilon),解释”范围”与”精度”的取舍。
  • 解释 FP16 训练失败的机制与三个修复:FP32 master weights、loss scaling、FP32 累积。
  • 说明 FP8 训练的做法(per-tile 量化)、难点(稳定性)与各方案的显存账本。
  • 独立完成一道”训练系统设计 + 精度选择”综合面试题。

核心概念

1. 现代浮点格式拓扑与动态范围 vs 精度权衡(如图 14-1 所示)

flowchart TD
    subgraph FloatFormats["主流深度学习浮点格式位宽构成"]
        direction TB
        FP32["FP32 (单精度): 1 符号位 + 8 指数位 + 23 尾数位 (动态范围 10^-38 ~ 10^38)"]
        FP16["FP16 (半精度): 1 符号位 + 5 指数位 + 10 尾数位 (动态范围仅 65504, 易溢出)"]
        BF16["BF16 (Brain Float): 1 符号位 + 8 指数位 + 7 尾数位 (动态范围与 FP32 完全相同)"]
        FP8["FP8 (E4M3 / E5M2): 8 位极窄位宽 (Hopper/Blackwell 前向计算新宠)"]
    end

图 14-1:现代深度学习主流浮点数值格式位宽与动态范围对比

在现代大模型软硬件协同体系中,数值精度的选择直接决定了矩阵乘法单元(Tensor Core)的计算吞吐量与显存占用上限。 IEEE 754 标准浮点数由三个字段定义:符号位(Sign Bit ss)、指数位(Exponent ee)与尾数位(Mantissa / Fraction mm): v=(−1)s×2e−bias×(1+∑i=1mbi2−i)v = (-1)^s \times 2^{e - \text{bias}} \times \left(1 + \sum_{i=1}^m b_i 2^{-i}\right)

各主流精度的位布局与关键数值特性如下表所示:

格式名称 总位宽 符号位 指数位 (ee) 尾数位 (mm) 最大表示范围 (VmaxV_{\max}) 最小正规格数 (VminV_{\min}) 精度刻度(1.0 处的 ULP / ϵ\epsilon)
FP32 (单精度) 32 位 (4B) 1 8 位 23 位 ≈3.4×1038\approx 3.4 \times 10^{38} ≈1.18×10−38\approx 1.18 \times 10^{-38} 2−23≈1.19×10−72^{-23} \approx 1.19 \times 10^{-7}
FP16 (半精度) 16 位 (2B) 1 5 位 10 位 𝟔𝟓,𝟓𝟎𝟒\mathbf{65,504} ≈6.10×10−5\approx 6.10 \times 10^{-5} 2−10≈9.77×10−42^{-10} \approx 9.77 \times 10^{-4}
BF16 (Brain Float) 16 位 (2B) 1 8 位 7 位 ≈3.39×1038\approx 3.39 \times 10^{38} ≈1.18×10−38\approx 1.18 \times 10^{-38} 2−7≈7.81×10−32^{-7} \approx 7.81 \times 10^{-3}
FP8 E4M3 8 位 (1B) 1 4 位 3 位 448448 (E4M3FN) ≈1.95×10−3\approx 1.95 \times 10^{-3} 2−3=0.1252^{-3} = 0.125
FP8 E5M2 8 位 (1B) 1 5 位 2 位 57,34457,344 ≈6.10×10−5\approx 6.10 \times 10^{-5} 2−2=0.252^{-2} = 0.25

深度工程洞见:为什么工业界全面拥抱 BF16 而淘汰原生 FP16?

  • FP16 的致命下溢诅咒:FP16 仅有 5 位指数,其最小正规化数仅为 6.1×10−56.1 \times 10^{-5}。但在百亿/千亿大模型反向传播中,深层参数的梯度方差通常在 10−6∼10−810^{-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.0w = 1.0。在 BF16 格式下,1.0 处末位有效数字的单位精度(Unit in the Last Place, ULP)为 ΔULP=2−7≈7.81×10−3\Delta_{\text{ULP}} = 2^{-7} \approx 7.81 \times 10^{-3}。

  • 设当前学习率 η=10−4\eta = 10^{-4},优化器计算出的反向梯度为 g=0.5g = 0.5。单步实际参数更新量应为: Δw=−η⋅g=−5×10−5\Delta w = -\eta \cdot g = -5 \times 10^{-5}

  • 若直接在 BF16 上就地累加: wnew=1.0−0.00005→BF16 舍入1.0w_{\text{new}} = 1.0 - 0.00005 \xrightarrow{\text{BF16 舍入}} 1.0 因为更新量 Δw\Delta w 远远小于 BF16 当前精度的半个 ULP,更新值在浮点舍入时被彻底“吞噬吸收”!模型在整个训练过程中参数纹丝不动,损失函数完全无法下降。

  • 解决方案:必须在优化器显存中常驻一份 FP32 Master 权重!前向与反向计算使用 BF16 算子跑高速 GEMM,但在 optimizer.step() 时,将梯度累加到 FP32 Master 权重上。FP32 的 ULP 高达 1.19×10−71.19 \times 10^{-7},足以精准记录微小的参数累积演化,步结时再截断投影回 BF16 供下一步使用。

支柱二:动态损失缩放(Dynamic Loss Scaling,如图 14-2 所示)

sequenceDiagram
    autonumber
    participant Loss as 计算 Loss
    participant Scale as 损失缩放因子 (Scale S)
    participant Bwd as 反向梯度计算 (BF16/FP16)
    participant Check as 溢出检查 (Inf/NaN 检测)
    participant Optimizer as 优化器更新 (FP32 主权重)

    Loss->>Scale: 乘以 Scale (放大梯度)
    Scale->>Bwd: 反向传播计算放大后的梯度 g_scaled
    Bwd->>Check: 扫描梯度张量是否有 Inf/NaN
    alt 存在溢出 (Overflow)
        Check-->>Optimizer: 跳过本步优化器更新 (Skip Step)
        Check->>Scale: 动态减小 Scale (如 S = S / 2)
    else 无溢出 (Finite)
        Check->>Optimizer: 反缩放 g = g_scaled / S -> 正常更新参数
        Check->>Scale: 连续 N 步正常 -> 放大 Scale (如 S = S * 2)
    end

图 14-2:动态损失缩放(Dynamic Loss Scaling)溢出检测与自适应更新时序流

在不得不使用 FP16 的老旧硬件(如 V100)上,必须引入自适应损失缩放器(Loss Scaler):

  1. 反向前放大:在反向传播启动前,将标量损失乘以一个巨大的缩放系数 SS(如 S=215=32768S = 2^{15} = 32768):ℒscaled=ℒ×S\mathcal{L}_{\text{scaled}} = \mathcal{L} \times S;

  2. 链式法则伴随放大:反向传播计算出的所有梯度张量被等比放大 SS 倍,强行推入 FP16 的正规表示区间,规避下溢;

  3. 溢出检测与缩放退避:在优化器执行更新前,检查全网梯度是否包含 Inf 或 NaN。若发生溢出,说明 SS 过大导致高位被击穿,立即放弃本步更新,并将 SS 减半(S←S/2S \leftarrow S / 2);若连续 NN 步无溢出,则尝试增大缩放因子(S←S×2S \leftarrow S \times 2)。

支柱三:敏感算子的 FP32 强制累加与约简

Softmax 的分母指数累加 ∑exp⁡(zi)\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)

  • 前向传播(Forward GEMM):激活张量与权重张量采用 E4M3 格式。前向激活值分布较为平缓紧凑,E4M3 提供了 3 位尾数精度,能有效控制困惑度(PPL)损耗。
  • 反向传播(Backward GEMM):反向梯度张量采用 E5M2 格式。反向梯度动态范围跨度极大,E5M2 拥有 5 位指数域,能最大程度防范梯度爆炸与下溢。

B. 细粒度分块量化(Tile-wise Fine-Grained Scaling)

FP8 的最大动态范围极窄(E4M3 仅 448)。若采用全张量(Tensor-wise)量化,异常离群点(Outliers)会使整张矩阵精度崩溃。 DeepSeek-V3 提出了细粒度分块量化:

  • 对激活张量按 1×1281 \times 128 瓦片(Token 内部特征向量)独立求缩放系数;
  • 对权重矩阵按 128×128128 \times 128 空间瓦片独立求缩放系数;
  • 配合专用的 FP8 算子,在 Hopper 架构上实现了几乎无损的 671B 庞大参数预训练。

C. 主流混合精度方案静态显存账本对比(每参数字节数 Bytes/param)

训练方案与精度配置 GEMM 精度 Master 权重 累积梯度 存储权重 存储梯度 优化器状态 (AdamW) 单参数静态总显存
传统 BF16(含 FP32 梯度累积) BF16 (2B) FP32 (4B) FP32 (4B) BF16 (2B) BF16 (2B) FP32 mm + FP32 vv (8B) 20 Bytes / param
标准 BF16(无 FP32 梯度累积) BF16 (2B) FP32 (4B) — (0B) BF16 (2B) BF16 (2B) FP32 mm + FP32 vv (8B) 16 Bytes / param
Transformer Engine FP8 FP8 (1B) — (0B) — (0B) FP32 (4B) FP32 (4B) FP32 mm + FP32 vv (8B) 16 Bytes / param
DeepSeek-V3 工业级 FP8 方案 FP8 (1B) FP32 (4B) FP32 (4B) FP8 (1B) BF16 (2B) BF16 mm + BF16 vv (4B) 15 Bytes / param
极致极简 FP8 (FP8-LM O3) FP8 (1B) FP16 (2B) FP16 (2B) FP8 (1B) FP8 (1B) FP8 mm + FP16 vv (3B) 9 Bytes / param

核心启示:计算精度(GEMM Precision)与存储精度(Storage Precision)是两个完全解耦的维度。FP8 训练的核心红利是直接将 Tensor Core 翻倍加速,而静态显存能否降低,完全取决于 Master 权重与优化器状态的格式抉择。

具体演示与数值推演

以一个在 BF16 精度下的模型参数更新步骤为例,设当前权重 w=1.0w = 1.0,反向求导计算所得的更新量为 Δ=1×10−3\Delta = 1 \times 10^{-3}(即 0.0010.001):

  • 在 BF16 单精度下的舍入吸收灾难: BF16 在基准点 1.01.0 处的尾数有效位为 7 位,其最小分辨精度为: ΔULP=2−7=1128≈0.0078125\Delta_{\text{ULP}} = 2^{-7} = \frac{1}{128} \approx 0.0078125 由于更新量 Δ=0.001<12ΔULP≈0.0039\Delta = 0.001 < \frac{1}{2} \Delta_{\text{ULP}} \approx 0.0039,在浮点数四舍六入五成双(Round to Nearest Even)规则下: bf16(1.0+0.001)≡1.0\text{bf16}(1.0 + 0.001) \equiv 1.0 本次梯度的反向计算被硬件完全视作噪声抹杀,优化器更新在物理上毫无任何效果!

  • 引入 FP32 Master 权重镜像: FP32 在 1.01.0 处的末位精度为: ΔULP_FP32=2−23≈1.192×10−7\Delta_{\text{ULP\_FP32}} = 2^{-23} \approx 1.192 \times 10^{-7} 更新量 Δ=0.001\Delta = 0.001 是其最小刻度的 83888388 倍!在 FP32 Master 权重上: wmaster=1.0+0.001=1.001000047…w_{\text{master}} = 1.0 + 0.001 = 1.001000047 \dots 即使单步无法在 BF16 中体现,连续累积 8 步后,累积更新量达到 0.008>ΔULP0.008 > \Delta_{\text{ULP}},此时 Cast 回 BF16 时,参数将精准步进跃升至 1.00781251.0078125。这完美诠释了“低精度负责超快前向反向,高精度主干负责微步记账”的混合精度第一法则。

代码填空题

实现浮点格式属性计算 + 低精度更新吸收模拟 + 各方案显存账本。

Code
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 公式错误、静态账本漏项)。

参考答案(仅 answer 分支)

先完成题目再核对;核对后必须解释关键公式,并改一个规模重新计算。

Code
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")

补全后的代码(关键填空处)

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())   # 每方案各分量求和

输出:

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. 配置:1 节点 8 卡 → TP=8(节点内满 NVLink)为主,辅以 ZeRO-2 或 ZeRO-3 分担静态账本;seq=8192 需注意激活(可开 selective/full 重计算或 CP 无意义——CP 需要多卡切序列,单节点 8 卡留给 TP;激活用重计算 + mbs=1);gbs 由 grad_acc 与 dp 决定(无 DP 时 grad_acc 单独承担);
    1. 精度:30B×16B ≈ 480 GB 静态账本 / 8 卡 = 60 GB > 80 GB 卡(含激活)→ 必须 ZeRO-2/3;BF16 方案总账本约 60 GB/卡(ZeRO-2、dp=8 时 (2Ψ+(2+12)Ψ/8)/1 维度…按卡计 30B: 参数 60GB/8? 参数量级:30e9×2=60 GB 参数、梯度 60 GB、opt 360 GB → ZeRO-3 每卡 (60+60+360)/8=60 GB,加激活/运行时超 80 → 需重计算 + mbs=1;FP8 方案(如 DeepSeek 式 15B/参数 → 450 GB/8 ≈ 56 GB)更宽松;
    1. FP8 必须保持高精度:master weights(FP32/BF16)、梯度累积(FP32)、部分归一化统计量;GEMM 输入/权重才用 FP8 并做 per-tile 量化;
    1. 训练前实验:单卡 memory profile(max_memory_allocated 曲线)+ micro-benchmark 各配置的 step time 与 MFU,验证 t_comm/t_compute(本配置以 TP 为主,用 TP 公式预判)。

通过要点

  • 混合精度的核心思想:低精度负责”计算”(快、省激活),高精度负责”记账”(master、累积、优化器状态);
  • FP8 的难点不是精度本身,而是动态范围小 → 必须量化(per-tile scaling)+ 稳定性管理;
  • 面试作答结构:范围/精度 → 失败机制 → 三个修复 → 账本 → 配置决策。

官方主参考