第 6 章 大规模模型训练

JAX 高级编程:性能、优化与规模化 · 第 6 章学习笔记

模型越来越大以后,训练中的问题就不再是"梯度怎么算",而是放不放得下、算不算得完、设备之间通信跟不跟得上。本章先分析大模型训练的三类瓶颈(内存、计算、通信),然后逐个介绍应对手段:用 Flax NNX / Optax 组织参数与优化器状态,用数据并行、梯度累积、梯度检查点(再物化)、混合精度、张量并行 / FSDP / 流水线并行等技术把训练扩展到更大的规模,最后比较适合大规模训练的优化器。

学习目标

  • 能估算一个模型训练时参数、梯度、优化器状态和激活值各占多少内存,判断瓶颈在哪里
  • 用 Flax NNX(nnx.Module、nnx.Rngs、nnx.Param / nnx.BatchStat、nnx.Optimizer、nnx.jit、nnx.split/merge)和 Optax(chain、clip_by_global_norm、adamw、学习率调度、MultiSteps)搭建训练循环,看懂 Linen / Haiku 旧代码
  • 把 pmap 写法迁移到 jit + NamedSharding,并用 nnx.with_partitioning 给参数加分片注解
  • 用 lax.scan 实现梯度累积,并说清均值 / 求和、BatchNorm 带来的差异
  • 用 jax.checkpoint 及各种 policy 在内存和计算之间取舍,并用 memory_analysis()、print_saved_residuals 定量验证
  • 理解 bf16 与 fp16 的区别,实现"参数 fp32 + 计算 bf16",以及 fp16 的动态 loss scaling
  • 在 data × model 二维 mesh 上实现 Megatron 式张量并行和 FSDP,并验证与单设备训练一致
  • 比较 AdamW、Adafactor、LAMB、Lion、LARS 的优化器状态内存和适用场景

小节目录

小节内容
6.1大型模型训练中的挑战概述
6.2JAX 生态系统库(Flax, Haiku)简介
6.3模型参数和状态的处理
6.4将 pmap 与训练框架结合使用
6.5梯度累积
6.6梯度检查点(再物化)
6.7混合精度训练
6.8模型并行策略
6.9大规模优化算法
6.10实践:实现梯度检查点
—动手练习 · 测验 · 本章更新说明 · 小结 · 速查表 · 后续学习路线

原课程链接:https://apxml.com/zh/courses/advanced-jax/chapter-6-large-scale-model-training-jax

阅读说明:讲解按课程要点组织,所有代码都实际运行过。过时的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法。

运行环境:Python 3.14、JAX / jaxlib 0.12.0.1、Flax 0.12.12(含 NNX)、Optax 0.2.9、Orbax-checkpoint 0.12.10,仅 CPU。用 XLA_FLAGS=--xla_force_host_platform_device_count=8 在一个 CPU 上模拟 8 个设备来演示并行。文中的结论都以这个环境的运行结果为准。计时结果只代表这台 CPU,不做 assert。

与入门课的关联(本章不再重复这些内容)

入门课笔记本章用到的基础
入门 02 JIT编译追踪与编译、lower() / compile()、静态参数、重编译
入门 03 自动微分gradgrad / value_and_grad / has_aux,反向传播需要保存残差
入门 04 自动向量化vmapvmap 与 batch 维度
入门 05 多设备并行pmappmap、axis_name、lax.pmean / psum、模拟多设备
入门 06 状态管理PyTree 状态、lax.scan 的 carry、手写的 SGD / Adam 优化器、donate_argnums
入门 07 实战专题TrainState、完整训练循环

本章在这些基础上换成生态库(NNX + Optax)来写,并重点讲"规模化"相关的新内容。

# 必须在 import jax 之前设置:在 CPU 上模拟 8 个设备,用于数据并行 / 模型并行 / FSDP 演示
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"

import sys, time, re, warnings, tempfile
from functools import partial

import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
from jax.sharding import NamedSharding, PartitionSpec as P, AxisType, reshard
import optax
import flax
from flax import nnx

print("Python", sys.version.split()[0])
print("JAX   ", jax.__version__, "| Flax", flax.__version__, "| Optax", optax.__version__)
print("设备  ", jax.devices())
assert jax.device_count() == 8

# ---------- 本章通用的小工具 ----------
def tree_bytes(tree):
    """PyTree 中所有数组叶子的总字节数。"""
    return sum(x.nbytes for x in jax.tree.leaves(tree) if hasattr(x, "nbytes"))

def max_diff(a, b):
    """两个同结构 PyTree 的最大逐元素绝对误差(转成 float64 比较)。"""
    la, lb = jax.tree.leaves(a), jax.tree.leaves(b)
    assert len(la) == len(lb)
    return max(float(np.max(np.abs(np.asarray(x, np.float64) - np.asarray(y, np.float64)))) for x, y in zip(la, lb))

def bench(f, *args, n=10):
    """先预热(含编译),再计时 n 次取平均,单位:毫秒。"""
    jax.block_until_ready(f(*args))
    t0 = time.perf_counter()
    for _ in range(n):
        out = f(*args)
    jax.block_until_ready(out)
    return (time.perf_counter() - t0) / n * 1e3

def mem_of(f, *args):
    """编译 jit(f),返回 XLA 的内存分析结果(不执行)。"""
    return jax.jit(f).lower(*args).compile().memory_analysis()

def collectives(compiled_text):
    """统计编译后 HLO 中各类集合通信的出现次数。"""
    ops = ["all-reduce", "all-gather", "reduce-scatter", "all-to-all", "collective-permute"]
    return {op: len(re.findall(op + r"(?:-start)?\(", compiled_text)) for op in ops}

MiB = 2 ** 20
Python 3.14.7
JAX    0.12.0.1 | Flax 0.12.12 | Optax 0.2.9
设备   [CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]

6.1 大型模型训练中的挑战概述

模型规模(尤其是 NLP 和 CV 领域)一直在增长。JAX 提供了加速和自动微分的工具,但模型大到一定程度,就会撞上硬件的物理限制。课程把挑战归为三类:内存、计算和通信。

内存限制:主要瓶颈

单个加速器(一块 GPU 或一个 TPU 核心)的高带宽显存(HBM)通常只有 16 GB 到 80 GB(这是前几代加速器的典型值。H200、B200、MI300X 等新一代加速器已经到了 141–192 GB,但对百亿参数的模型来说还是不够,下面的结论不变)。训练时占内存的主要有五类:

  1. 模型参数。百亿参数的模型已经很常见。100 亿参数用 FP32 存储,光参数就要 $$10\times10^{9}\ \text{参数}\times 4\ \text{字节/参数} = 40\ \text{GB}.$$
  2. 优化器状态。Adam / AdamW 为每个参数维护一阶矩 $m$ 和二阶矩 $v$,额外需要 $$10\times10^{9}\times 2\times 4\ \text{字节} = 80\ \text{GB},$$ 参数加优化器状态的"静态内存"已经到了 120 GB,超过绝大多数单卡的容量。
  3. 激活值。前向传播的中间结果要留到反向传播时用。它的大小随 batch、序列长度、模型深度和宽度增长,网络深、序列长的时候可能远超参数内存。
  4. 梯度。与参数同形状,FP32 下又是 40 GB。
  5. 工作区内存。XLA 编译器、cuDNN 等库执行运算时需要的临时缓冲区。

超出设备内存就会 OOM,训练直接中止。后面几节各管一块:梯度检查点(6.6)主要降低激活内存,混合精度(6.7)减少激活和参数的字节数,FSDP(6.8)把参数、梯度和优化器状态分摊到多个设备上。

计算成本与训练时间

  • FLOPs 增长:计算量往往随模型维度非线性增长。比如 Transformer 自注意力的计算量随序列长度二次增长;深度或宽度翻倍,计算量可能远超两倍。
  • 训练时长:可能要几天、几周甚至几个月,硬件成本、能耗和迭代周期都很高。
  • 硬件要求:需要高性能 GPU 集群或大型 TPU Pod。
  • 降低总 FLOPs 通常要改算法或架构;混合精度可以借助更快的低精度计算单元提速。

分布式训练中的通信开销

  • 数据并行:每个设备都有一份完整的模型副本,各自处理不同的数据分片。本地反向传播完,用 All-Reduce 集合通信同步梯度,再做优化器更新。
  • 通信成本取决于模型大小,以及设备之间互连的带宽和延迟。超大模型的通信可能占掉每步时间的一大块,拖累扩展效率。互连慢(以太网,对比 NVLink / TPU ICI)的话问题更严重。
  • 模型并行 / 流水线并行会引入更复杂的通信模式(6.8)。
  • 降低通信影响的办法:优化集合通信算法、让通信与计算重叠、选择计算与通信更平衡的并行策略。

课程参考文献:Attention Is All You Need(Vaswani 等,2017);Mixed Precision Training(Micikevicius 等,arXiv:1710.03740);Megatron-LM(Shoeybi 等,arXiv:1909.08053)。

下面先把上面的内存算式写成代码,再用 Optax 验证一下"Adam 状态 = 2 倍参数"。

GB = 1e9
n_params = 10e9                      # 示例:100 亿参数

mem = {
    "参数 (fp32)":        n_params * 4,
    "梯度 (fp32)":        n_params * 4,
    "Adam m+v (fp32)":    n_params * 2 * 4,
}
for k, v in mem.items():
    print(f"{k:18s} {v / GB:6.0f} GB")
static = mem["参数 (fp32)"] + mem["Adam m+v (fp32)"]
print(f"{'静态(参数+优化器)':14s} {static / GB:6.0f} GB")
print(f"{'合计(不含激活)':15s} {sum(mem.values()) / GB:6.0f} GB")

assert mem["参数 (fp32)"] / GB == 40 and mem["Adam m+v (fp32)"] / GB == 80
assert static / GB == 120 and sum(mem.values()) / GB == 160

# 用 Optax 实测:Adam 状态的字节数恰好是参数的 2 倍(另有一个 int32 的步数 count)
toy = {"w": jnp.zeros((1024, 512)), "b": jnp.zeros(512)}
adam_state = optax.adam(1e-3).init(toy)
ratio = tree_bytes(adam_state) / tree_bytes(toy)
print(f"\nOptax Adam 状态 / 参数 字节比 = {ratio:.6f}")
assert abs(ratio - 2.0) < 1e-4
参数 (fp32)              40 GB
梯度 (fp32)              40 GB
Adam m+v (fp32)        80 GB
静态(参数+优化器)        120 GB
合计(不含激活)           160 GB
Optax Adam 状态 / 参数 字节比 = 2.000002

⚠️ 注意:常有人说 Adam/AdamW 的优化器状态"需要参数的 2–3 倍存储"。其实在 Optax 0.2.9 里,Adam/AdamW 的状态恰好是参数的 2 倍:$m$、$v$ 各一份,外加一个标量步数。只有把混合精度训练里的 fp32 主权重副本也算进优化器状态,才会到 3 倍(见下方补充)。

🔍 补充:每个参数到底要多少字节?激活又有多大?

🟢 必读

1)混合精度 + Adam 的"16 字节/参数"经验法则(ZeRO 论文的记法):bf16 参数 2 B + bf16 梯度 2 B + fp32 主权重 4 B + fp32 $m$ 4 B + fp32 $v$ 4 B = 16 B/参数。100 亿参数就是 160 GB。可见混合精度并没有降低静态内存,它省下的主要是激活和带宽。要把静态内存降下来,得靠 6.8 节的 FSDP/ZeRO 分片:$N$ 个设备分摊以后,每个设备约 $16\Phi/N$ 字节。

2)训练计算量的估算:稠密 Transformer 训练一个 token 大约要 $6\Phi$ FLOPs(前向 $2\Phi$、反向 $4\Phi$,$\Phi$ 为参数量)。所以总计算量 $C\approx 6\Phi T$($T$ 为训练 token 数)。"反向 ≈ 2 倍前向"这个比例,6.6 节讨论检查点开销时还会用到。

3)数据并行的通信量:环形 All-Reduce 里,每个设备收发的数据量约为 $2\frac{N-1}{N}S$($S$ 为梯度字节数),几乎和设备数无关。这是数据并行能扩展的原因,但也说明每步都要搬运约 2 倍模型大小的数据。

def bytes_per_param(mixed_precision=True):
    """Adam 训练每个参数需要的字节数(不含激活)。"""
    if mixed_precision:
        return dict(bf16参数=2, bf16梯度=2, fp32主权重=4, adam_m=4, adam_v=4)
    return dict(fp32参数=4, fp32梯度=4, adam_m=4, adam_v=4)

mp = bytes_per_param(True)
print("混合精度 Adam:", mp, "合计", sum(mp.values()), "B/参数 →", n_params * sum(mp.values()) / GB, "GB")
assert sum(mp.values()) == 16
for n_dev in [8, 64, 512]:
    print(f"  FSDP/ZeRO-3 分到 {n_dev:3d} 个设备:每设备 {n_params * 16 / n_dev / GB:7.2f} GB")

# 训练计算量:C ≈ 6 · Φ · T
tokens = 1e12
C_total = 6 * n_params * tokens
chip_flops, mfu, n_chips = 300e12, 0.4, 256        # 假设:每卡 300 TFLOP/s、40% 利用率、256 卡
days = C_total / (chip_flops * mfu * n_chips) / 86400
print(f"\n10B 参数 × 1T token ≈ {C_total:.1e} FLOPs,256 卡约 {days:.1f} 天")

# 环形 all-reduce 每个设备的通信量
S = n_params * 4
for N in [8, 64]:
    print(f"N={N:3d}: 每设备每步收发 ≈ {2 * (N - 1) / N * S / GB:.1f} GB 梯度数据")
混合精度 Adam:
 {'bf16参数': 2, 'bf16梯度': 2, 'fp32主权重': 4, 'adam_m': 4, 'adam_v': 4} 合计 16 B/参数 → 160.0 GB
  FSDP/ZeRO-3 分到   8 个设备:每设备   20.00 GB
  FSDP/ZeRO-3 分到  64 个设备:每设备    2.50 GB
  FSDP/ZeRO-3 分到 512 个设备:每设备    0.31 GB

10B 参数 × 1T token ≈ 6.0e+22 FLOPs,256 卡约 22.6 天
N=  8: 每设备每步收发 ≈ 70.0 GB 梯度数据
N= 64: 每设备每步收发 ≈ 78.8 GB 梯度数据

6.2 JAX 生态系统库(Flax, Haiku)简介

jit、grad、vmap、pmap 是底层原语。直接用它们写大模型,参数、优化器统计量、随机数 key 这些状态管起来会非常繁琐,多设备时更麻烦。神经网络库在这些原语之上提供了更高层的抽象。课程介绍了 JAX 生态的两个主要库:Flax(Google)和 Haiku(DeepMind),两者风格不同,目标相同。

课程讲的 Flax(Linen):结构化模块 + 显式状态

  1. 模块系统:继承 nn.Module,子层在 setup 中定义,或在 @nn.compact 修饰的 __call__ 里内联定义。
  2. 显式初始化:参数在 module.init(rng, 示例输入) 时创建,结构定义与参数值分离。
  3. 函数式 apply:model.apply({'params': params}, x) 用给定参数执行前向,便于与 JAX 变换组合。
  4. PyTree 状态:参数是嵌套字典,可直接传给 jit / grad。
  5. TrainState:flax.training.train_state.TrainState 把 apply_fn、参数和优化器状态打包,简化训练循环。

课程讲的 Haiku:面向对象风格 + 隐式状态

  1. 模块系统:继承 hk.Module,子层通常在 __init__ 或 __call__ 中实例化。
  2. 隐式参数创建:第一次调用层时由 Haiku 内部创建并管理参数。
  3. hk.transform:把含模块的函数变换成一对纯函数 init(初始化参数)和 apply(前向)。
  4. 内部状态管理:在 hk.transform / hk.transform_with_state 上下文中,幕后管理 RNG 序列和 BatchNorm 统计量。

为什么大模型要用这些库

  1. 组织性:有固定的结构,复杂架构也好读、好维护;
  2. 状态管理:统一处理参数、优化器状态、批统计量和 RNG,跨设备时尤其重要;
  3. 减少样板代码;
  4. 可组合性:模块可以嵌套和复用;
  5. 生态集成:配套的检查点、指标记录、训练循环工具。

课程的结论是:选哪个看风格偏好。Flax 更显式、更函数式;Haiku 更面向对象,状态是隐式的。

⚠️ 注意:很多资料把 Flax 说成"以 flax.linen 为核心",并把 Haiku 当作和它并列的现役选择。到 2026 年 11 月,情况已经变了:

  • Flax 的推荐 API 已经是 NNX。官方文档首页写着 "This site covers the new Flax NNX API",Linen 文档搬到了单独的站点(flax-linen.readthedocs.io),并写着 "new users are encouraged to use Flax NNX"。Linen 是旧 API,不过官方也说了它近期不会被废弃。下面会看到,Linen 在 Flax 0.12.12 里仍能正常运行。
  • Haiku 已进入维护模式。dm-haiku 的 README 写着:"As of July 2023 Google DeepMind recommends that new projects adopt Flax instead of Haiku"。项目只做 bug 修复和 JAX 新版本兼容,不再加新功能。

所以本教程的主线一律用 NNX 写。课程的 Linen 代码保留作对照,Haiku 代码只作阅读参考(本环境没有安装 Haiku)。

先运行课程的 Linen 示例作对照,再给出 NNX 写法。

# ❌ 旧写法(过时):flax.linen 是旧 API(新项目推荐 NNX);PRNGKey、jax.tree_util.tree_map 也是旧写法
try:
    import flax.linen as nn

    class SimpleMLP(nn.Module):
        features: list[int]          # 定义层大小的列表,例如 [128, 64, 10]

        @nn.compact                  # 允许在 __call__ 中内联定义子模块
        def __call__(self, x):
            for i, feat in enumerate(self.features):
                x = nn.Dense(features=feat, name=f'dense_{i}')(x)
                if i != len(self.features) - 1:   # 除最后一层外都用 ReLU
                    x = nn.relu(x)
            return x

    key = jax.random.PRNGKey(0)
    dummy_input = jnp.ones((1, 28 * 28))        # batch=1 的扁平化 MNIST 图像
    linen_model = SimpleMLP(features=[128, 10])
    linen_params = linen_model.init(key, dummy_input)['params']
    print("参数 PyTree 结构:", jax.tree_util.tree_map(lambda x: x.shape, linen_params))
    linen_out = linen_model.apply({'params': linen_params}, dummy_input)
    print("输出形状:", linen_out.shape)
    print("实测:Linen 在 Flax", flax.__version__, "中仍可运行(旧 API,未废弃)")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
参数 PyTree 结构: {'dense_0': {'bias': (128,), 'kernel': (784, 128)}, 'dense_1': {'bias': (10,), 'kernel': (128, 10)}}
输出形状: (1, 10)
实测:Linen 在 Flax 0.12.12 中仍可运行(旧 API,未废弃)
# ✅ 推荐写法:Flax NNX
class SimpleMLP(nnx.Module):
    def __init__(self, din: int, features: list[int], *, rngs: nnx.Rngs):
        dims = [din] + list(features)
        # 子模块列表要用 nnx.List 包起来,NNX 才能把它识别为图节点
        self.layers = nnx.List([nnx.Linear(a, b, rngs=rngs) for a, b in zip(dims[:-1], dims[1:])])

    def __call__(self, x):
        for i, layer in enumerate(self.layers):
            x = layer(x)
            if i != len(self.layers) - 1:
                x = nnx.relu(x)
        return x

model = SimpleMLP(28 * 28, [128, 10], rngs=nnx.Rngs(0))     # 构造即初始化
x_demo = jnp.ones((1, 28 * 28))
out = model(x_demo)                                           # 直接调用
print("输出形状:", out.shape)

params_state = nnx.state(model, nnx.Param)                    # 取出所有 nnx.Param
shapes = jax.tree.map(jnp.shape, nnx.to_pure_dict(params_state))
print("参数结构:", shapes)

# 与上面 Linen 版本的参数形状逐一对应(Linen 的 dense_i ↔ NNX 的 layers[i])
assert shapes["layers"][0]["kernel"] == linen_params["dense_0"]["kernel"].shape == (784, 128)
assert shapes["layers"][1]["kernel"] == linen_params["dense_1"]["kernel"].shape == (128, 10)
assert out.shape == linen_out.shape == (1, 10)
输出形状: (1, 10)
参数结构: {'layers': {0: {'bias': (128,), 'kernel': (784, 128)}, 1: {'bias': (10,), 'kernel': (128, 10)}}}

NNX 写法要点:NNX 的模块就是普通的可变 Python 对象。在 __init__ 里直接创建子层和参数(需要随机数时显式传入 nnx.Rngs),没有 init / apply 两步。构造完对象,参数就已经在里面了。

NNX 的"函数式出口":nnx.split / nnx.merge

NNX 对象虽然可变,但随时可以拆成静态结构 GraphDef + 纯数据 State(一个 PyTree),再合回去。这一步就相当于 Haiku 的 init/apply 和 Linen 的 apply(params, x):State 可以直接交给 jax.jit、jax.grad、Orbax 这些只认 PyTree 的工具。

nnx.jit / nnx.grad 等"提升版"变换会在内部自动做 split → 调用 → merge,并把函数里对模块的修改(比如 BatchNorm 统计量、优化器步数)写回原对象。

# ❌ 旧写法(过时):Haiku 自 2023-07 起进入维护模式,DeepMind 推荐新项目改用 Flax;本环境未安装 haiku
try:
    import haiku as hk

    class HkMLP(hk.Module):
        def __init__(self, features, name=None):
            super().__init__(name=name)
            self.features = features
        def __call__(self, x):
            for i, feat in enumerate(self.features):
                x = hk.Linear(output_size=feat, name=f'linear_{i}')(x)
                if i != len(self.features) - 1:
                    x = jax.nn.relu(x)
            return x

    model = hk.transform(lambda x: HkMLP([128, 10])(x))
    params = model.init(jax.random.PRNGKey(0), jnp.ones((1, 784)))
    print(model.apply(params, jax.random.PRNGKey(0), jnp.ones((1, 784))).shape)
except ImportError as e:
    print("实测:", type(e).__name__, "-", e)
实测: ModuleNotFoundError - No module named 'haiku'
# ✅ 推荐写法:Haiku 的 init / apply 在 NNX 中对应 nnx.split / nnx.merge
graphdef, state = nnx.split(model)          # 结构 + 数据
print("GraphDef 类型:", type(graphdef).__name__, "| State 叶子数:", len(jax.tree.leaves(state)))

@jax.jit                                     # 普通的 jax.jit:只接受 PyTree
def pure_apply(state, x):                    # 相当于 Linen 的 model.apply(params, x)
    return nnx.merge(graphdef, state)(x)

@nnx.jit                                     # NNX 提升版 jit:可以直接传模块对象
def nnx_apply(model, x):
    return model(x)

y1, y2, y3 = pure_apply(state, x_demo), nnx_apply(model, x_demo), model(x_demo)
assert max_diff(y1, y3) == 0.0 and max_diff(y2, y3) == 0.0
print("jax.jit + split/merge、nnx.jit、直接调用 三者结果完全一致 ✓")

# 合回去得到一个新对象,与原对象互不影响
model_copy = nnx.merge(graphdef, state)
assert model_copy is not model and max_diff(nnx.state(model_copy), nnx.state(model)) == 0.0
GraphDef 类型: GraphDef | State 叶子数: 4
jax.jit + split/merge、nnx.jit、直接调用 三者结果完全一致 ✓

Linen / NNX / Haiku 对照表

概念Flax Linen(旧)Flax NNX(推荐)Haiku(维护模式)
定义模块class M(nn.Module) + dataclass 字段 + setup / @nn.compactclass M(nnx.Module),在 __init__ 中直接创建子层class M(hk.Module),在 __call__ 中创建
初始化params = model.init(key, x)['params']model = M(..., rngs=nnx.Rngs(0))(构造即初始化)params = hk.transform(f).init(key, x)
前向model.apply({'params': params}, x)model(x)f.apply(params, key, x)
参数在哪外部字典 variables['params']对象属性中的 nnx.Param外部字典
可变状态(BN 统计量)variables['batch_stats'] + mutable=['batch_stats']nnx.BatchStat,前向时原地更新hk.transform_with_state 返回 state
随机数rngs={'dropout': key} 传给 applynnx.Rngs(params=0, dropout=1) 存在模块中hk.next_rng_key(),apply 时传 key
训练 / 推理模式train 参数 / deterministicmodel.train() / model.eval()is_training 参数
优化器TrainState.create(apply_fn, params, tx)nnx.Optimizer(model, tx, wrt=nnx.Param)手动 optax
jitjax.jitnnx.jit(或 jax.jit + nnx.split/merge)jax.jit
转成纯 PyTree本来就是nnx.split(model) / nnx.state(model, 过滤器)本来就是
检查点remat 包装(nn.remat)nnx.remathk.remat
参数分片注解nn.with_partitioningnnx.with_partitioning无内置

🔍 补充:Flax 0.12 中 NNX 的几个"新规矩"

🟢 必读

  1. .value 已弃用:读写 nnx.Variable 的值,数组用 var[...](读)和 var[...] = new(写),其他类型用 get_value() / set_value()。旧代码里的 self.count.value += 1 会触发 DeprecationWarning。
  2. nnx.Optimizer 必须给 wrt:nnx.Optimizer(model, tx, wrt=nnx.Param),wrt 是关键字参数且没有默认值,用来声明"优化哪些变量"。更新用 optimizer.update(model, grads)(要把模块也传进去)。
  3. 模块列表用 nnx.List:放在属性里的模块列表 / 字典用 nnx.List / nnx.Dict 包装。
  4. 两种模式:默认的 graph 模式支持共享引用(同一个子模块挂在两处);graph=False 的 tree 模式把模块当普通 PyTree 处理,开销更小。
class Counter(nnx.Module):
    def __init__(self):
        self.count = nnx.Variable(jnp.array(0))

c = Counter()
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    _ = c.count.value                              # 旧写法
dep = [str(m.message) for m in w if issubclass(m.category, DeprecationWarning)]
print("读取 .value 的警告:", dep[0].splitlines()[0] if dep else None)
assert dep and "deprecated" in dep[0]

c.count[...] = c.count[...] + 1                    # ✅ 新写法
assert int(c.count[...]) == 1

# nnx.Optimizer 的 wrt 是必填关键字参数
import inspect
sig = inspect.signature(nnx.Optimizer.__init__)
print("nnx.Optimizer 签名:", sig)
assert sig.parameters["wrt"].default is inspect.Parameter.empty
try:
    nnx.Optimizer(model, optax.sgd(0.1))
except TypeError as e:
    print("不传 wrt →", type(e).__name__, ":", e)
读取 .value 的警告: '.value' access is now deprecated. For Variable[Array] instances use:
nnx.Optimizer 签名: (self, model: 'M', tx: 'optax.GradientTransformation', *, wrt: 'filterlib.Filter', graph: 'bool | None' = None)
不传 wrt → TypeError : Missing required argument `wrt`. As of Flax 0.11.0 the `wrt` argument is required, if you want to keep the previous use nnx.ModelAndOptimizer instead of nnx.Optimizer.

6.3 模型参数和状态的处理

训练大模型要管理参数、优化器统计量、PRNG key 等状态。JAX 的函数式风格要求状态显式地作为输入传进去、作为输出返回来。初看有点啰嗦,但数据流很清楚,也好调试,在分布式环境里尤其有用。

状态作为 PyTree

参数、优化器状态(动量缓冲、学习率调度的步数)、BatchNorm 统计量、PRNG key 通常都组织成 PyTree。JAX 的变换都按 PyTree 工作:对一个接收 PyTree 的函数求 jax.grad,会对其中所有数值叶子求导;pmap / jit 会按 PyTree 自动复制或分发数据。(PyTree 的基础见 入门 06 状态管理,这里不重复。)

课程的示例把参数、Adam 状态、key 和步数打包成一个字典,并统计叶子数。

# 课程示例(用推荐写法:jax.random.key、jax.tree.*)
params = {
    'encoder': {
        'layer_1': {'w': jnp.ones((128, 256)), 'b': jnp.zeros(256)},
        'layer_norm': {'scale': jnp.ones(256), 'bias': jnp.zeros(256)},
    },
    'decoder': {
        'output': {'w': jnp.ones((256, 10)), 'b': jnp.zeros(10)},
    },
}
optimizer = optax.adam(learning_rate=1e-3)
opt_state = optimizer.init(params)          # 结构与参数相似
key = jax.random.key(0)

training_state = {'params': params, 'opt_state': opt_state, 'rng_key': key, 'step': 0}
leaves, treedef = jax.tree.flatten(training_state)
print("叶子节点数:", len(leaves))

# 逐项拆解
n_p   = len(jax.tree.leaves(params))
n_opt = len(jax.tree.leaves(opt_state))
print(f"参数 {n_p} + Adam 状态 {n_opt}(count 1 + mu {n_p} + nu {n_p},EmptyState 0)+ key 1 + step 1")
print("opt_state 结构:", [type(s).__name__ for s in opt_state])
assert n_p == 6 and n_opt == 1 + 2 * n_p
assert len(leaves) == 21
叶子节点数: 21
参数 6 + Adam 状态 13(count 1 + mu 6 + nu 6,EmptyState 0)+ key 1 + step 1
opt_state 结构: ['ScaleByAdamState', 'EmptyState']

⚠️ 注意:这个状态字典的叶子数是 21(JAX 0.12 + Optax 0.2.9),容易误数成 11。参数有 6 个叶子;optax.adam 的状态是 (ScaleByAdamState(count, mu, nu), EmptyState()),也就是 1 + 6 + 6 = 13 个叶子(EmptyState 没有叶子);再加上 key 和 step 各 1 个。

生态库的状态管理

  • Flax(Linen):把相关状态打包进一个 TrainState 对象(params、opt_state、step、apply_fn、tx)。更新时调用 state.apply_gradients(grads=...),它会生成一个新对象,不会原地修改。需要 BatchNorm 的话,可以继承 TrainState,加一个 batch_stats 字段。
  • Haiku:用 hk.transform_with_state 把纯函数逻辑和参数 params、可变状态 state(比如 BN 统计量)分开。init 返回 (params, state),apply 返回 (输出, new_state),由调用方自己显式传递。

下面运行课程的 Linen TrainState 示例作对照。Haiku 的 transform_with_state 示例没有运行(本环境没装 Haiku),它在 NNX 里对应的是 nnx.BatchStat(见后文)。

# ❌ 旧写法(过时):Linen + flax.training.train_state 是旧 API;NNX 中对应的是 nnx.Optimizer
try:
    from typing import Any
    import flax.linen as nn
    from flax.training import train_state

    class SimpleModel(nn.Module):
        features: int
        @nn.compact
        def __call__(self, x):
            return nn.Dense(features=self.features)(x)

    class TrainState(train_state.TrainState):
        batch_stats: Any = None              # 可选:BatchNorm 统计量

    key = jax.random.PRNGKey(0)
    lin_model = SimpleModel(features=10)
    lin_params = lin_model.init(key, jnp.ones([1, 128]))['params']
    ts = TrainState.create(apply_fn=lin_model.apply, params=lin_params, tx=optax.adam(1e-3))
    grads = jax.tree.map(jnp.ones_like, ts.params)
    ts2 = ts.apply_gradients(grads=grads)    # 不可变更新:返回新对象
    print("实测:可以运行;step", int(ts.step), "→", int(ts2.step), ";旧对象未被修改")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
实测:可以运行;step 0 → 1 ;旧对象未被修改
# ✅ 推荐写法:NNX 中 TrainState 的角色由 nnx.Optimizer 承担(步数、优化器状态都在里面,原地更新)
class SimpleModelNNX(nnx.Module):
    def __init__(self, din, features, *, rngs):
        self.dense = nnx.Linear(din, features, rngs=rngs)
    def __call__(self, x):
        return self.dense(x)

nnx_model = SimpleModelNNX(128, 10, rngs=nnx.Rngs(0))
nnx_opt = nnx.Optimizer(nnx_model, optax.adam(1e-3), wrt=nnx.Param)
grads = jax.tree.map(jnp.ones_like, nnx.state(nnx_model, nnx.Param))
w_old = nnx_model.dense.kernel[...]
nnx_opt.update(nnx_model, grads)                 # 相当于 state.apply_gradients(grads=grads)
print("nnx.Optimizer.step:", int(nnx_opt.step[...]), "| 参数已原地更新:", bool(jnp.any(nnx_model.dense.kernel[...] != w_old)))
assert int(nnx_opt.step[...]) == 1 and int(ts2.step) == 1
# 与 Linen TrainState 一步更新的效果一致(同样的梯度、同样的 Adam,第一步更新量都是 -lr·sign(g))
assert np.allclose(nnx_model.dense.kernel[...] - w_old, ts2.params["Dense_0"]["kernel"] - ts.params["Dense_0"]["kernel"], atol=1e-7)
nnx.Optimizer.step: 1 | 参数已原地更新: True

✅ NNX 中的状态:nnx.Variable 及其子类

NNX 用变量类型区分不同性质的状态,再用过滤器按类型取出:

类型用途会被优化器更新吗
nnx.Param可训练参数(nnx.Linear 的 kernel / bias 等)是(wrt=nnx.Param)
nnx.BatchStatBatchNorm 的滑动均值 / 方差,在训练模式的前向中原地更新否
nnx.RngState(RngKey / RngCount)nnx.Rngs 中的 key 与计数器否
自定义 class Foo(nnx.Variable)任意自定义状态(计数器、EMA 等)否(除非放进 wrt)

nnx.split(model, nnx.Param, nnx.BatchStat, ...) 可以一次把状态按类型拆成多份(... 表示"其余所有"),nnx.state(model, 过滤器) 只取一份。

class StepCounter(nnx.Variable):          # 自定义变量类型
    pass

class BNNet(nnx.Module):
    def __init__(self, din, dh, dout, *, rngs: nnx.Rngs):
        self.l1 = nnx.Linear(din, dh, rngs=rngs)
        self.bn = nnx.BatchNorm(dh, momentum=0.9, rngs=rngs)
        self.drop = nnx.Dropout(0.1, rngs=rngs)
        self.l2 = nnx.Linear(dh, dout, rngs=rngs)
        self.calls = StepCounter(jnp.array(0, jnp.int32))

    def __call__(self, x):
        self.calls[...] += 1                           # 前向中原地修改自定义状态
        x = nnx.relu(self.bn(self.l1(x)))
        return self.l2(self.drop(x))

net = BNNet(16, 32, 4, rngs=nnx.Rngs(params=0, dropout=1))
graphdef, p_state, bn_state, rest = nnx.split(net, nnx.Param, nnx.BatchStat, ...)
print("Param    叶子:", jax.tree.map(jnp.shape, nnx.to_pure_dict(p_state)))
print("BatchStat 叶子:", jax.tree.map(jnp.shape, nnx.to_pure_dict(bn_state)))
print("其余状态:", list(nnx.to_pure_dict(rest).keys()))
assert set(nnx.to_pure_dict(bn_state)["bn"].keys()) == {"mean", "var"}
assert "calls" in nnx.to_pure_dict(rest) and "drop" in nnx.to_pure_dict(rest)
print("BatchStat 是 Variable 的子类:", issubclass(nnx.BatchStat, nnx.Variable), "| Param 同理:", issubclass(nnx.Param, nnx.Variable))
Param    叶子: {'bn': {'bias': (32,), 'scale': (32,)}, 'l1': {'bias': (32,), 'kernel': (16, 32)}, 'l2': {'bias': (4,), 'kernel': (32, 4)}}
BatchStat 叶子: {'bn': {'mean': (32,), 'var': (32,)}}
其余状态: ['calls', 'drop']
BatchStat 是 Variable 的子类: True | Param 同理: True

✅ Optax 组合优化器 + nnx.Optimizer 训练步

实际大模型训练中最常见的优化器配置是:全局范数梯度裁剪 + AdamW + 预热余弦学习率。Optax 用 optax.chain 把多个梯度变换串起来,状态就是各部分状态组成的元组。

optax.warmup_cosine_decay_schedule(init_value, peak_value, warmup_steps, decay_steps, end_value) 的公式($t$ 为步数,$W$ 为预热步数,$D$ 为含预热在内的总步数):

$$ \eta(t)=\begin{cases} \eta_0 + (\eta_{\max}-\eta_0)\,\dfrac{t}{W}, & t< W\\[2mm] \eta_{\text{end}} + \dfrac{\eta_{\max}-\eta_{\text{end}}}{2}\left(1+\cos\dfrac{\pi\,(t-W)}{D-W}\right), & W\le t\le D \end{cases} $$

schedule = optax.warmup_cosine_decay_schedule(init_value=0.0, peak_value=1e-3,
                                              warmup_steps=100, decay_steps=1000, end_value=1e-5)
for t in [0, 50, 100, 550, 1000, 2000]:
    print(f"step {t:5d}: lr = {float(schedule(t)):.3e}")

def cosine_ref(t, W=100, D=1000, peak=1e-3, end=1e-5):
    if t < W:
        return peak * t / W
    t = min(t, D)
    return end + (peak - end) / 2 * (1 + np.cos(np.pi * (t - W) / (D - W)))
for t in [0, 37, 100, 321, 550, 999, 1000, 2000]:
    assert np.isclose(float(schedule(t)), cosine_ref(t), rtol=1e-5, atol=1e-9), t

tx = optax.chain(
    optax.clip_by_global_norm(1.0),                       # 先裁剪
    optax.adamw(learning_rate=schedule, weight_decay=1e-4),
)

# 验证裁剪:手工把梯度缩到全局范数 1 后再喂给 adamw,结果应与 chain 完全一致
p0 = {"w": jnp.arange(6.0).reshape(2, 3), "b": jnp.ones(3)}
g0 = {"w": jnp.full((2, 3), 10.0), "b": jnp.full(3, -5.0)}
u_chain, _ = tx.update(g0, tx.init(p0), p0)
gn = optax.global_norm(g0)
g_clipped = jax.tree.map(lambda g: g / gn, g0)
aw = optax.adamw(learning_rate=schedule, weight_decay=1e-4)
u_manual, _ = aw.update(g_clipped, aw.init(p0), p0)
print(f"\n裁剪前全局范数 {float(gn):.2f} → 裁剪后 {float(optax.global_norm(g_clipped)):.2f}")
assert max_diff(u_chain, u_manual) < 1e-9
print("chain(clip, adamw) 与 手工裁剪 + adamw 一致 ✓")
step     0: lr = 0.000e+00
step    50: lr = 5.000e-04
step   100: lr = 1.000e-03
step   550: lr = 5.050e-04
step  1000: lr = 1.000e-05
step  2000: lr = 1.000e-05
裁剪前全局范数 25.98 → 裁剪后 1.00
chain(clip, adamw) 与 手工裁剪 + adamw 一致 ✓
# 用 nnx.Optimizer 训练 BNNet:一步之内参数、BatchStat、自定义计数器、优化器步数都被更新
rng = np.random.default_rng(0)
Xb = jnp.asarray(rng.normal(size=(64, 16)), jnp.float32)
Yb = jnp.asarray(rng.normal(size=(64, 4)), jnp.float32)

net = BNNet(16, 32, 4, rngs=nnx.Rngs(params=0, dropout=1))
opt = nnx.Optimizer(net, tx, wrt=nnx.Param)

@nnx.jit
def train_step(model, optimizer, x, y):
    def loss_fn(model):
        return jnp.mean((model(x) - y) ** 2)
    loss, grads = nnx.value_and_grad(loss_fn)(model)   # 只对 nnx.Param 求导(默认过滤器)
    optimizer.update(model, grads)                      # 原地更新参数与优化器状态
    return loss

bn_before = nnx.to_pure_dict(nnx.state(net, nnx.BatchStat))
w_before = net.l1.kernel[...]
losses = [float(train_step(net, opt, Xb, Yb)) for _ in range(5)]
print("loss:", np.round(losses, 4))
print("optimizer.step =", int(opt.step[...]), "| 自定义计数器 calls =", int(net.calls[...]))
assert int(opt.step[...]) == 5 and int(net.calls[...]) == 5
assert max_diff(bn_before, nnx.to_pure_dict(nnx.state(net, nnx.BatchStat))) > 0     # BN 统计量更新了
assert max_diff(w_before, net.l1.kernel[...]) > 0                                   # 参数更新了

net.eval()                        # 推理模式:BN 用滑动统计量、Dropout 关闭
y_eval1, y_eval2 = net(Xb), net(Xb)
assert max_diff(y_eval1, y_eval2) == 0.0
net.train()
print("eval 模式下前向是确定性的 ✓")
loss: [1.4642 1.4341 1.4713 1.4325 1.4904]
optimizer.step = 5 | 自定义计数器 calls = 5
eval 模式下前向是确定性的 ✓

✅ 手写 Adam 与 Optax 的对拍(见入门 07 专题 5)

手写 Adam(接口仿照 Optax:init / update 返回 (updates, new_state),$\Delta\theta=-\eta\,\hat m/(\sqrt{\hat v}+\epsilon)$)已在入门课 入门 06 状态管理 6.6 节的补充里实现过;手写 SGD / 动量 / Adam / AdamW / chain 并与 Optax 逐步对拍的完整版本见 入门 07 实战专题 专题 5。本章不再重复,下面直接使用 Optax。

分布式环境中的状态

课程用一张图说明了 pmap 数据并行时各类状态的去向:

  • 参数和优化器状态:标准数据并行里,它们在所有设备上都一样。在主机上初始化一次,复制到每个设备。每个设备算出梯度后,先用 lax.pmean 跨设备取平均,再在 pmap 函数内部做更新,这样各副本就保持一致。
  • PRNG key:这是一个常见又隐蔽的出错点。如果所有设备用同一个 key 做 dropout 或数据增强,生成的就是相同的随机数。课程的做法是:在主机上把主 key split 成每个设备一个子 key,组成数组传给 pmap;函数里再 split 出这一步要用的 key,并把新 key 返回,留给下一步用。

课程代码(pmap 中按设备拆 key):

# ❌ 旧写法(过时):用 pmap + 每设备一个 key;jit + 分片的全局视角下只需要一个 key(见下一个单元)
try:
    num_devices = jax.local_device_count()
    key = jax.random.PRNGKey(42)
    device_keys = jax.random.split(key, num_devices)       # 在主机上拆分一次

    @partial(jax.pmap, axis_name='devices')
    def dropout_pmap(local_key, x):
        dropout_key, new_local_key = jax.random.split(local_key)
        keep = jax.random.bernoulli(dropout_key, 0.5, x.shape)
        return jnp.where(keep, x / 0.5, 0.0), new_local_key

    xs = jnp.ones((num_devices, 4, 8))
    masked, device_keys = dropout_pmap(device_keys, xs)
    same = [bool(jnp.all(masked[0] == masked[i])) for i in range(1, num_devices)]
    print("实测:pmap 仍可运行;各设备掩码与设备 0 相同的个数 =", sum(same))
except Exception as e:
    print("实测报错:", type(e).__name__, e)
实测:pmap 仍可运行;各设备掩码与设备 0 相同的个数 = 0
# ✅ 推荐写法:jit + NamedSharding 是"全局视角"——对整个全局数组生成随机数,各设备自动拿到不同的切片
mesh_dp = jax.make_mesh((8,), ("data",))
x_global = jnp.ones((32, 8))
key = jax.random.key(42)

@jax.jit
def dropout_global(key, x):
    keep = jax.random.bernoulli(key, 0.5, x.shape)
    return jnp.where(keep, x / 0.5, 0.0)

with jax.set_mesh(mesh_dp):
    x_sharded = jax.device_put(x_global, P("data"))
    y_sharded = dropout_global(key, x_sharded)
    print("输入:", jax.typeof(x_sharded), "→ 输出:", jax.typeof(y_sharded))
y_single = dropout_global(key, x_global)

# jax_threefry_partitionable 默认为 True:分片计算与单设备计算得到完全相同的随机掩码
print("jax_threefry_partitionable =", jax.config.jax_threefry_partitionable)
assert jax.config.jax_threefry_partitionable
assert bool(jnp.all(y_sharded == y_single))
blocks = np.asarray(y_sharded).reshape(8, 4, 8)
assert not all(np.array_equal(blocks[0], blocks[i]) for i in range(1, 8))
print("分片版与单设备版掩码逐元素相同,且各设备的切片互不相同 ✓")
输入: float32[32@data,8] → 输出: float32[32@data,8]
jax_threefry_partitionable = True
分片版与单设备版掩码逐元素相同,且各设备的切片互不相同 ✓

序列化与检查点

训练可能要跑几小时到几周,必须定期把训练状态存盘,中断后才能恢复。需要保存:

  1. 模型参数;
  2. 优化器状态(带动量或自适应学习率的优化器必须保存,否则恢复后训练行为会变);
  3. 步数计数器(进度、学习率调度依赖它);
  4. PRNG key(可选但推荐,便于复现);
  5. 其他状态,如 BatchNorm 滑动统计量。

课程提到,Orbax(orbax.checkpoint)正在成为标准方案。它支持异步检查点(后台写盘,不阻塞训练),保存结构也很灵活。Flax 还有一个旧的 flax.training.checkpoints。课程代码在恢复后用 jax.device_put_replicated 把状态复制回各个设备:

# ❌ 旧写法(过时/错误):jax.device_put_replicated 已从 JAX 0.12 中移除
try:
    restored_state = {"w": jnp.ones(3)}
    state = jax.device_put_replicated(restored_state, jax.local_devices())
    print("实测:可以运行")
except AttributeError as e:
    print("实测报错:AttributeError:", e)
实测报错:AttributeError: jax.device_put_replicated is deprecated; use jax.device_put instead. See https://docs.jax.dev/en/latest/migrate_pmap.html#drop-in-replacements for a drop-in replacement.
# ✅ 推荐写法:Orbax 保存 / 恢复 NNX 状态;恢复时按"抽象状态"(形状 + dtype + 分片)直接放到目标设备上
import orbax.checkpoint as ocp

ckpt_dir = tempfile.mkdtemp(prefix="adv06_ckpt_")
net_src = BNNet(16, 32, 4, rngs=nnx.Rngs(params=0, dropout=1))
opt_src = nnx.Optimizer(net_src, tx, wrt=nnx.Param)
train_step(net_src, opt_src, Xb, Yb)

def pack(model, optimizer):
    """要存盘的训练状态:参数、BN 统计量、优化器(含步数)。都是纯 PyTree。"""
    return {"params": nnx.state(model, nnx.Param),
            "batch_stats": nnx.state(model, nnx.BatchStat),
            "optimizer": nnx.state(optimizer)}

checkpointer = ocp.StandardCheckpointer()
checkpointer.save(os.path.join(ckpt_dir, "step_1"), pack(net_src, opt_src))
checkpointer.wait_until_finished()                 # 保存是异步的,读回前要等它完成

# 恢复:先构造"抽象"模型(只有形状,不分配内存),用它的状态做恢复模板
abs_net = nnx.eval_shape(lambda: BNNet(16, 32, 4, rngs=nnx.Rngs(params=0, dropout=1)))
abs_opt = nnx.eval_shape(lambda: nnx.Optimizer(abs_net, tx, wrt=nnx.Param))
restored = checkpointer.restore(os.path.join(ckpt_dir, "step_1"), pack(abs_net, abs_opt))

net_new = BNNet(16, 32, 4, rngs=nnx.Rngs(params=123, dropout=1))   # 随便初始化
opt_new = nnx.Optimizer(net_new, tx, wrt=nnx.Param)
nnx.update(net_new, restored["params"], restored["batch_stats"])   # 把恢复的状态写回
nnx.update(opt_new, restored["optimizer"])
print("恢复后的 optimizer.step =", int(opt_new.step[...]))
assert int(opt_new.step[...]) == 1
assert max_diff(nnx.state(net_new, nnx.Param), nnx.state(net_src, nnx.Param)) == 0.0
assert max_diff(nnx.state(net_new, nnx.BatchStat), nnx.state(net_src, nnx.BatchStat)) == 0.0
assert max_diff(nnx.state(opt_new), nnx.state(opt_src)) == 0.0
print("Orbax 保存 → 恢复:参数、BN 统计量、优化器状态逐位一致 ✓")
恢复后的 optimizer.step = 1
Orbax 保存 → 恢复:参数、BN 统计量、优化器状态逐位一致 ✓

⚠️ 注意:jax.device_put_replicated(restored_state, jax.local_devices()) 这种写法在 JAX 0.12 里已经不能用了。这个函数(还有 jax.device_put_sharded)已被移除,调用会报 AttributeError,错误信息提示改用 jax.device_put。用 jit + 分片的写法时,连"复制"这一步都不需要:恢复模板里带上 NamedSharding,Orbax 会直接把数据放到对应的设备上。

🔍 补充:检查点的几个实战要点

⚪ 选读:要做长时间训练、需要断点续训时

  • 步数一定要存。学习率调度、optax.MultiSteps 的累积计数都靠它。只存参数、不存优化器状态和步数的话,恢复后学习率会从头开始预热。
  • 恢复模板用 nnx.eval_shape:它只追踪形状,不真正初始化,百亿参数的模型也不占内存。把模板里的 sharding 设好,就能直接按分片恢复(每个进程只读自己那一份)。
  • 数据加载器的位置也属于训练状态(Grain 的迭代器可以存档),否则恢复后会重复或跳过数据。

6.4 将 pmap 与训练框架结合使用

框架(Flax / Haiku)负责模型定义和状态管理,pmap 负责把计算分发到多个设备。两者结合起来,就是课程介绍的数据并行训练。核心仍是 SPMD(单程序多数据):按单设备的写法写函数,pmap 让它在每个设备上同时运行,每个设备处理输入的不同切片。(pmap、axis_name、pmean 的基础见 入门 05 多设备并行pmap。)

课程的流程分四步:

  1. 复制模型状态:在主机上初始化参数和优化器状态,打包成 TrainState,复制到所有设备。复制后,每个叶子会多出一个大小等于设备数的前导维度。
  2. 数据分片:全局 batch $B$ 平均分给 $N$ 个设备,每个设备 $B/N$ 条。用 tree_map 把 batch 里的每个数组 reshape 成 (N, B/N, ...),这一步必须在调用 pmap 函数之前做完。
  3. 定义并映射训练步:每个设备用本地数据算损失和梯度。更新前必须用 lax.pmean(grads, axis_name) 跨设备平均梯度,然后用 state.apply_gradients 更新。jax.pmap(train_step, axis_name='batch_axis') 把 axis_name 和函数里的 pmean 对应起来。返回的状态在各设备上一致,损失是各设备自己的本地值(形状 (N,))。准确率之类的指标也可以 pmean。
  4. 处理 RNG:dropout 这类随机操作不能让所有设备用同一个 key。在主机上 split 出每个设备一个子 key,传进去。

下面先运行课程第 1 步的代码。

import flax.linen as nn
from flax.training import train_state

class LinenCNN(nn.Module):               # 这里补一个最小模型作为 model
    @nn.compact
    def __call__(self, x):
        x = x.reshape((x.shape[0], -1))
        x = nn.relu(nn.Dense(32)(x))
        return nn.Dense(10)(x)

model = LinenCNN()
key = jax.random.key(0)
lparams = model.init(key, jnp.ones([1, 28, 28, 1]))['params']
tx_adam = optax.adam(learning_rate=1e-3)
state = train_state.TrainState.create(apply_fn=model.apply, params=lparams, tx=tx_adam)
num_devices = jax.local_device_count()
# ❌ 旧写法(过时/错误):jax.device_put_replicate 拼写有误,且正确拼写的 jax.device_put_replicated 也已在 0.12 中移除
for fn_name in ["device_put_replicate", "device_put_replicated"]:
    try:
        replicated_state = getattr(jax, fn_name)(state, jax.local_devices())
        print(fn_name, "→ 可以运行")
    except AttributeError as e:
        print(f"jax.{fn_name} → AttributeError: {str(e)[:110]}")
jax.device_put_replicate → AttributeError: module 'jax' has no attribute 'device_put_replicate'
jax.device_put_replicated → AttributeError: jax.device_put_replicated is deprecated; use jax.device_put instead. See https://docs.jax.dev/en/latest/migrat
# ✅ 推荐写法(如果仍要用 pmap):按官方迁移指南,用 jax.device_put 放一个"堆叠后按设备切分"的数组
def replicate_for_pmap(tree, devices):
    mesh = jax.sharding.Mesh(np.array(devices), ("d",))
    n = len(devices)
    return jax.tree.map(
        lambda x: jax.device_put(jnp.broadcast_to(x, (n,) + jnp.shape(x)), NamedSharding(mesh, P("d"))),
        tree)

replicated_state = replicate_for_pmap(state, jax.local_devices())
print(f"状态已在 {num_devices} 个设备上复制。")
print("复制后 kernel 形状:", replicated_state.params['Dense_0']['kernel'].shape)
print("原始   kernel 形状:", state.params['Dense_0']['kernel'].shape)
assert replicated_state.params['Dense_0']['kernel'].shape == (num_devices,) + state.params['Dense_0']['kernel'].shape
状态已在 8 个设备上复制。
复制后 kernel 形状: (8, 784, 32)
原始   kernel 形状: (784, 32)
# 课程第 2、3 步:数据分片 + pmap 训练步
global_batch_size = 64
local_batch_size = global_batch_size // num_devices

def shard_batch(batch):
    """把 (B, ...) 重塑为 (设备数, B/设备数, ...)。"""
    return jax.tree.map(lambda x: x.reshape((num_devices, local_batch_size) + x.shape[1:]), batch)

rng = np.random.default_rng(0)
global_images = jnp.asarray(rng.normal(size=(global_batch_size, 28, 28, 1)), jnp.float32)
global_labels = jnp.asarray(rng.integers(0, 10, global_batch_size), jnp.int32)
batch = {'image': global_images, 'label': global_labels}
sharded_batch = shard_batch(batch)
print("全局图像形状:", global_images.shape, "→ 分片图像形状:", sharded_batch['image'].shape)

def compute_loss(params, batch, apply_fn):
    logits = apply_fn({'params': params}, batch['image'])
    one_hot = jax.nn.one_hot(batch['label'], num_classes=10)
    return jnp.mean(-jnp.sum(one_hot * jax.nn.log_softmax(logits), axis=-1))

def train_step(state, batch):
    loss, grads = jax.value_and_grad(compute_loss)(state.params, batch, state.apply_fn)
    grads = jax.lax.pmean(grads, axis_name='batch_axis')      # 关键:跨设备平均梯度
    return state.apply_gradients(grads=grads), loss
全局图像形状: (64, 28, 28, 1) → 分片图像形状: (8, 8, 28, 28, 1)

现代写法:NNX + Optax + jit + NamedSharding

下面先运行课程的 pmap 训练步作对照,紧接着给出现代写法。JAX 0.12 推荐用 全局视角 写数据并行:

  • 建一个 Mesh,把 batch 维按 P('data') 分到各设备。参数不加分片注解,就是复制的。
  • 训练步就是普通的单设备代码,用 nnx.jit(或 jax.jit)编译。编译器看到"数据分片、参数复制",会自动插入梯度的 all-reduce。不需要 pmean,也不用手工 reshape 出设备维、手工复制状态。
  • 返回的 loss 是全局 batch 的平均,而不是每个设备一份。

jax.make_mesh 在 0.12 里默认创建 Explicit 类型的轴:分片是数组类型的一部分,jax.typeof(x) 会显示成 float32[64@data,28,28,1] 这种形式(@data 表示这一维按 data 轴切分)。

# ❌ 旧写法(过时):pmap 在 0.12 仍可用,但官方建议迁移到 jit + 分片(或 shard_map)
try:
    p_train_step = jax.pmap(train_step, axis_name='batch_axis')
    pmap_state, local_losses = p_train_step(replicated_state, sharded_batch)
    print("实测:pmap 可以运行;local_losses 形状", local_losses.shape, "| 平均损失", f"{float(local_losses.mean()):.4f}")
    print("pmap 输出数组的分片类型:", type(local_losses.sharding).__name__, local_losses.sharding.spec)
except Exception as e:
    print("实测报错:", type(e).__name__, e)
实测:pmap 可以运行;local_losses 形状 (8,) | 平均损失 2.4516
pmap 输出数组的分片类型: NamedSharding P('batch_axis',)
# ✅ 推荐写法:NNX + Optax + jit + NamedSharding 数据并行(不需要 pmean / 手工 reshape / 复制状态)
class NNXMLP(nnx.Module):
    def __init__(self, *, rngs):
        self.l1 = nnx.Linear(784, 32, rngs=rngs)
        self.l2 = nnx.Linear(32, 10, rngs=rngs)
    def __call__(self, x):
        return self.l2(nnx.relu(self.l1(x.reshape(x.shape[0], -1))))

@nnx.jit
def nnx_train_step(model, optimizer, x, y):
    def loss_fn(model):
        return optax.softmax_cross_entropy_with_integer_labels(model(x), y).mean()
    loss, grads = nnx.value_and_grad(loss_fn)(model)
    optimizer.update(model, grads)
    return loss

mesh_dp = jax.make_mesh((8,), ("data",))
print("mesh:", mesh_dp)
with jax.set_mesh(mesh_dp):
    dp_model = NNXMLP(rngs=nnx.Rngs(0))
    dp_opt = nnx.Optimizer(dp_model, optax.adam(1e-3), wrt=nnx.Param)
    x_dp = jax.device_put(global_images, P("data"))          # batch 维分片
    y_dp = jax.device_put(global_labels, P("data"))
    print("x:", jax.typeof(x_dp), "| kernel:", jax.typeof(dp_model.l1.kernel[...]))
    dp_losses = [float(nnx_train_step(dp_model, dp_opt, x_dp, y_dp)) for _ in range(3)]
    jax.debug.visualize_array_sharding(x_dp.reshape(64, -1)[:, :8])

# 单设备参照:同样的初始化、同样的数据
ref_model = NNXMLP(rngs=nnx.Rngs(0))
ref_opt = nnx.Optimizer(ref_model, optax.adam(1e-3), wrt=nnx.Param)
ref_losses = [float(nnx_train_step(ref_model, ref_opt, global_images, global_labels)) for _ in range(3)]
print("数据并行 loss:", np.round(dp_losses, 6))
print("单设备   loss:", np.round(ref_losses, 6))
d = max_diff(nnx.state(dp_model, nnx.Param), nnx.state(ref_model, nnx.Param))
print(f"3 步后参数最大差异:{d:.2e}")
assert np.allclose(dp_losses, ref_losses, rtol=1e-5) and d < 1e-5

# 看一眼编译器自动插入的通信
with jax.set_mesh(mesh_dp):
    hlo = nnx_train_step.lower(dp_model, dp_opt, x_dp, y_dp).compile().as_text()
print("自动插入的集合通信:", collectives(hlo))
assert collectives(hlo)["all-reduce"] >= 1
mesh: Mesh('data': 8, axis_types=(Explicit,))
x: float32[64@data,28,28,1] | kernel: float32[784,32]
  CPU 0  
         
  CPU 1  
         
  CPU 2  
         
  CPU 3  
         
  CPU 4  
         
  CPU 5  
         
  CPU 6  
         
  CPU 7  
         
数据并行 loss: [2.569236 2.263043 1.98849 ]
单设备   loss: [2.569236 2.263043 1.98849 ]
3 步后参数最大差异:2.98e-08
自动插入的集合通信: {'all-reduce': 1, 'all-gather': 0, 'reduce-scatter': 0, 'all-to-all': 0, 'collective-permute': 0}
# pmap 版与现代写法也一致(课程的 Linen 模型 vs 单设备 jit)
@jax.jit
def single_step(state, batch):
    loss, g = jax.value_and_grad(compute_loss)(state.params, batch, state.apply_fn)
    return state.apply_gradients(grads=g), loss

s1, l1 = single_step(state, batch)
d = max_diff(jax.tree.map(lambda x: x[0], pmap_state.params), s1.params)
print(f"pmap 第 0 个副本 vs 单设备:参数差 {d:.2e};平均 loss {float(local_losses.mean()):.6f} vs {float(l1):.6f}")
assert d < 1e-6 and np.isclose(float(local_losses.mean()), float(l1), rtol=1e-6)
# 所有副本完全一致
assert all(max_diff(jax.tree.map(lambda x: x[i], pmap_state.params),
                    jax.tree.map(lambda x: x[0], pmap_state.params)) == 0 for i in range(num_devices))
pmap 第 0 个副本 vs 单设备:参数差 7.08e-08;平均 loss 2.451626 vs 2.451626

✅ 参数分片注解:nnx.with_partitioning

数据并行里参数是复制的。参数太大时,就要把参数本身也切开(6.8 节细讲)。NNX 的做法是在初始化器上加分片注解:nnx.with_partitioning(初始化器, (轴名, ...))。元组的每一项对应参数的一个维度,None 表示这一维不切。

  • 在 jax.set_mesh(mesh) 上下文里构造模块时,NNX 会立即按注解把参数放到对应设备上(eager sharding)。没有 mesh 上下文时,构造带注解的参数会报错。
  • nnx.get_partition_spec(state) 取出每个变量的 PartitionSpec。
  • 优化器状态自动继承参数的分片:nnx.Optimizer 用 zeros_like 创建 $m$、$v$,形状和分片都与参数一致。
mesh2d = jax.make_mesh((2, 4), ("data", "model"))

class ShardedMLP(nnx.Module):
    def __init__(self, din, dh, dout, *, rngs):
        init = nnx.initializers.lecun_normal()
        # 第一层按列切(输出维 → model 轴),第二层按行切(输入维 → model 轴)
        self.up = nnx.Linear(din, dh, use_bias=False,
                             kernel_init=nnx.with_partitioning(init, (None, "model")), rngs=rngs)
        self.down = nnx.Linear(dh, dout, use_bias=False,
                               kernel_init=nnx.with_partitioning(init, ("model", None)), rngs=rngs)
    def __call__(self, x):
        h = nnx.relu(self.up(x))
        # Explicit 模式下,收缩维被切分的矩阵乘需要指明输出分片(这里求和后按 batch 分片)
        return self.down(h, out_sharding=P("data", None))

try:
    ShardedMLP(64, 256, 64, rngs=nnx.Rngs(0))
except ValueError as e:
    print("没有 mesh 上下文 → ValueError:", str(e)[:100], "...")

with jax.set_mesh(mesh2d):
    smodel = ShardedMLP(64, 256, 64, rngs=nnx.Rngs(0))
    sopt = nnx.Optimizer(smodel, optax.adam(1e-3), wrt=nnx.Param)
    print("分片注解:", jax.tree.map(str, nnx.to_pure_dict(nnx.get_partition_spec(nnx.state(smodel)))))
    print("up.kernel   :", jax.typeof(smodel.up.kernel[...]))
    print("down.kernel :", jax.typeof(smodel.down.kernel[...]))
    mu_up = sopt.opt_state[0].mu["up"]["kernel"][...]
    print("Adam mu(up) :", jax.typeof(mu_up))
    assert mu_up.sharding.spec == smodel.up.kernel[...].sharding.spec == P(None, "model")
    # 每个设备只存 1/4 的 up.kernel
    k = smodel.up.kernel[...]
    print(f"up.kernel 全局 {k.nbytes} B,每设备 {k.addressable_shards[0].data.nbytes} B")
    assert k.addressable_shards[0].data.nbytes * 4 == k.nbytes
    jax.debug.visualize_array_sharding(k)
没有 mesh 上下文 → ValueError: An auto mesh context or metadata is required if creating a variable with annotation out_sharding=P(N ...
分片注解: {'down': {'kernel': "P('model', None)"}, 'up': {'kernel': "P(None, 'model')"}}
up.kernel   : float32[64,256@model]
down.kernel : float32[256@model,64]
Adam mu(up) : float32[64,256@model]
up.kernel 全局 65536 B,每设备 16384 B
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
         C…                  C…                  C…                  C…         
         0…                  1…                  2…                  3…         
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                

🔍 补充:从 pmap 迁移到 jit + 分片的要点

⚪ 选读:要维护或迁移 pmap 旧代码时

pmap 写法jit + NamedSharding 写法
手工 reshape 出设备维 (N, B/N, ...)全局数组 (B, ...),device_put(x, P('data'))
device_put_replicated 复制状态(0.12 已移除)不加注解的参数默认就是复制的
函数内 lax.pmean(grads, 'batch')编译器根据分片自动插入 all-reduce
每设备一个 PRNG key一个 key,对全局形状采样(jax_threefry_partitionable=True)
返回值带设备维,需要 x[0] 取回返回的就是全局值
只能做一维的设备映射任意维度的 mesh(data × model × ...),参数也能分片
需要逐设备手写代码时用 jax.shard_map(显式写 psum 等集合通信)

还有一点:0.12 里 pmap 的输出已经是 NamedSharding(上面打印的 P('batch_axis',)),说明 pmap 内部也建在同一套分片机制上。新代码直接写 jit 就行。如果要用 shard_map 一对一翻译 pmap 训练步,最容易踩的坑是「梯度悄悄变成设备数 $N$ 倍」,完整的 pmap → shard_map 迁移清单见 入门 05 多设备并行pmap 的「shard_map 中梯度变成 N 倍」专题。


6.5 梯度累积

训练大模型时,想要的 batch 可能放不进单个加速器的内存。就算放得下,为了训练稳定,也可能需要更大的 batch。梯度累积的思路是:依次处理几个小的微批次(micro-batch),把梯度加起来,最后只做一次优化器更新。这样,"参与一次更新的样本数"和"同时待在内存里的样本数"就分开了。

工作原理

设有效 batch 为 $B$、微批次大小为 $b$,累积步数 $N$ 满足 $B=N\times b$。

  1. 初始化一个与参数同结构、全为 0 的累积梯度;
  2. 循环 $N$ 次:取一个微批次 → 前向 → 求梯度 → 加到累积梯度上;
  3. 循环结束后把累积梯度除以 $N$,用它更新参数,并把累积梯度清零。

$$ \nabla_\theta \mathcal L_{\text{eff}}=\frac1N\sum_{i=1}^{N}\nabla_\theta \mathcal L(\text{微批次}_i;\theta) $$

主要好处是:算梯度时,内存里一次只需要放一个微批次的激活。

⚠️ 注意:累积的平均梯度不只是"近似"大 batch 的梯度。只要损失按样本取均值、各微批次一样大、模型里没有 BatchNorm 这类跨样本运算,两者在数学上完全相等,数值上只差 float32 舍入(约 $10^{-7}$)。下面用 assert 验证这一点,再演示真正会产生差异的三种情况:求和损失、微批次不等大、BatchNorm。

课程的实现

课程先给出普通的 train_step,再把累积版拆成两部分。一部分是 jit 编译的 micro_batch_step,算一个微批次的梯度,用 has_aux=True 带出 logits。另一部分是 Python 循环 accumulated_train_step:用 tree_map 累加、求平均,并在函数内部定义一个 @jax.jit 的 apply_update 来应用优化器。课程认为数据加载一般不放在 jit 里,所以循环不整体编译;也提到可以用 lax.scan 把累积循环放进一个 jit 函数,但"需要仔细管理状态和数据加载"。

先准备一个小模型和数据(参数、数据都作为参数传进 jit 函数)。

rng = np.random.default_rng(0)
X_acc = jnp.asarray(rng.normal(size=(64, 16)), jnp.float32)
Y_acc = jnp.asarray(rng.normal(size=(64, 1)), jnp.float32)
acc_params = {"w1": jnp.asarray(rng.normal(size=(16, 32)) * 0.3, jnp.float32), "b1": jnp.zeros(32),
              "w2": jnp.asarray(rng.normal(size=(32, 1)) * 0.3, jnp.float32), "b2": jnp.zeros(1)}

def mlp_apply(p, x):
    return jnp.tanh(x @ p["w1"] + p["b1"]) @ p["w2"] + p["b2"]

def loss_fn(p, x, y):
    return jnp.mean((mlp_apply(p, x) - y) ** 2)     # 按样本取均值

acc_tx = optax.adam(1e-2)
N_ACC = 4

def micro_batches(x, y, n):
    return [(x[i * len(x) // n:(i + 1) * len(x) // n], y[i * len(y) // n:(i + 1) * len(y) // n]) for i in range(n)]
# ❌ 旧写法(错误):apply_update 定义在函数内部,每次调用都是一个新的函数对象 → 每一步都重新追踪/编译
trace_count = {"n": 0}

@jax.jit
def micro_batch_step(params, x, y):
    return jax.value_and_grad(loss_fn)(params, x, y)

def accumulated_train_step(params, opt_state, data_iterator, accumulation_steps):
    accumulated_grads = jax.tree.map(jnp.zeros_like, params)
    total_loss = 0.0
    for _ in range(accumulation_steps):
        x, y = next(data_iterator)
        loss, grads = micro_batch_step(params, x, y)
        accumulated_grads = jax.tree.map(lambda a, g: a + g, accumulated_grads, grads)
        total_loss += loss
    averaged_grads = jax.tree.map(lambda g: g / accumulation_steps, accumulated_grads)

    @jax.jit
    def apply_update(p, o_state, avg_grads):
        trace_count["n"] += 1                       # 只在追踪时执行
        updates, new_o_state = acc_tx.update(avg_grads, o_state, p)
        return optax.apply_updates(p, updates), new_o_state

    params, opt_state = apply_update(params, opt_state, averaged_grads)
    return params, opt_state, total_loss / accumulation_steps

try:
    p_course, s_course = acc_params, acc_tx.init(acc_params)
    for step in range(5):
        p_course, s_course, _ = accumulated_train_step(p_course, s_course,
                                                       iter(micro_batches(X_acc, Y_acc, N_ACC)), N_ACC)
    print(f"实测:5 次训练步,apply_update 被追踪了 {trace_count['n']} 次")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
assert trace_count["n"] == 5
实测:5 次训练步,apply_update 被追踪了 5 次
# ✅ 推荐写法:整个累积循环放进一个 jit,用 lax.scan 遍历微批次(数据先 reshape 成 (N, b, ...) 传入)
@partial(jax.jit, static_argnames="n_micro")
def accum_grads_scan(params, x, y, n_micro):
    xs = x.reshape(n_micro, -1, *x.shape[1:])
    ys = y.reshape(n_micro, -1, *y.shape[1:])
    def body(carry, mb):
        acc, loss_sum = carry
        loss, g = jax.value_and_grad(loss_fn)(params, *mb)
        return (jax.tree.map(jnp.add, acc, g), loss_sum + loss), None
    init = (jax.tree.map(jnp.zeros_like, params), 0.0)
    (acc, loss_sum), _ = lax.scan(body, init, (xs, ys))
    return jax.tree.map(lambda a: a / n_micro, acc), loss_sum / n_micro

@partial(jax.jit, static_argnames="n_micro", donate_argnums=(0, 1))
def accum_train_step(params, opt_state, x, y, n_micro):
    grads, loss = accum_grads_scan(params, x, y, n_micro)
    updates, opt_state = acc_tx.update(grads, opt_state, params)
    return optax.apply_updates(params, updates), opt_state, loss

g_big = jax.jit(jax.grad(loss_fn))(acc_params, X_acc, Y_acc)      # 一次性用整个 batch
g_acc, _ = accum_grads_scan(acc_params, X_acc, Y_acc, N_ACC)
d = max_diff(g_big, g_acc)
print(f"lax.scan 累积 4 个微批次 vs 大 batch:梯度最大差 {d:.2e}")
assert d < 1e-6

# 与上面逐步累积的写法结果也一致(训练 5 步)
p_new, s_new = jax.tree.map(jnp.copy, acc_params), acc_tx.init(acc_params)
for step in range(5):
    p_new, s_new, _ = accum_train_step(p_new, s_new, X_acc, Y_acc, n_micro=N_ACC)
print(f"5 步后 scan 版 vs 逐步累积版参数差:{max_diff(p_new, p_course):.2e}")
assert max_diff(p_new, p_course) < 1e-5
lax.scan 累积 4 个微批次 vs 大 batch:梯度最大差 3.58e-07
5 步后 scan 版 vs 逐步累积版参数差:2.98e-08

✅ optax.MultiSteps:把累积做成优化器的一部分

optax.MultiSteps(opt, every_k_schedule=k) 可以包装任意优化器。前 $k-1$ 次 update 只把梯度累积进自己的状态,返回全零更新;第 $k$ 次才用平均梯度(use_grad_mean=True,默认)调用内层优化器,并清零累积器。训练循环完全不用改,每个微批次照常调用一次训练步就行。代价是状态里多一份和参数一样大的累积缓冲区(可以把 accumulator_dtype 设成 bf16 省内存)。

ms = optax.MultiSteps(optax.adam(1e-2), every_k_schedule=N_ACC)
ms_state = ms.init(acc_params)
p_ms = acc_params

@jax.jit
def ms_step(p, s, x, y):
    g = jax.grad(loss_fn)(p, x, y)
    u, s = ms.update(g, s, p)
    return optax.apply_updates(p, u), s, u

for i, (xb, yb) in enumerate(micro_batches(X_acc, Y_acc, N_ACC)):
    p_ms, ms_state, u = ms_step(p_ms, ms_state, xb, yb)
    print(f"微批次 {i}: |update|max = {float(max(jnp.abs(l).max() for l in jax.tree.leaves(u))):.4f}, "
          f"mini_step = {int(ms_state.mini_step)}, gradient_step = {int(ms_state.gradient_step)}")
    if i < N_ACC - 1:
        assert all(float(jnp.abs(l).max()) == 0.0 for l in jax.tree.leaves(u))   # 前 k-1 次是零更新

# 与"一次大 batch 的 Adam 更新"一致
adam = optax.adam(1e-2)
u_big, _ = adam.update(g_big, adam.init(acc_params), acc_params)
p_big = optax.apply_updates(acc_params, u_big)
print(f"MultiSteps(4) vs 大 batch Adam:参数差 {max_diff(p_ms, p_big):.2e}")
assert max_diff(p_ms, p_big) < 1e-6
print("MultiSteps 额外状态(累积缓冲区)字节数 / 参数字节数 =",
      tree_bytes(ms_state.acc_grads) / tree_bytes(acc_params))
微批次 0: |update|max = 0.0000, mini_step = 1, gradient_step = 0
微批次 1: |update|max = 0.0000, mini_step = 2, gradient_step = 0
微批次 2: |update|max = 0.0000, mini_step = 3, gradient_step = 0
微批次 3: |update|max = 0.0100, mini_step = 0, gradient_step = 1
MultiSteps(4) vs 大 batch Adam:参数差 7.45e-09
MultiSteps 额外状态(累积缓冲区)字节数 / 参数字节数 = 1.0

与 pmap 的配合,以及权衡

  • 与数据并行结合:每个设备在自己的数据分片上独立累积 $N$ 步,然后用 lax.pmean 跨设备平均累积后的梯度(只通信一次),每个设备再用全局平均梯度做同样的更新。每个设备只需要放下 $b/\text{设备数}$ 的激活,有效 batch 仍然是 $B$。
  • 内存 vs 计算:峰值内存明显降低,有效 batch 可以更大;代价是每次更新要做 $N$ 次前向和反向。如果更大的 batch 让收敛需要的步数变少,总计算量不一定会增加。
  • 不节省参数和优化器状态的内存:累积只省和激活有关的内存。参数、Adam 动量这些,还得靠混合精度或优化器状态分片(6.7、6.8)。
  • BatchNorm:统计量按微批次算,微批次小的时候噪声大。可以换成 LayerNorm,或者跨设备同步 BN 统计量(更复杂)。
  • 学习率:梯度已经在 $N$ 步上取了平均,所以理论上学习率一般不用大改,但可能还要凭经验调一调。

下面用现代写法实现"数据并行 + 梯度累积":数据按 data 轴分片,微批次维放在最前面,由 scan 遍历,每个微批次本身也按 batch 维分片。整个函数还是单设备的写法。

with jax.set_mesh(mesh_dp):
    # 形状 (N, b, ...):微批次维不切,batch 维按 data 轴切
    xs_dp = jax.device_put(X_acc.reshape(N_ACC, -1, 16), P(None, "data"))
    ys_dp = jax.device_put(Y_acc.reshape(N_ACC, -1, 1), P(None, "data"))
    print("微批次数据:", jax.typeof(xs_dp))

    @jax.jit
    def dp_accum(params, xs, ys):
        def body(acc, mb):
            return jax.tree.map(jnp.add, acc, jax.grad(loss_fn)(params, *mb)), None
        acc, _ = lax.scan(body, jax.tree.map(jnp.zeros_like, params), (xs, ys))
        return jax.tree.map(lambda a: a / xs.shape[0], acc)

    g_dp = dp_accum(acc_params, xs_dp, ys_dp)
    hlo = dp_accum.lower(acc_params, xs_dp, ys_dp).compile().as_text()
print(f"数据并行 + 累积 vs 单设备大 batch:梯度差 {max_diff(g_dp, g_big):.2e};集合通信 {collectives(hlo)}")
assert max_diff(g_dp, g_big) < 1e-6
微批次数据: float32[4,16@data,16]
数据并行 + 累积 vs 单设备大 batch:梯度差 2.38e-07;集合通信 {'all-reduce': 1, 'all-gather': 0, 'reduce-scatter': 0, 'all-to-all': 0, 'collective-permute': 0}

🔍 补充:累积什么时候不等于大 batch?

🟢 必读

  1. 求和损失:如果损失是 $\sum$ 而不是均值,大 batch 的梯度就是各微批次梯度的和。这时再除以 $N$ 就错了:梯度小了 $N$ 倍,相当于学习率被悄悄除以了 $N$。
  2. 微批次不等大(比如最后一个 batch 不满,或者语言模型按 token 数算损失,每个微批次的有效 token 数不一样):对"每个微批次的均值"直接取平均,等于给样本少的微批次更大的权重。正确做法是按样本数(或 token 数)加权:累积 $\sum$(损失和),最后除以总样本数。
  3. BatchNorm:统计量按微批次算,前向本身就不一样,梯度不可能一致。BN 的滑动统计量也会被更新 $N$ 次,而不是 1 次。
mbs = micro_batches(X_acc, Y_acc, N_ACC)

# 1) 求和损失:累积后应该"相加",而不是"取平均"
def loss_sum(p, x, y):
    return jnp.sum((mlp_apply(p, x) - y) ** 2)
gs_big = jax.grad(loss_sum)(acc_params, X_acc, Y_acc)
gs_list = [jax.grad(loss_sum)(acc_params, xb, yb) for xb, yb in mbs]
gs_sum = jax.tree.map(lambda *g: sum(g), *gs_list)
gs_mean = jax.tree.map(lambda *g: sum(g) / N_ACC, *gs_list)
print(f"求和损失:累加 {max_diff(gs_sum, gs_big):.2e}  |  错误地取平均 {max_diff(gs_mean, gs_big):.2e}")
assert max_diff(gs_sum, gs_big) < 1e-4 and max_diff(gs_mean, gs_big) > 1.0

# 2) 微批次不等大:48 + 16 条
uneq = [(X_acc[:48], Y_acc[:48]), (X_acc[48:], Y_acc[48:])]
g_list = [jax.grad(loss_fn)(acc_params, xb, yb) for xb, yb in uneq]
g_naive = jax.tree.map(lambda a, b: (a + b) / 2, *g_list)
g_weighted = jax.tree.map(lambda a, b: (48 * a + 16 * b) / 64, *g_list)
print(f"不等大微批次:简单平均 {max_diff(g_naive, g_big):.2e}  |  按样本数加权 {max_diff(g_weighted, g_big):.2e}")
assert max_diff(g_weighted, g_big) < 1e-6 and max_diff(g_naive, g_big) > 1e-3

# 3) BatchNorm:大 batch 与 4 个微批次的梯度、滑动统计量都不同
class BNReg(nnx.Module):
    def __init__(self, *, rngs):
        self.l = nnx.Linear(16, 8, rngs=rngs); self.bn = nnx.BatchNorm(8, rngs=rngs); self.o = nnx.Linear(8, 1, rngs=rngs)
    def __call__(self, x):
        return self.o(nnx.relu(self.bn(self.l(x))))

@nnx.jit
def bn_grad(model, x, y):
    return nnx.grad(lambda m: jnp.mean((m(x) - y) ** 2))(model)

m_big, m_micro = BNReg(rngs=nnx.Rngs(0)), BNReg(rngs=nnx.Rngs(0))
gb = bn_grad(m_big, X_acc, Y_acc)
gm = jax.tree.map(lambda *g: sum(g) / N_ACC, *[bn_grad(m_micro, xb, yb) for xb, yb in mbs])
d_grad = max_diff(gb, gm)
d_stat = max_diff(m_big.bn.mean[...], m_micro.bn.mean[...])
print(f"BatchNorm:梯度差 {d_grad:.3f}(梯度量级 {float(jnp.abs(gb['l']['kernel'][...]).max()):.3f}),滑动均值差 {d_stat:.4f}")
assert d_grad > 1e-2 and d_stat > 1e-3
求和损失:累加 2.29e-05  |  错误地取平均 6.66e+01
不等大微批次:简单平均 2.89e-01  |  按样本数加权 3.58e-07
BatchNorm:梯度差 0.161(梯度量级 0.253),滑动均值差 0.0086

6.6 梯度检查点(再物化)

标准反向传播要把前向的中间激活全部存下来,留给反向用。模型又深又宽时,这些激活可能超出设备内存。梯度检查点(gradient checkpointing,也叫激活检查点、再物化 rematerialization)的做法是:前向时只留一部分激活;反向需要某个没存的激活时,从最近一个存下的激活出发,把它重新算出来。

  • 收益:内存大幅降低;
  • 代价:每步计算量增加,因为反向时要重跑一部分前向。

工作原理

  • 标准前向:输入 → 块 1 → 激活 1 → 块 2 → 激活 2 → … → 块 N → 输出,所有激活都存着。
  • 检查点前向:只存选定的激活(比如激活 2、激活 K、输出),其余的丢掉。
  • 反向:算块 3 的梯度时,直接用存下的激活 2;算块 1、2 的梯度时需要激活 1,它没存,于是从更早的检查点(输入)出发重算块 1。指定好检查点边界后,重算的细节由 JAX 自动处理。

jax.checkpoint(jax.remat)

jax.checkpoint 的别名是 jax.remat(re-materialization),可以当函数包装器用,也可以当装饰器用。被包装函数内部的中间结果不会为反向保存。自动微分知道这些值没存,会在反向时自动重算。课程示例:

def compute_intensive_block(x, params):
    """代表多层或复杂操作的计算块。"""
    x = jnp.dot(x, params['w1']) + params['b1']
    x = jax.nn.relu(x)
    x = jnp.dot(x, params['w2']) + params['b2']
    return x

checkpointed_block = jax.checkpoint(compute_intensive_block)    # 块内部的激活不会被存储

def model_fn(x, all_params):
    intermediate_output = x                                       # 代表前面层的输出
    x = checkpointed_block(intermediate_output, all_params['block_params'])
    return x                                                      # 代表最终输出

grad_fn = jax.grad(lambda p, data: jnp.sum(model_fn(data, p)))
# ❌ 旧写法(错误):w1、w2 用同一个 key 生成;两者元素个数相同(65536),展平后是完全相同的随机序列
key = jax.random.PRNGKey(0)
try:
    w1 = jax.random.normal(key, (128, 512))
    w2 = jax.random.normal(key, (512, 128))
    same = bool(jnp.all(w1.ravel() == w2.ravel()))
    print("实测:w1.ravel() 与 w2.ravel() 完全相同?", same)
except Exception as e:
    print("实测报错:", type(e).__name__, e)
assert same
实测:w1.ravel() 与 w2.ravel() 完全相同? True
# ✅ 推荐写法:每个参数用 split 出来的独立 key
k1, k2 = jax.random.split(jax.random.key(0))
dummy_x = jnp.ones((1, 128))
dummy_params = {'block_params': {
    'w1': jax.random.normal(k1, (128, 512)), 'b1': jnp.zeros(512),
    'w2': jax.random.normal(k2, (512, 128)), 'b2': jnp.zeros(128)}}
assert not bool(jnp.all(dummy_params['block_params']['w1'].ravel() == dummy_params['block_params']['w2'].ravel()))

gradients = jax.jit(grad_fn)(dummy_params, dummy_x)
# 与不做检查点的结果一致
plain_grad = jax.jit(jax.grad(lambda p, d: jnp.sum(compute_intensive_block(d, p['block_params']))))(dummy_params, dummy_x)
assert max_diff(gradients, plain_grad) < 1e-4
print("梯度计算成功,与不做检查点的梯度一致 ✓")

# jax.remat 是 jax.checkpoint 的别名(一个薄包装,不是同一个对象)
print("jax.remat is jax.checkpoint:", jax.remat is jax.checkpoint, "| 文档:", jax.remat.__doc__.strip())
assert max_diff(jax.grad(lambda p, d: jnp.sum(jax.remat(compute_intensive_block)(d, p['block_params'])))(dummy_params, dummy_x),
                plain_grad) < 1e-4
梯度计算成功,与不做检查点的梯度一致 ✓
jax.remat is jax.checkpoint: False | 文档: Alias of :func:`jax.checkpoint`.

有策略地使用检查点

  • 粒度:对大的、计算密集的部分效果最好。对一个加法这样的小操作做检查点,重算开销相对太大,得不偿失。
  • 逻辑块:常见做法是对每个 Transformer 层或大的卷积块做检查点。
  • 先找瓶颈:用性能分析工具找出激活内存占得最多的部分。
  • 与 jit 配合:jax.checkpoint 和 jax.jit 可以顺畅地组合。
  • 什么时候考虑:激活导致 OOM;想训练更深或更宽的模型;能接受每步时间变长(课程举的例子是"延长 20–40%,具体因模型而异")。

⚠️ 注意:容易以为"对整个模型做检查点省内存最多,但计算时间几乎翻倍"。在 JAX 0.12 里,这两半都不成立(见下方两个单元):

  1. 内存:把整个模型包进一个 jax.checkpoint,反向开始时会把整个前向重算一遍,重算过程中照样保存所有激活。所以峰值临时内存和不做检查点完全相同(83.25 MiB vs 83.25 MiB)。真正省内存的是逐层检查点(降到 37 MiB)。
  2. 计算:反向本身大约是前向的 2 倍,总量约 $3F$。重算最多再加一次前向 $F$,也就是 $3F\to 4F$,增加约 33%,而不是"翻倍"。下方用 cost_analysis 数了 FLOPs:逐层检查点只增加约 17%,因为 JAX 只重算反向真正需要的部分(块的最后一个矩阵乘输出不用重算)。整模型一个 checkpoint 在只求 jax.grad 时,FLOPs 甚至不增加:grad 用不到原始前向的输出,那次前向被当作死代码删掉了,只剩反向里的重算。换成训练中常用的 jax.value_and_grad(要返回损失),原始前向必须保留,FLOPs 就增加约 33%。不管哪种都远不到"翻倍",和上面"延长 20–40%"是一个量级。实际耗时增加多少取决于硬件,本机 CPU 的计时见下方输出,仅供参考。
L_W, B_W, D_W, H_W = 16, 256, 256, 1024
def res_block(x, w1, w2):
    return x + jax.nn.gelu(x @ w1) @ w2

def unrolled_loss(p, x, block=res_block):
    for i in range(L_W):
        x = block(x, p["w1"][i], p["w2"][i])
    return jnp.mean(x ** 2)

r = np.random.default_rng(0)
wp = {"w1": jnp.asarray(r.normal(size=(L_W, D_W, H_W)) * 0.02, jnp.float32),
      "w2": jnp.asarray(r.normal(size=(L_W, H_W, D_W)) * 0.02, jnp.float32)}
wx = jnp.asarray(r.normal(size=(B_W, D_W)), jnp.float32)

variants = {
    "不做检查点":           unrolled_loss,
    "整个模型一个 checkpoint": jax.checkpoint(unrolled_loss),
    "逐层 checkpoint":        partial(unrolled_loss, block=jax.checkpoint(res_block)),
}
temp = {}
for name, f in variants.items():
    temp[name] = mem_of(jax.grad(f), wp, wx).temp_size_in_bytes / MiB
    print(f"{name:14s} 临时内存 {temp[name]:7.2f} MiB")
assert temp["整个模型一个 checkpoint"] >= 0.95 * temp["不做检查点"]       # 几乎没有节省
assert temp["逐层 checkpoint"] < 0.6 * temp["不做检查点"]                 # 逐层才真正省内存
不做检查点          临时内存   83.25 MiB
整个模型一个 checkpoint 临时内存   83.25 MiB
逐层 checkpoint  临时内存   37.00 MiB
# 计算量:用 XLA 的 cost_analysis 数 FLOPs
def flops(f, *args):
    return jax.jit(f).lower(*args).compile().cost_analysis()["flops"]

F_fwd = flops(unrolled_loss, wp, wx)
F_bwd_plain = flops(jax.grad(unrolled_loss), wp, wx)
F_bwd_layer = flops(jax.grad(variants["逐层 checkpoint"]), wp, wx)
F_bwd_whole = flops(jax.grad(variants["整个模型一个 checkpoint"]), wp, wx)
print(f"前向           {F_fwd / 1e9:6.2f} GFLOP")
print(f"前向+反向       {F_bwd_plain / 1e9:6.2f} GFLOP  (= {F_bwd_plain / F_fwd:.2f} × 前向)")
print(f"逐层检查点      {F_bwd_layer / 1e9:6.2f} GFLOP  (比不做检查点多 {F_bwd_layer / F_bwd_plain - 1:.1%})")
print(f"整模型检查点    {F_bwd_whole / 1e9:6.2f} GFLOP  (比不做检查点多 {F_bwd_whole / F_bwd_plain - 1:.1%})")
assert 2.8 < F_bwd_plain / F_fwd < 3.2                 # 反向 ≈ 2 × 前向
assert F_bwd_layer / F_bwd_plain < 4 / 3 + 1e-3        # 最多 +33%,远不到"翻倍"
assert F_bwd_whole / F_bwd_plain < 4 / 3 + 1e-3
# 整模型一个 checkpoint:只求 grad 时原始前向被当作死代码删掉;value_and_grad 要返回损失,原始前向保留
F_vg_plain = flops(jax.value_and_grad(unrolled_loss), wp, wx)
F_vg_whole = flops(jax.value_and_grad(variants["整个模型一个 checkpoint"]), wp, wx)
print(f"整模型检查点 + value_and_grad:比不做检查点多 {F_vg_whole / F_vg_plain - 1:.1%}(原始前向不能删)")
assert F_bwd_whole <= 1.01 * F_bwd_plain
assert 1.2 < F_vg_whole / F_vg_plain < 1.4

for name, f in variants.items():
    print(f"{name:14s} 实测耗时 {bench(jax.jit(jax.grad(f)), wp, wx):7.2f} ms")
前向             4.33 GFLOP
前向+反向        12.98 GFLOP  (= 3.00 × 前向)
逐层检查点       15.16 GFLOP  (比不做检查点多 16.8%)
整模型检查点     12.98 GFLOP  (比不做检查点多 0.0%)
整模型检查点 + value_and_grad:比不做检查点多 33.4%(原始前向不能删)
不做检查点          实测耗时   52.34 ms
整个模型一个 checkpoint 实测耗时   51.86 ms
逐层 checkpoint  实测耗时   64.47 ms

🔍 补充:检查点策略(policy)

⚪ 选读:需要在内存和重算量之间精细取舍时

不加 policy 时,jax.checkpoint 只保存被包装函数的输入,内部全部重算。policy 参数让你细致地控制"哪些中间结果保存、哪些重算"。JAX 0.12 里 jax.checkpoint_policies 可用的策略有:

策略含义
everything_saveable全部保存(等于不做检查点)
nothing_saveable全部重算(等于默认行为)
dots_saveable(别名 checkpoint_dots)只保存矩阵乘(dot_general)的结果,逐元素运算重算(矩阵乘贵,逐元素便宜)
dots_with_no_batch_dims_saveable(别名 checkpoint_dots_with_no_batch_dims)只保存没有 batch 维的矩阵乘结果(注意力里的 batched matmul 不存)
save_only_these_names(*names)只保存用 checkpoint_name(x, "名字") 标记过的中间值
save_anything_except_these_names(*names)除了这些名字都保存
save_any_names_but_these(*names)保存所有有名字的,除了这些
save_from_both_policies(p1, p2)两个策略的并集
offload_dot_with_no_batch_dims(src, dst)、save_and_offload_only_these_names(...)把残差卸载到主机内存(GPU/TPU 上有意义)

另外两个参数:

  • prevent_cse=True(默认):防止 XLA 的公共子表达式消除把"重算"又优化回"复用",那样检查点就白做了。在 lax.scan 里用时可以设成 False(scan 本身就挡住了 CSE),可能稍快一点。
  • static_argnums:把某些参数当作静态值(比如控制分支的 Python 布尔量)。

工具:from jax.ad_checkpoint import checkpoint_name, print_saved_residuals。print_saved_residuals(f, *args) 会打印反向传播要保存哪些残差(形状、来源),0.12 里可用。注意 jax.ad_checkpoint 子模块要显式 import:只 import jax 就写 jax.ad_checkpoint.xxx,会报 AttributeError。

下面用一个 16 层、lax.scan 堆叠的残差 MLP 对比各策略。

from jax.ad_checkpoint import checkpoint_name, print_saved_residuals
cp = jax.checkpoint_policies
print("0.12 中可用的策略:", [n for n in dir(cp) if not n.startswith("_")])

def named_block(x, w1, w2):
    h = checkpoint_name(x @ w1, "pre_act")       # 给第一个矩阵乘的输出起名字
    return x + jax.nn.gelu(h) @ w2

def make_scan_loss(policy=None, remat=True):
    blk = jax.checkpoint(named_block, policy=policy) if remat else named_block
    def loss(params, x):
        def body(x, p):
            return blk(x, p["w1"], p["w2"]), None
        x, _ = lax.scan(body, x, params)          # 参数沿第 0 维堆叠,scan 逐层遍历
        return jnp.mean(x ** 2)
    return loss

policies = {
    "不做检查点":                      make_scan_loss(remat=False),
    "everything_saveable":             make_scan_loss(cp.everything_saveable),
    "dots_saveable":                   make_scan_loss(cp.dots_saveable),
    "save_only_these_names('pre_act')": make_scan_loss(cp.save_only_these_names("pre_act")),
    "nothing_saveable":                make_scan_loss(cp.nothing_saveable),
    "默认(policy=None)":              make_scan_loss(None),
}

g_ref = jax.jit(jax.grad(policies["不做检查点"]))(wp, wx)
rows = []
for name, f in policies.items():
    gf = jax.grad(f)
    ma = mem_of(gf, wp, wx)
    g = jax.jit(gf)(wp, wx)
    ms_ = bench(jax.jit(gf), wp, wx)
    rows.append((name, ma.temp_size_in_bytes / MiB, ms_, max_diff(g, g_ref)))
base_temp = rows[0][1]
print(f"\n{'策略':34s} {'临时内存MiB':>10s} {'相对':>7s} {'耗时ms':>8s} {'梯度误差':>9s}")
for name, t, ms_, err in rows:
    print(f"{name:34s} {t:10.2f} {t / base_temp:7.1%} {ms_:8.2f} {err:9.1e}")
    assert err < 1e-5                                       # 所有策略的梯度都与不做检查点一致
mem = {n: t for n, t, _, _ in rows}
assert mem["nothing_saveable"] == mem["默认(policy=None)"]
assert mem["nothing_saveable"] < mem["dots_saveable"] < mem["不做检查点"]
assert mem["save_only_these_names('pre_act')"] == mem["dots_saveable"]   # 这个块里两者保存的东西相同
assert cp.checkpoint_dots is cp.dots_saveable and cp.checkpoint_dots_with_no_batch_dims is cp.dots_with_no_batch_dims_saveable   # 别名
0.12 中可用的策略: ['checkpoint_dots', 'checkpoint_dots_with_no_batch_dims', 'dots_saveable', 'dots_with_no_batch_dims_saveable', 'everything_saveable', 'nothing_saveable', 'offload_dot_with_no_batch_dims', 'save_and_offload_only_these_names', 'save_any_names_but_these', 'save_anything_except_these_names', 'save_from_both_policies', 'save_only_these_names']
策略                                    临时内存MiB      相对     耗时ms      梯度误差
不做检查点                                   88.50  100.0%    66.69   0.0e+00
everything_saveable                     92.50  104.5%    52.00   0.0e+00
dots_saveable                           25.50   28.8%    53.60   0.0e+00
save_only_these_names('pre_act')        25.50   28.8%    56.74   0.0e+00
nothing_saveable                         9.50   10.7%    69.38   0.0e+00
默认(policy=None)                          9.50   10.7%    61.68   0.0e+00
print("—— 不做检查点:每层要为反向保存的残差 ——")
print_saved_residuals(policies["不做检查点"], wp, wx)
print("\n—— dots_saveable:只保存矩阵乘结果 ——")
print_saved_residuals(policies["dots_saveable"], wp, wx)
print("\n—— nothing_saveable:只保存每层输入 ——")
print_saved_residuals(policies["nothing_saveable"], wp, wx)
—— 不做检查点:每层要为反向保存的残差 ——
f32[16,256,1024] from the argument params['w1']
f32[16,1024,256] from the argument params['w2']
f32[16,256,256] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[256,256] output of mul from /tmp/ipykernel_90669/1743565504.py:15:24 (make_scan_loss.<locals>.loss)

—— dots_saveable:只保存矩阵乘结果 ——
f32[16,256,1024] from the argument params['w1']
f32[16,1024,256] from the argument params['w2']
f32[16,256,1024] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[16,256,256] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[256,256] output of mul from /tmp/ipykernel_90669/1743565504.py:15:24 (make_scan_loss.<locals>.loss)

—— nothing_saveable:只保存每层输入 ——
f32[16,256,1024] from the argument params['w1']
f32[16,1024,256] from the argument params['w2']
f32[16,256,256] output of scan from /tmp/ipykernel_90669/1743565504.py:14:15 (make_scan_loss.<locals>.loss)
f32[256,256] output of mul from /tmp/ipykernel_90669/1743565504.py:15:24 (make_scan_loss.<locals>.loss)

读输出:

  • 不做检查点时,每层存了 5 个 [16,256,1024] 的数组:x @ w1 的结果,以及 tanh 近似 GELU 的几个中间量($x^3$、tanh 的输出、$1-\tanh^2$ 等)。宽度为 $H$ 的中间量才是大头。
  • dots_saveable 只存 x @ w1(1 个 [16,256,1024])和每层输入 [16,256,256],GELU 在反向时由 x @ w1 重算。几乎不增加计算,却省掉了 4/5 的大数组。
  • nothing_saveable 只存每层输入(scan 的 carry),宽度为 $H$ 的中间量全部重算。

注意,print_saved_residuals 反映的是 jaxpr 层面的残差。XLA 编译时还会做融合,memory_analysis() 给出的临时内存才是最终数字,两者趋势一致,但数值不同。另外,在 CPU 上,memory_analysis() 的 peak_memory_in_bytes 大约就等于"参数 + 输出"(只差几十字节),不随策略变化。所以本章一律用 temp_size_in_bytes(临时缓冲区)来衡量激活内存。

ma0 = mem_of(jax.grad(policies["不做检查点"]), wp, wx)
ma1 = mem_of(jax.grad(policies["nothing_saveable"]), wp, wx)
print("peak_memory_in_bytes:", ma0.peak_memory_in_bytes, ma1.peak_memory_in_bytes,
      "| argument+output:", ma0.argument_size_in_bytes + ma0.output_size_in_bytes)
print("temp_size_in_bytes  :", ma0.temp_size_in_bytes, ma1.temp_size_in_bytes)
assert ma0.peak_memory_in_bytes == ma1.peak_memory_in_bytes          # CPU 上 peak 字段不反映临时内存
assert ma1.temp_size_in_bytes < ma0.temp_size_in_bytes
peak_memory_in_bytes: 67371048 67371048 | argument+output: 67371024
temp_size_in_bytes  : 92799300 9961668

✅ NNX 中的检查点:nnx.remat

nnx.remat 是 jax.checkpoint 的 NNX 提升版,可以直接包装"接收模块的函数",参数 policy、prevent_cse、static_argnums 和 jax.checkpoint 一样。常见写法是把"调用一层"的函数包起来,在模型的循环里用。

class MLPBlock(nnx.Module):
    def __init__(self, d, h, *, rngs):
        self.up = nnx.Linear(d, h, rngs=rngs)
        self.down = nnx.Linear(h, d, rngs=rngs)
    def __call__(self, x):
        return x + self.down(jax.nn.gelu(self.up(x)))

def call_block(block, x):
    return block(x)
call_block_remat = nnx.remat(call_block)                         # 默认:只保存输入
call_block_dots = nnx.remat(call_block, policy=cp.dots_saveable)  # 也可以带 policy

class DeepMLP(nnx.Module):
    def __init__(self, n_layers, d, h, *, remat=None, rngs):
        self.blocks = nnx.List([MLPBlock(d, h, rngs=rngs) for _ in range(n_layers)])
        self.remat = remat
    def __call__(self, x):
        call = {None: call_block, "full": call_block_remat, "dots": call_block_dots}[self.remat]
        for blk in self.blocks:
            x = call(blk, x)
        return x

def nnx_loss(model, x):
    return jnp.mean(model(x) ** 2)

x_nnx = jnp.asarray(np.random.default_rng(1).normal(size=(256, 256)), jnp.float32)
res = {}
for mode in [None, "dots", "full"]:
    m = DeepMLP(8, 256, 1024, remat=mode, rngs=nnx.Rngs(0))
    gdef, st = nnx.split(m)
    # 用 split/merge 得到纯函数,再交给 jax.jit(...).lower().compile().memory_analysis()
    pure_grad = lambda st, x: jax.grad(lambda s: nnx_loss(nnx.merge(gdef, s), x))(st)
    temp_mb = mem_of(pure_grad, st, x_nnx).temp_size_in_bytes / MiB
    g = nnx.jit(nnx.grad(nnx_loss))(m, x_nnx)
    res[mode] = (temp_mb, g)
    print(f"remat={str(mode):5s} 临时内存 {temp_mb:6.2f} MiB")
assert max_diff(res[None][1], res["full"][1]) < 1e-6 and max_diff(res[None][1], res["dots"][1]) < 1e-6
assert res["full"][0] <= res["dots"][0] < res[None][0]
print(f"nnx.remat 梯度与不做检查点一致 ✓;全重算节省 {1 - res['full'][0] / res[None][0]:.1%} 临时内存")
remat=None  临时内存  12.25 MiB
remat=dots  临时内存   2.25 MiB
remat=full  临时内存   2.25 MiB
nnx.remat 梯度与不做检查点一致 ✓;全重算节省 81.6% 临时内存

🔍 补充:用 lax.scan 堆叠层 + 检查点是大模型的标准结构

🟢 必读

  • 直接写 Python for 循环展开 $L$ 层,编译时间随 $L$ 线性增长。把参数沿第 0 维堆叠,再用 lax.scan(NNX 里用 nnx.scan 或 nnx.vmap 创建堆叠层),就只编译一层,编译时间和层数无关。MaxText 等参考实现都是这么做的。
  • scan + jax.checkpoint(也就是上面 make_scan_loss 的写法)时,scan 只为每层保存 carry(层输入),正好是"每层一个检查点",激活内存从 $O(L\cdot\text{每层激活})$ 降到 $O(L\cdot\text{层输入}+\text{一层激活})$。
  • 更进一步的 $\sqrt{L}$ 策略:每 $k$ 层做一次检查点,可以在 $O(\sqrt L)$ 的内存下只多一次前向(Chen 等 2016,Training Deep Nets with Sublinear Memory Cost)。见文末练习 2。

6.7 混合精度训练

混合精度训练在一部分计算里用 16 位浮点数:激活占的内存大约减半,在支持的硬件上明显提速,精度损失通常很小。做法是:前向和反向里耗时的矩阵乘、卷积用 16 位;为了数值稳定,模型权重的主副本(通常还有优化器状态)保持 float32。

两种 16 位格式

  1. jnp.float16(IEEE 754 半精度):1 位符号、5 位指数、10 位尾数。
    • 优点:在支持 fp16 的硬件(比如 NVIDIA Tensor Core)上提速明显,内存是 fp32 的一半。
    • 缺点:动态范围很窄,小梯度容易下溢成 0,大值容易上溢成 inf / NaN,通常需要 loss scaling。
  2. jnp.bfloat16(Brain Float,Google Brain 提出):1 位符号、8 位指数(和 fp32 一样)、7 位尾数。
    • 优点:动态范围和 fp32 几乎一样,很少上溢或下溢,通常不需要 loss scaling;省的内存和 fp16 一样;在 TPU 和较新的 GPU 上能提速。
    • 缺点:精度比 fp16 还低(尾数更少),对特别敏感的模型可能影响收敛。
特性float32float16bfloat16
总位数321616
指数位数858
尾数位数23107
动态范围宽窄宽(与 fp32 相近)
精度高中低
需要 loss scaling?否通常需要通常不需要

课程结论:硬件支持时(TPU、Ampere 及以后的 NVIDIA GPU)首选 bf16;如果只有 fp16 得到高效支持,就必须配合 loss scaling 仔细实现。

下面用 jnp.finfo 验证这张表。

print(f"{'dtype':9s} {'位数':>4s} {'指数':>4s} {'尾数':>4s} {'eps':>10s} {'最大值':>11s} {'最小正规数':>11s} {'最小次正规数':>11s}")
info = {}
for dt in [jnp.float32, jnp.float16, jnp.bfloat16]:
    fi = jnp.finfo(dt)
    info[fi.dtype.name] = fi
    print(f"{fi.dtype.name:9s} {fi.bits:4d} {fi.nexp:4d} {fi.nmant:4d} {float(fi.eps):10.3g} {float(fi.max):11.4g} "
          f"{float(fi.tiny):11.4g} {float(fi.smallest_subnormal):11.4g}")
assert (info["float16"].nexp, info["float16"].nmant) == (5, 10)
assert (info["bfloat16"].nexp, info["bfloat16"].nmant) == (8, 7)
assert info["bfloat16"].nexp == info["float32"].nexp                # 指数位与 fp32 相同 → 动态范围相同
assert float(info["float16"].max) == 65504.0
assert float(info["bfloat16"].eps) > float(info["float16"].eps)     # bf16 精度更低

# 下溢 / 上溢
print("\n1e-8  →  fp16:", jnp.array(1e-8, jnp.float16), "| bf16:", jnp.array(1e-8, jnp.bfloat16))
print("7e4   →  fp16:", jnp.array(7e4, jnp.float32).astype(jnp.float16), "| bf16:", jnp.array(7e4, jnp.bfloat16))
assert float(jnp.array(1e-8, jnp.float16)) == 0.0 and float(jnp.array(1e-8, jnp.bfloat16)) > 0
assert jnp.isinf(jnp.array(7e4, jnp.float32).astype(jnp.float16))
# 精度:bf16 只有 8 位有效二进制位,256 + 1 == 256
print("256 + 1 →  bf16:", jnp.bfloat16(256) + jnp.bfloat16(1), "| fp16:", jnp.float16(256) + jnp.float16(1))
assert float(jnp.bfloat16(256) + jnp.bfloat16(1)) == 256.0 and float(jnp.float16(256) + jnp.float16(1)) == 257.0
dtype       位数   指数   尾数        eps         最大值       最小正规数      最小次正规数
float32     32    8   23   1.19e-07   3.403e+38   1.175e-38   1.401e-45
float16     16    5   10   0.000977    6.55e+04   6.104e-05    5.96e-08
bfloat16    16    8    7    0.00781    3.39e+38   1.175e-38   9.184e-41

1e-8  →  fp16: 0.0 | bf16: 1.00117e-08
7e4   →  fp16: inf | bf16: 70144
256 + 1 →  bf16: 256 | fp16: 257.0

在 JAX 中实现:参数 fp32,计算 bf16

课程先展示了用库的做法:Flax 可以分别指定参数 dtype(param_dtype)和计算 dtype(dtype)。设置 param_dtype=jnp.float32、dtype=jnp.bfloat16 后,参数以 fp32 初始化和存储,前向时层内部先把参数和输入转成 bf16 再计算。预期输出是:输入 bf16、参数 fp32、输出 bf16。

# ❌ 旧写法(过时):flax.linen 是旧 API(NNX 写法见下一个单元)
try:
    import flax.linen as nn

    class SimpleDense(nn.Module):
        features: int
        param_dtype: jnp.dtype = jnp.float32     # 主权重 fp32
        dtype: jnp.dtype = jnp.bfloat16          # 计算 bf16

        @nn.compact
        def __call__(self, x):
            x = x.astype(self.dtype)
            y = nn.Dense(features=self.features, param_dtype=self.param_dtype,
                         dtype=self.dtype, name='dense_layer')(x)
            return nn.relu(y)

    key = jax.random.PRNGKey(0)
    dummy_input = jnp.zeros((1, 10), dtype=jnp.bfloat16)
    lin_mp = SimpleDense(features=5)
    lin_mp_params = lin_mp.init(key, dummy_input)['params']
    output = lin_mp.apply({'params': lin_mp_params}, dummy_input)
    print(f"Input dtype: {dummy_input.dtype}")
    print(f"Parameter dtype (example): {jax.tree.leaves(lin_mp_params)[0].dtype}")
    print(f"Output dtype: {output.dtype}")
    lin_grads = jax.grad(lambda p: lin_mp.apply({'params': p}, jnp.ones((1, 10))).astype(jnp.float32).sum())(lin_mp_params)
    print("梯度 dtype:", jax.tree.map(lambda g: g.dtype.name, lin_grads))
except Exception as e:
    print("实测报错:", type(e).__name__, e)
assert output.dtype == jnp.bfloat16 and jax.tree.leaves(lin_mp_params)[0].dtype == jnp.float32
Input dtype: bfloat16
Parameter dtype (example): float32
Output dtype: bfloat16
梯度 dtype: {'dense_layer': {'bias': 'float32', 'kernel': 'float32'}}
# ✅ 推荐写法:NNX 的 nnx.Linear 同样有 dtype / param_dtype
lin_bf16 = nnx.Linear(10, 5, dtype=jnp.bfloat16, param_dtype=jnp.float32, rngs=nnx.Rngs(0))
x_mp = jnp.ones((4, 10), jnp.float32)
y_mp = lin_bf16(x_mp)
print("参数 dtype:", lin_bf16.kernel[...].dtype, "| 输出 dtype:", y_mp.dtype)
assert lin_bf16.kernel[...].dtype == jnp.float32 and y_mp.dtype == jnp.bfloat16

# 关键:对 fp32 参数求导,得到的梯度是 fp32(cast 的转置会把 bf16 余切转回 fp32)
grads_mp = nnx.grad(lambda m: m(x_mp).astype(jnp.float32).sum())(lin_bf16)
print("梯度 dtype:", {k: v.dtype.name for k, v in nnx.to_pure_dict(grads_mp).items()})
assert all(g.dtype == jnp.float32 for g in jax.tree.leaves(grads_mp))
assert all(g.dtype == jnp.float32 for g in jax.tree.leaves(lin_grads))      # Linen 也一样
参数 dtype: float32 | 输出 dtype: bfloat16
梯度 dtype: {'bias': 'float32', 'kernel': 'float32'}

⚠️ 注意:容易以为参数梯度也是计算 dtype(这里是 bfloat16),也容易以为混合精度让"激活和梯度的内存约减半"。实际上(JAX 0.12 + Flax 0.12.12,Linen 和 NNX 都一样),对 fp32 参数求导得到的梯度是 float32,因为 astype(bf16) 的转置会把余切转回参数的 dtype。反向传播中间流动的余切(激活的梯度)是 bf16,所以省下的是激活和激活梯度的内存;参数梯度、优化器状态仍然是 fp32。

手动类型转换

不用库的话,就得自己管理:把 fp32 权重转成 bf16,用 bf16 做矩阵乘和激活,输出是 bf16;求出的梯度用来更新 fp32 主参数。课程提醒,这样做要更仔细地保证整个模型和训练循环里的类型都对。

def predict_bf16(params_f32, inputs_bf16):
    """手动转换示例:params_f32 是 [(W, b), ...] 列表。"""
    activations = inputs_bf16
    for W_f32, b_f32 in params_f32:
        W_bf16, b_bf16 = W_f32.astype(jnp.bfloat16), b_f32.astype(jnp.bfloat16)
        outputs = jnp.dot(activations, W_bf16) + b_bf16
        activations = jax.nn.relu(outputs)
    return activations                                          # 输出是 bf16

r = np.random.default_rng(0)
mp_params = [(jnp.asarray(r.normal(size=(64, 128)) / 8, jnp.float32), jnp.zeros(128)),
             (jnp.asarray(r.normal(size=(128, 32)) / 11, jnp.float32), jnp.zeros(32))]
x_in = jnp.asarray(r.normal(size=(16, 64)), jnp.float32)
out_bf16 = jax.jit(predict_bf16)(mp_params, x_in.astype(jnp.bfloat16))
print("输出 dtype:", out_bf16.dtype)
assert out_bf16.dtype == jnp.bfloat16
输出 dtype: bfloat16

🔍 补充:preferred_element_type —— 让矩阵乘在 fp32 里累加

⚪ 选读:低精度矩阵乘的精度不够、想了解累加器 dtype 的作用时

矩阵乘的每个输出元素是 $K$ 个乘积的和。如果累加器也是 bf16,每加一次都要舍入到 8 位有效二进制位(约 2–3 位十进制有效数字),误差会随 $K$ 变大。jnp.dot(a, b, preferred_element_type=jnp.float32) 是在告诉 XLA:输入是 bf16,但输出(累加器)用 fp32。在 GPU / TPU 上,这本来就是 Tensor Core / MXU 的原生工作方式(bf16 乘、fp32 加),几乎不增加开销。Flax 的 nnx.Linear 也有 preferred_element_type 参数。

下面在 CPU 上对比各种精度组合和 float64 参考结果的相对误差。注意:在本机上,bf16 输出的 jnp.dot 和「fp32 累加后再舍入成 bf16」逐元素相同,也就是说 XLA:CPU 内部本来就用 fp32 累加(下面单元有 assert)。所以在 CPU 上,这两行的误差差别只来自输出的最后一次舍入,误差的大头是输入舍入成 bf16。累加器精度的影响,只有真正用低精度一个个累加时才看得出来,见单元末尾顺序累加的例子。

a_np = np.random.default_rng(0).normal(size=(256, 1024))
b_np = np.random.default_rng(1).normal(size=(1024, 256))
ref = a_np @ b_np                                    # float64 参考
a32, b32 = jnp.asarray(a_np, jnp.float32), jnp.asarray(b_np, jnp.float32)

matmuls = {
    "fp32":                         lambda a, b: jnp.dot(a, b),
    "bf16 输入, bf16 输出":           lambda a, b: jnp.dot(a.astype(jnp.bfloat16), b.astype(jnp.bfloat16)),
    "bf16 输入, fp32 累加(preferred)": lambda a, b: jnp.dot(a.astype(jnp.bfloat16), b.astype(jnp.bfloat16),
                                                         preferred_element_type=jnp.float32),
    "fp16 输入, fp16 输出":           lambda a, b: jnp.dot(a.astype(jnp.float16), b.astype(jnp.float16)),
}
err = {}
for name, f in matmuls.items():
    out = jax.jit(f)(a32, b32)                      # 数据作为参数传入
    err[name] = np.abs(np.asarray(out, np.float64) - ref).max() / np.abs(ref).max()
    print(f"{name:32s} 输出 {str(out.dtype):9s} 相对误差 {err[name]:.2e}")
assert err["fp32"] < 1e-5
assert err["bf16 输入, fp32 累加(preferred)"] < err["bf16 输入, bf16 输出"]
assert err["fp16 输入, fp16 输出"] < err["bf16 输入, bf16 输出"]          # fp16 尾数多,精度比 bf16 高

hlo = jax.jit(matmuls["bf16 输入, fp32 累加(preferred)"]).lower(a32, b32).as_text()
dot_line = [l.strip() for l in hlo.splitlines() if "dot_general" in l][0]
print("\nStableHLO:", dot_line[:160])
assert "xbf16>) -> tensor<256x256xf32>" in dot_line

# CPU 实测:bf16 输出的 dot 与"fp32 累加后再舍入到 bf16"逐元素相同 → XLA:CPU 内部用 fp32 累加
a16, b16 = a32.astype(jnp.bfloat16), b32.astype(jnp.bfloat16)
out_bf16_dot = jax.jit(lambda a, b: jnp.dot(a, b))(a16, b16)
out_acc32_dot = jax.jit(lambda a, b: jnp.dot(a, b, preferred_element_type=jnp.float32))(a16, b16)
assert bool(jnp.all(out_bf16_dot == out_acc32_dot.astype(jnp.bfloat16)))

# 逐个累加的危险:在 bf16 中顺序累加 1000 个 1.0,到 256 就加不上去了
seq_sum = jax.jit(lambda x: lax.fori_loop(0, x.shape[0], lambda i, s: s + x[i], jnp.zeros((), x.dtype)))
print("\n顺序累加 1000 个 1.0:bf16 =", seq_sum(jnp.ones(1000, jnp.bfloat16)), "| fp32 =", seq_sum(jnp.ones(1000, jnp.float32)))
print("jnp.sum(10000 个 bf16 的 1.0) =", jnp.sum(jnp.ones(10000, jnp.bfloat16)), "(XLA 内部用 fp32 归约,再舍入到 bf16)")
assert float(seq_sum(jnp.ones(1000, jnp.bfloat16))) == 256.0
fp32                             输出 float32   相对误差 1.26e-06
bf16 输入, bf16 输出                 输出 bfloat16  相对误差 3.42e-03
bf16 输入, fp32 累加(preferred)      输出 float32   相对误差 2.36e-03
fp16 输入, fp16 输出                 输出 float16   相对误差 4.34e-04

StableHLO: %2 = stablehlo.dot_general %0, %1, contracting_dims = [1] x [0], precision = [DEFAULT, DEFAULT] : (tensor<256x1024xbf16>, tensor<1024x256xbf16>) -> tensor<256x2
顺序累加 1000 个 1.0:bf16 = 256 | fp32 = 1000.0
jnp.sum(10000 个 bf16 的 1.0) = 9984 (XLA 内部用 fp32 归约,再舍入到 bf16)
# 实际训练对比:fp32 vs "参数 fp32 + 计算 bf16",同一初始化、同一数据
def make_mlp_loss(compute_dtype):
    def loss(p, x, y):
        h = x.astype(compute_dtype)
        for i, (w, b) in enumerate(p):
            h = jnp.dot(h, w.astype(compute_dtype), preferred_element_type=jnp.float32) + b
            h = (jnp.tanh(h) if i < len(p) - 1 else h).astype(compute_dtype)
        return jnp.mean((h.astype(jnp.float32) - y) ** 2)       # 损失用 fp32 计算
    return loss

r = np.random.default_rng(3)
Xmp = jnp.asarray(r.uniform(-2, 2, size=(512, 8)), jnp.float32)
Ymp = jnp.sin(Xmp.sum(axis=1, keepdims=True))
p_init = [(jnp.asarray(r.normal(size=(8, 64)) / np.sqrt(8), jnp.float32), jnp.zeros(64)),
          (jnp.asarray(r.normal(size=(64, 64)) / 8, jnp.float32), jnp.zeros(64)),
          (jnp.asarray(r.normal(size=(64, 1)) / 8, jnp.float32), jnp.zeros(1))]
mp_tx = optax.adam(3e-3)

def train_curve(compute_dtype, steps=400):
    loss = make_mlp_loss(compute_dtype)
    @jax.jit
    def run(p, x, y):
        def step(carry, _):
            p, s = carry
            l, g = jax.value_and_grad(loss)(p, x, y)
            u, s = mp_tx.update(g, s, p)
            return (optax.apply_updates(p, u), s), l
        (p, _), ls = lax.scan(step, (p, mp_tx.init(p)), None, length=steps)
        return p, ls
    return run(p_init, Xmp, Ymp)

p32, l32 = train_curve(jnp.float32)
pbf, lbf = train_curve(jnp.bfloat16)
print(f"fp32       : loss {float(l32[0]):.4f} → {float(l32[-1]):.5f}")
print(f"bf16 计算   : loss {float(lbf[0]):.4f} → {float(lbf[-1]):.5f}")
print("bf16 训练后的参数 dtype:", p32[0][0].dtype, pbf[0][0].dtype)
assert pbf[0][0].dtype == jnp.float32                    # 主权重始终是 fp32
assert float(l32[-1]) < 0.2 * float(l32[0]) and float(lbf[-1]) < 0.2 * float(lbf[0])
fp32       : loss 0.7060 → 0.00015
bf16 计算   : loss 0.7059 → 0.00054
bf16 训练后的参数 dtype: float32 float32

fp16 的 Loss Scaling

fp16 的动态范围有限,很小的梯度会下溢成 0。课程给出了四步:

  1. 缩放损失:反向传播前,把损失乘以缩放因子 $S$(比如 $S=2^{15}$):$\mathcal L_{\text{scaled}}=\mathcal L\times S$;
  2. 计算梯度:求导是线性的,所以梯度也被放大 $S$ 倍:$\nabla_\theta\mathcal L_{\text{scaled}}=S\,\nabla_\theta\mathcal L$,原本会下溢的小梯度就落进了 fp16 能表示的范围;
  3. 反缩放:更新 fp32 主权重之前除以 $S$:$g=\nabla_\theta\mathcal L_{\text{scaled}}/S$;
  4. 更新权重:用反缩放后的梯度更新 fp32 主权重。

$S$ 可以固定,也可以动态调整:从一个较大的 $S$ 开始;一旦发现溢出(梯度里出现 inf / NaN),就减小 $S$ 并跳过这一步更新;连续好几步都正常,就增大 $S$。

先看问题本身:用 fp16 计算时,小梯度会在反向传播中下溢。

def make_fp16_loss(scale, weight=1.0):
    """fp16 计算、fp32 主权重;返回 weight × 损失 × scale。"""
    def loss(p, x, y):
        h = x.astype(jnp.float16)
        for i, (w, b) in enumerate(p):
            h = h @ w.astype(jnp.float16) + b.astype(jnp.float16)
            if i < len(p) - 1:
                h = jnp.tanh(h)
        err = h.astype(jnp.float32) - y
        return weight * jnp.mean(err ** 2) * scale
    return loss

def fp32_loss(p, x, y, weight=1.0):
    h = x
    for i, (w, b) in enumerate(p):
        h = h @ w + b
        if i < len(p) - 1:
            h = jnp.tanh(h)
    return weight * jnp.mean((h - y) ** 2)

# 损失权重 1e-4:模拟"对超大 batch / 长序列取均值"或"辅助损失权重很小"时梯度非常小的情形
W_LOSS = 1e-4
g_true = jax.jit(jax.grad(partial(fp32_loss, weight=W_LOSS)))(p_init, Xmp, Ymp)
flat_true = np.concatenate([np.ravel(t) for t in jax.tree.leaves(g_true)])

def grad_quality(scale):
    g = jax.jit(jax.grad(make_fp16_loss(scale, W_LOSS)))(p_init, Xmp, Ymp)
    flat = np.concatenate([np.ravel(t) for t in jax.tree.leaves(g)]) / scale      # 反缩放
    zero_frac = np.mean((flat == 0) & (flat_true != 0))
    finite = bool(np.all(np.isfinite(flat)))
    rel = np.linalg.norm(flat - flat_true) / np.linalg.norm(flat_true) if finite else np.inf
    return zero_frac, rel, finite

print(f"真实梯度的量级:max |g| = {np.abs(flat_true).max():.2e}(fp16 最小正规数 {float(jnp.finfo(jnp.float16).tiny):.1e})")
quality = {}
for S in [1.0, 2.0 ** 8, 2.0 ** 15, 2.0 ** 32]:
    zf, rel, fin = grad_quality(S)
    quality[S] = (zf, rel, fin)
    print(f"S = 2^{int(np.log2(S)):2d}: 下溢成 0 的比例 {zf:6.1%}, 相对误差 {rel:9.3e}, 全部有限: {fin}")
assert quality[1.0][0] > 0.05 and quality[1.0][1] > 0.05         # 不缩放:大量下溢,梯度失真
assert quality[2.0 ** 15][0] == 0 and quality[2.0 ** 15][1] < 0.01 # 2^15:没有下溢,误差只剩 fp16 前向的舍入
assert not quality[2.0 ** 32][2]                                  # 太大:溢出成 inf
真实梯度的量级:max |g| = 3.79e-05(fp16 最小正规数 6.1e-05)
S = 2^ 0: 下溢成 0 的比例  21.3%, 相对误差 1.557e-01, 全部有限: True
S = 2^ 8: 下溢成 0 的比例   1.4%, 相对误差 8.996e-04, 全部有限: True
S = 2^15: 下溢成 0 的比例   0.0%, 相对误差 6.778e-04, 全部有限: True
S = 2^32: 下溢成 0 的比例   0.0%, 相对误差       inf, 全部有限: False

✅ 手写动态 loss scaling

状态只有两个数:当前缩放因子 scale 和连续正常的步数 good_steps。每步:

  • 梯度全部有限 → 正常更新;good_steps += 1,达到 period 时 scale *= 2 并清零;
  • 出现 inf / NaN → 跳过更新(参数和优化器状态保持不变),scale /= 2,good_steps = 0。

所有分支都用 jnp.where 写成数据依赖的选择,整个训练循环可以放进 lax.scan。

from typing import NamedTuple

class DynScale(NamedTuple):
    scale: jax.Array
    good_steps: jax.Array

def all_finite(tree):
    return jnp.all(jnp.stack([jnp.all(jnp.isfinite(t)) for t in jax.tree.leaves(tree)]))

def dyn_scale_step(ls: DynScale, finite, period=50, factor=2.0, min_scale=1.0):
    grow = finite & (ls.good_steps + 1 >= period)
    new_scale = jnp.where(finite, jnp.where(grow, ls.scale * factor, ls.scale),
                          jnp.maximum(ls.scale / factor, min_scale))
    new_good = jnp.where(finite & ~grow, ls.good_steps + 1, 0)
    return DynScale(new_scale, new_good)

fp16_tx = mp_tx                    # 与上面 fp32 / bf16 训练使用同一个优化器(Adam 3e-3)

@partial(jax.jit, static_argnames="steps")
def train_fp16_dynamic(p, x, y, ls0, steps):
    def step(carry, _):
        p, s, ls = carry
        scaled_loss = lambda p_: make_fp16_loss(1.0)(p_, x, y) * ls.scale   # 1) 缩放损失
        loss_scaled, g = jax.value_and_grad(scaled_loss)(p)                 # 2) 梯度也被放大
        g = jax.tree.map(lambda t: t / ls.scale, g)                         # 3) 反缩放
        finite = all_finite(g)
        u, s_new = fp16_tx.update(jax.tree.map(lambda t: jnp.where(finite, t, 0.0), g), s, p)
        p_new = optax.apply_updates(p, u)
        # 溢出时跳过:参数、优化器状态都保持旧值
        p = jax.tree.map(lambda a, b: jnp.where(finite, a, b), p_new, p)
        s = jax.tree.map(lambda a, b: jnp.where(finite, a, b), s_new, s)
        return (p, s, dyn_scale_step(ls, finite)), (ls.scale, finite, loss_scaled / ls.scale)
    (p, s, ls), (scales, finites, losses) = lax.scan(step, (p, fp16_tx.init(p), ls0), None, length=steps)
    return p, ls, scales, finites, losses

ls0 = DynScale(jnp.float32(2.0 ** 24), jnp.int32(0))       # 故意从过大的 scale 开始
p_fp16, ls_end, scales, finites, losses_fp16 = train_fp16_dynamic(p_init, Xmp, Ymp, ls0, steps=400)
scales, finites = np.asarray(scales), np.asarray(finites)
first_ok = int(np.argmax(finites))
print("前 12 步的 scale:", [f"2^{int(np.log2(s))}" for s in scales[:12]])
print("前 12 步是否有限:", finites[:12].astype(int).tolist())
print(f"第一次有限梯度出现在第 {first_ok} 步;被跳过的步数共 {int((~finites).sum())};最终 scale = 2^{int(np.log2(float(ls_end.scale)))}")
print(f"fp16 + 动态缩放:loss {float(losses_fp16[first_ok]):.4f} → {float(losses_fp16[-1]):.5f}(fp32 参照 {float(l32[-1]):.5f})")
assert not finites[0] and finites[first_ok] and scales[first_ok] < scales[0]
assert float(losses_fp16[-1]) < 0.2 * float(losses_fp16[first_ok])
assert p_fp16[0][0].dtype == jnp.float32
前 12 步的 scale: ['2^24', '2^23', '2^22', '2^21', '2^20', '2^19', '2^18', '2^17', '2^17', '2^17', '2^17', '2^17']
前 12 步是否有限: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
第一次有限梯度出现在第 7 步;被跳过的步数共 9;最终 scale = 2^21
fp16 + 动态缩放:loss 0.7060 → 0.00025(fp32 参照 0.00015)

⚠️ 注意:别指望在 Optax 里找到现成的 loss scaling。Optax 0.2.9 没有 loss scaling 工具,只有配套的 optax.apply_if_finite / optax.skip_not_finite(梯度出现 inf/NaN 时跳过这一步更新)。Flax 里有 flax.training.dynamic_scale.DynamicScale(Linen 时代的工具,现在仍能导入)。DeepMind 另外还有一个独立的小库 jmp,专门做混合精度策略和 loss scaling。上面手写的版本把"缩放 + 跳过"都放进了一个 jit。

optax_names = [n for n in dir(optax) if "scal" in n.lower() and "loss" in n.lower()]
print("optax 中名字含 loss+scale 的 API:", optax_names)
print("optax 中与'跳过非有限更新'相关的 API:", [n for n in dir(optax) if "finite" in n])
from flax.training import dynamic_scale
print("Flax 的 DynamicScale:", dynamic_scale.DynamicScale)
assert optax_names == [] and hasattr(optax, "apply_if_finite")

# optax.apply_if_finite:遇到 NaN 梯度时更新为 0,并计数
safe = optax.apply_if_finite(optax.sgd(0.1), max_consecutive_errors=5)
st = safe.init({"w": jnp.ones(2)})
u, st = safe.update({"w": jnp.array([jnp.nan, 1.0])}, st, {"w": jnp.ones(2)})
print("NaN 梯度 → 更新", u["w"], "| notfinite_count =", int(st.notfinite_count))
assert float(jnp.abs(u["w"]).sum()) == 0.0 and int(st.notfinite_count) == 1
optax 中名字含 loss+scale 的 API: []
optax 中与'跳过非有限更新'相关的 API: ['apply_if_finite', 'skip_not_finite']
Flax 的 DynamicScale: <class 'flax.training.dynamic_scale.DynamicScale'>
NaN 梯度 → 更新 [0. 0.] | notfinite_count = 1

注意事项

  • 硬件:实际能快多少,取决于 GPU / TPU 对 16 位运算的支持(Tensor Core、bf16 单元),要用性能分析确认。(本机是 CPU,没有 bf16 矩阵单元,所以本节只比较精度,不比较速度。)
  • 数值稳定性:bf16 一般很可靠;fp16 要仔细实现,还要配合 loss scaling。归一化层的方差计算、softmax、最终损失这些敏感操作,通常还是应该留在 fp32。
  • 精度:一般能和纯 fp32 训练差不多,但最终模型效果要验证一下。
  • 调试:常见的步骤是检查 NaN,以及确认 fp16 下的梯度缩放对不对。

🔍 补充:混合精度的实战清单

🟢 必读

  1. 哪些留在 fp32:主权重、优化器状态、损失、LayerNorm / BatchNorm 的统计量、softmax 的归约(或至少用 fp32 累加)、梯度的全局范数。
  2. Adam 状态也能降精度:optax.adam(..., mu_dtype=jnp.bfloat16) 把一阶矩存成 bf16,优化器状态从 2 倍参数降到 1.5 倍(见 6.9);二阶矩对精度更敏感,一般保留 fp32。
  3. 通信也能降精度:数据并行里 all-reduce bf16 梯度,能把通信量减半,但累加误差会随设备数变大,需要验证。
  4. bf16 不是万能的:bf16 只有 8 位有效二进制位(约 2–3 位十进制有效数字)。参数更新量 $\eta g$ 不到参数本身的约 $2^{-9}$~$2^{-8}$(半个相邻间距)时,用 bf16 存参数,更新会直接被舍入掉。这就是必须保留 fp32 主权重的原因(练习里有演示)。

6.8 模型并行策略

数据并行(配合梯度累积、检查点)可以扩展数据量和有效 batch,但前提是每个设备都放得下完整的模型。如果参数和中间激活大到单个加速器用了混合精度也放不下,就得把模型本身切开,分到多个设备上,这就是模型并行。数据并行是"同一个模型,不同的数据分片";模型并行是"把层或层的一部分分到不同设备上,数据在切开的模型里跨设备流动"。

张量并行(层内并行)

把单个层内部的计算拆到多个设备上,对 Transformer 里巨大的线性层特别有效。以 $Y=XW$ 为例,把 $W$ 按列切成 $W=[W_1, W_2]$:输入 $X$ 送到两个设备,设备 1 算 $Y_1=XW_1$,设备 2 算 $Y_2=XW_2$,最后拼起来得到 $Y=[Y_1, Y_2]$。也可以按行切,这时每个设备得到的是部分和,需要一次 all-reduce 求和。注意力这类更复杂的操作也可以这样并行。

难点:

  • 通信开销:单层的前向和反向里都要频繁通信(all-gather、reduce-scatter、all-reduce),互连慢的话会成为瓶颈;
  • 实现复杂:要仔细处理张量切分、通信原语和跨分片的梯度传播。

流水线并行(层间并行)

按层切分:把连续的几层(一个阶段)分给不同设备,前一阶段的输出激活就是后一阶段的输入。比如 4 层 L1–L4 放在 4 个设备 D1–D4 上。朴素实现一次只处理一个 batch,任何时刻只有一个设备在干活,其余都闲着,这些空闲就叫流水线气泡。改进办法是微批次化:把 batch 拆成微批次 MB1、MB2……,D1 处理完 MB1 就交给 D2,自己接着处理 MB2,这样多个设备能同时工作。开头和结尾的气泡还在,但被大量微批次摊薄了。

难点:气泡没法完全避免;各阶段的耗时要负载均衡,不然最慢的阶段就成了瓶颈;微批次、激活和反向梯度流的调度很复杂。

混合方法

  • 数据并行 + 流水线并行:把整条流水线复制多份,每份处理不同的数据分片;
  • 数据并行 + 张量并行:层内用张量并行,再把这个张量并行的模型做数据并行复制;
  • 完全分片数据并行(FSDP):在数据并行的各个设备之间分片参数、梯度和优化器状态,只在计算要用时才 all-gather 参数。可以把它看成"参数像模型并行那样被切开,但用在数据并行的场景里"(也就是 ZeRO-3)。

课程的结论是:只用 pmap、lax.p* 集合操作和设备放置这些原语,也能实现上面这些策略,但难度很高,所以实践中一般依赖更高层的库。

⚠️ 注意:旧资料里常见的说法是,JAX 的张量并行"在分配给并行层的设备子集上使用 pmap,配合 jax.lax.psum / all_gather",流水线并行"用 jax.device_put 手动放置",FSDP 则"生态中也在出现类似功能"。这是 pmap 时代的做法。在 JAX 0.12 里,张量并行、FSDP 以及它们的任意组合,都只是"给数组标注 PartitionSpec":在多维 Mesh 上用 jit + NamedSharding,编译器(GSPMD / Shardy)会自动插入 all-gather、reduce-scatter、all-reduce;需要手写通信时用 jax.shard_map。下面全部用这种方式来写,并 assert 和单设备的结果一致。

先建一个 2 × 4 的二维 mesh:data 轴 2 个设备、model 轴 4 个设备,看看不同的 PartitionSpec 怎么把一个矩阵分到 8 个设备上。

mesh2d = jax.make_mesh((2, 4), ("data", "model"))
print(mesh2d, "| 设备排布:\n", np.vectorize(lambda d: d.id)(mesh2d.devices))
assert mesh2d.axis_types == (AxisType.Explicit, AxisType.Explicit)     # 0.12 的 make_mesh 默认是 Explicit

demo = jnp.arange(8 * 8, dtype=jnp.float32).reshape(8, 8)
with jax.set_mesh(mesh2d):
    for spec in [P(), P("data", None), P(None, "model"), P("data", "model"), P(("data", "model"), None)]:
        arr = jax.device_put(demo, spec)
        shard_shape = arr.addressable_shards[0].data.shape
        print(f"\nPartitionSpec{str(tuple(spec)):24s} → jax.typeof: {jax.typeof(arr)},每设备分片 {shard_shape}")
        jax.debug.visualize_array_sharding(arr, use_color=False)
Mesh('data': 2, 'model': 4, axis_types=(Explicit, Explicit)) | 设备排布:
 [[0 1 2 3]
 [4 5 6 7]]
PartitionSpec()                       → jax.typeof: float32[8,8],每设备分片 (8, 8)
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│  CPU 0,1,2,3,4,5,6,7  │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
PartitionSpec('data', None)           → jax.typeof: float32[8@data,8],每设备分片 (4, 8)
┌───────────────────────┐
│                       │
│      CPU 0,1,2,3      │
│                       │
│                       │
├───────────────────────┤
│                       │
│      CPU 4,5,6,7      │
│                       │
│                       │
└───────────────────────┘
PartitionSpec(None, 'model')          → jax.typeof: float32[8,8@model],每设备分片 (8, 2)
┌───────┬───────┬───────┬───────┐
│       │       │       │       │
│       │       │       │       │
│       │       │       │       │
│       │       │       │       │
│CPU 0,4│CPU 1,5│CPU 2,6│CPU 3,7│
│       │       │       │       │
│       │       │       │       │
│       │       │       │       │
│       │       │       │       │
└───────┴───────┴───────┴───────┘
PartitionSpec('data', 'model')        → jax.typeof: float32[8@data,8@model],每设备分片 (4, 2)
┌───────┬───────┬───────┬───────┐
│       │       │       │       │
│ CPU 0 │ CPU 1 │ CPU 2 │ CPU 3 │
│       │       │       │       │
│       │       │       │       │
├───────┼───────┼───────┼───────┤
│       │       │       │       │
│ CPU 4 │ CPU 5 │ CPU 6 │ CPU 7 │
│       │       │       │       │
│       │       │       │       │
└───────┴───────┴───────┴───────┘
PartitionSpec(('data', 'model'), None) → jax.typeof: float32[8@(data,model),8],每设备分片 (1, 8)
┌───────────────────────┐
│         CPU 0         │
├───────────────────────┤
│         CPU 1         │
├───────────────────────┤
│         CPU 2         │
├───────────────────────┤
│         CPU 3         │
├───────────────────────┤
│         CPU 4         │
├───────────────────────┤
│         CPU 5         │
├───────────────────────┤
│         CPU 6         │
├───────────────────────┤
│         CPU 7         │
└───────────────────────┘

✅ Megatron 式张量并行:列切分 + 行切分

Transformer 的 MLP 是 $Y=\mathrm{act}(XW_1)W_2$。Megatron-LM 的经典切法:

  • $W_1\in\mathbb R^{D\times H}$ 按列切(P(None, 'model')):每个设备拿到 $H/4$ 列,算出 $\mathrm{act}(XW_1^{(i)})$。激活函数是逐元素的,所以不需要通信;
  • $W_2\in\mathbb R^{H\times D}$ 按行切(P('model', None)):每个设备用自己那 $H/4$ 行算出一个部分和 $Z^{(i)}=h^{(i)}W_2^{(i)}$,最后 $Y=\sum_i Z^{(i)}$,只要一次 all-reduce。

$$ Y=\sum_{i=1}^{4}\mathrm{act}\!\left(XW_1^{(i)}\right)W_2^{(i)} $$

这样整个 MLP 前向只有一次通信。同时 batch 维按 data 轴切,就是"数据并行 × 张量并行"的二维并行。

在 Explicit 模式下,收缩维被切分的矩阵乘,结果的分片有歧义(部分和求和以后按什么切?)。JAX 要求用 out_sharding 明确写出来,否则报 ShardingTypeError。这正好让通信"显式可见"。

r = np.random.default_rng(0)
D_TP, H_TP, B_TP = 64, 256, 32
w1_np = r.normal(size=(D_TP, H_TP)) * 0.1
w2_np = r.normal(size=(H_TP, D_TP)) * 0.1
x_np = r.normal(size=(B_TP, D_TP))
ref64 = np.maximum(x_np @ w1_np, 0) @ w2_np                 # NumPy float64 参考

def tp_mlp(w1, w2, x):
    h = jax.nn.relu(x @ w1)                                  # 列并行:无通信
    print("  [追踪] h  :", jax.typeof(h))
    y = jnp.matmul(h, w2, out_sharding=P("data", None))      # 行并行:部分和 → all-reduce
    print("  [追踪] y  :", jax.typeof(y))
    return y

with jax.set_mesh(mesh2d):
    w1_tp = jax.device_put(jnp.asarray(w1_np, jnp.float32), P(None, "model"))
    w2_tp = jax.device_put(jnp.asarray(w2_np, jnp.float32), P("model", None))
    x_tp = jax.device_put(jnp.asarray(x_np, jnp.float32), P("data", None))
    print("w1:", jax.typeof(w1_tp), "| w2:", jax.typeof(w2_tp), "| x:", jax.typeof(x_tp))
    tp_fn = jax.jit(tp_mlp)
    y_tp = tp_fn(w1_tp, w2_tp, x_tp)
    print("集合通信:", collectives(tp_fn.lower(w1_tp, w2_tp, x_tp).compile().as_text()))

    # 不写 out_sharding 会怎样?
    try:
        jax.jit(lambda w1, w2, x: jax.nn.relu(x @ w1) @ w2)(w1_tp, w2_tp, x_tp)
    except Exception as e:
        print("不指定 out_sharding →", type(e).__name__, ":", str(e)[:120], "...")

err = np.abs(np.asarray(y_tp, np.float64) - ref64).max()
print(f"张量并行结果 vs NumPy float64:最大误差 {err:.2e}")
assert err < 1e-5
w1: float32[64,256@model] | w2: float32[256@model,64] | x: float32[32@data,64]
  [追踪] h  : float32[32@data,256@model]
  [追踪] y  : float32[32@data,64]
集合通信: {'all-reduce': 1, 'all-gather': 0, 'reduce-scatter': 0, 'all-to-all': 0, 'collective-permute': 0}
不指定 out_sharding → ShardingTypeError : Contracting dimensions are sharded and it is ambiguous how the output should be sharded. Please specify the output shard ...
张量并行结果 vs NumPy float64:最大误差 6.49e-07

✅ 同一件事用 jax.shard_map 手写

jit + 分片是"全局视角":你写全局计算,编译器负责通信。jax.shard_map 是"单设备视角":函数体看到的是本设备的分片,通信要自己写(lax.psum 等)。它适合要精确控制通信的场景(比如手写让通信和计算重叠的算子),也最能看清 Megatron 的数学:每个设备算出部分和,psum 沿 model 轴求和。

def tp_mlp_local(w1_blk, w2_blk, x_blk):
    # 这里的形状都是"本设备的分片"
    h = jax.nn.relu(x_blk @ w1_blk)              # (B/2, H/4)
    partial_sum = h @ w2_blk                     # (B/2, D):只是部分和
    return lax.psum(partial_sum, "model")        # 沿 model 轴求和(all-reduce)

with jax.set_mesh(mesh2d):
    sm = jax.jit(jax.shard_map(tp_mlp_local,
                               in_specs=(P(None, "model"), P("model", None), P("data", None)),
                               out_specs=P("data", None)))
    y_sm = sm(w1_tp, w2_tp, x_tp)
    shapes = jax.shard_map(lambda a, b, c: (jnp.zeros(a.shape), jnp.zeros(b.shape), jnp.zeros(c.shape)),
                           in_specs=(P(None, "model"), P("model", None), P("data", None)),
                           out_specs=(P(None, "model"), P("model", None), P("data", None)))(w1_tp, w2_tp, x_tp)
print("每个设备看到的分片形状:w1", shapes[0].addressable_shards[0].data.shape,
      "w2", shapes[1].addressable_shards[0].data.shape, "x", shapes[2].addressable_shards[0].data.shape)
assert max_diff(y_sm, y_tp) < 1e-6
print(f"shard_map 手写版 与 jit 自动版 一致 ✓(差 {max_diff(y_sm, y_tp):.1e})")
每个设备看到的分片形状:w1 (64, 64) w2 (64, 64) x (16, 64)
shard_map 手写版 与 jit 自动版 一致 ✓(差 0.0e+00)

✅ 二维并行训练:分片训练 vs 单设备训练

把上面的 MLP 接上 Adam 训练 5 步。分片版本里,参数按 Megatron 的方式切,数据按 data 轴切。Optax 的状态用 zeros_like 创建,自动继承参数的分片;梯度的分片也和参数一致(反向中的 all-reduce 由编译器插入)。最后 assert 它和单设备训练的参数一致。

Y_np = r.normal(size=(B_TP, D_TP))
tx_mp = optax.adam(1e-2)
tx_strict = optax.sgd(0.1, momentum=0.9)       # 更新对梯度是线性的,用来做严格对拍

def make_train_step(fwd, tx=tx_mp):
    def loss(p, x, y):
        return jnp.mean((fwd(p, x) - y) ** 2)
    @jax.jit
    def step(p, s, x, y):
        l, g = jax.value_and_grad(loss)(p, x, y)
        u, s = tx.update(g, s, p)
        return optax.apply_updates(p, u), s, l
    return step

def train_n(step, p, tx, x, y, n=5):
    s = tx.init(p)
    for _ in range(n):
        p, s, l = step(p, s, x, y)
    return p, s, l

def fwd_single(p, x):
    return jax.nn.relu(x @ p["w1"]) @ p["w2"]

def fwd_tp(p, x):
    h = jax.nn.relu(x @ p["w1"])
    return jnp.matmul(h, p["w2"], out_sharding=P("data", None))

params_np = {"w1": w1_np.astype(np.float32), "w2": w2_np.astype(np.float32)}
X32, Y32 = x_np.astype(np.float32), Y_np.astype(np.float32)

# 单设备参照
p_ref = jax.tree.map(jnp.asarray, params_np); s_ref = tx_mp.init(p_ref)
step_single = make_train_step(fwd_single)
for _ in range(5):
    p_ref, s_ref, l_ref = step_single(p_ref, s_ref, jnp.asarray(X32), jnp.asarray(Y32))

with jax.set_mesh(mesh2d):
    p_tp = {"w1": jax.device_put(params_np["w1"], P(None, "model")),
            "w2": jax.device_put(params_np["w2"], P("model", None))}
    s_tp = tx_mp.init(p_tp)
    xs, ys = jax.device_put(X32, P("data", None)), jax.device_put(Y32, P("data", None))
    step_tp = make_train_step(fwd_tp)
    print("通信:", collectives(step_tp.lower(p_tp, s_tp, xs, ys).compile().as_text()))
    for _ in range(5):
        p_tp, s_tp, l_tp = step_tp(p_tp, s_tp, xs, ys)
    print("训练后 w1:", jax.typeof(p_tp["w1"]), "| Adam nu(w2):", jax.typeof(s_tp[0].nu["w2"]))
    assert p_tp["w1"].sharding.spec == P(None, "model") and s_tp[0].nu["w2"].sharding.spec == P("model", None)

d = max_diff(p_tp, p_ref)
print(f"Adam 5 步后:loss {float(l_tp):.6f} vs {float(l_ref):.6f};参数最大差 {d:.2e}")
assert np.isclose(float(l_tp), float(l_ref), rtol=1e-5) and d < 1e-3

# 严格对拍:动量 SGD(更新是梯度的线性函数,不会放大舍入误差)
p_ref_s, _, _ = train_n(make_train_step(fwd_single, tx_strict), jax.tree.map(jnp.asarray, params_np), tx_strict,
                        jnp.asarray(X32), jnp.asarray(Y32))
with jax.set_mesh(mesh2d):
    p_tp_s, _, _ = train_n(make_train_step(fwd_tp, tx_strict),
                           {"w1": jax.device_put(params_np["w1"], P(None, "model")),
                            "w2": jax.device_put(params_np["w2"], P("model", None))}, tx_strict, xs, ys)
d_s = max_diff(p_tp_s, p_ref_s)
print(f"动量 SGD 5 步后:参数最大差 {d_s:.2e}")
assert d_s < 1e-6
通信: {'all-reduce': 2, 'all-gather': 0, 'reduce-scatter': 0, 'all-to-all': 0, 'collective-permute': 0}
训练后 w1: float32[64,256@model] | Adam nu(w2): float32[256@model,64]
Adam 5 步后:loss 0.327869 vs 0.327869;参数最大差 1.59e-04
动量 SGD 5 步后:参数最大差 2.98e-08

✅ FSDP / ZeRO-3:参数、梯度、优化器状态都分片

FSDP 不改变计算方式(每个设备还是算完整的层,处理自己的数据分片),只改变存储方式:

  • 参数按某一维切到所有设备上(这里用 8 个设备的 fsdp 轴,P('fsdp', None)),每个设备只存 $1/8$;
  • 前向用到某层时,先 all-gather 出完整权重,用完就丢;
  • 反向时,权重梯度经过 reduce-scatter(每个设备只拿到自己那一片梯度的和);
  • 优化器状态和参数一样是分片的,所以更新也只在本地分片上做。

在 Explicit 模式下,"用之前 all-gather"就是一句 reshard(w, P());它的转置(反向)正好是 reduce-scatter。

mesh_fsdp = jax.make_mesh((8,), ("fsdp",))

def fwd_fsdp(p, x):
    w1 = reshard(p["w1"], P())            # all-gather:临时得到完整权重
    w2 = reshard(p["w2"], P())
    return jax.nn.relu(x @ w1) @ w2

with jax.set_mesh(mesh_fsdp):
    p_f = {"w1": jax.device_put(params_np["w1"], P("fsdp", None)),
           "w2": jax.device_put(params_np["w2"], P("fsdp", None))}
    s_f = tx_mp.init(p_f)
    xs_f, ys_f = jax.device_put(X32, P("fsdp", None)), jax.device_put(Y32, P("fsdp", None))
    step_f = make_train_step(fwd_fsdp)
    hlo_f = step_f.lower(p_f, s_f, xs_f, ys_f).compile().as_text()
    for _ in range(5):
        p_f, s_f, l_f = step_f(p_f, s_f, xs_f, ys_f)
    g_f = jax.jit(jax.grad(lambda p, x, y: jnp.mean((fwd_fsdp(p, x) - y) ** 2)))(p_f, xs_f, ys_f)
    print("参数    :", jax.typeof(p_f["w1"]), jax.typeof(p_f["w2"]))
    print("梯度    :", jax.typeof(g_f["w1"]), jax.typeof(g_f["w2"]))
    print("Adam mu :", jax.typeof(s_f[0].mu["w1"]), "| nu:", jax.typeof(s_f[0].nu["w2"]))
    print("通信    :", collectives(hlo_f))

def per_device_bytes(tree):
    return sum(x.addressable_shards[0].data.nbytes for x in jax.tree.leaves(tree) if hasattr(x, "addressable_shards"))

full = tree_bytes(p_f) + tree_bytes(s_f[0].mu) + tree_bytes(s_f[0].nu)
local = per_device_bytes(p_f) + per_device_bytes(s_f[0].mu) + per_device_bytes(s_f[0].nu)
print(f"参数 + Adam 状态:全局 {full / 1024:.0f} KiB,每设备 {local / 1024:.0f} KiB(1/{full // local})")
assert full == 8 * local
assert g_f["w1"].sharding.spec == P("fsdp", None)                 # 梯度也是分片的
assert collectives(hlo_f)["all-gather"] >= 2

d = max_diff(p_f, p_ref)
print(f"FSDP + Adam 5 步后 vs 单设备:loss {float(l_f):.6f} vs {float(l_ref):.6f},参数最大差 {d:.2e}")
assert np.isclose(float(l_f), float(l_ref), rtol=1e-5) and d < 1e-3
with jax.set_mesh(mesh_fsdp):
    p_f_s, _, _ = train_n(make_train_step(fwd_fsdp, tx_strict),
                          {"w1": jax.device_put(params_np["w1"], P("fsdp", None)),
                           "w2": jax.device_put(params_np["w2"], P("fsdp", None))}, tx_strict, xs_f, ys_f)
print(f"FSDP + 动量 SGD 5 步后:参数最大差 {max_diff(p_f_s, p_ref_s):.2e}")
assert max_diff(p_f_s, p_ref_s) < 1e-6
参数    : float32[64@fsdp,256] float32[256@fsdp,64]
梯度    : float32[64@fsdp,256] float32[256@fsdp,64]
Adam mu : float32[64@fsdp,256] | nu: float32[256@fsdp,64]
通信    : {'all-reduce': 1, 'all-gather': 2, 'reduce-scatter': 0, 'all-to-all': 0, 'collective-permute': 0}
参数 + Adam 状态:全局 384 KiB,每设备 48 KiB(1/8)
FSDP + Adam 5 步后 vs 单设备:loss 0.327869 vs 0.327869,参数最大差 2.63e-05
FSDP + 动量 SGD 5 步后:参数最大差 2.98e-08

🔍 补充:为什么 Adam 下分片训练与单设备"不逐位一致"? ⚪ 选读:验证分片训练是否正确时

分片后,矩阵乘的求和顺序变了(先算部分和再 all-reduce),梯度会有 $10^{-7}$ 量级的舍入差异。SGD 的更新是梯度的线性函数,这点差异原样保留(上面测得 $<10^{-6}$)。Adam 的更新是 $\hat m/(\sqrt{\hat v}+\epsilon)$,它会把接近 0 的梯度分量(ReLU、对称性导致的 $10^{-8}$ 级梯度)归一化到 $\pm\eta$ 量级,舍入差异就被放大成 $10^{-4}$ 级的参数差。这不是 bug。验证分片是否正确时,应该对比梯度,或者用线性优化器,或者看 loss 曲线的相对误差。

注:在这个 jit 自动分片的版本里,CPU 后端把反向中的 reduce-scatter 实现成了 all-reduce + 切片(上面的统计里 reduce-scatter 为 0)。不过梯度最终的分片(64@fsdp)说明,语义上就是 reduce-scatter。文末练习 3 用 shard_map + all_gather 手写了同一个 FSDP,编译结果里就出现了真正的 reduce-scatter。

✅ 用 NNX 做二维并行训练,并与单设备 NNX 对拍

6.4 节的 ShardedMLP(nnx.with_partitioning 注解 + out_sharding)可以直接拿来训练。对照组是同样的初始化器、同样的 Rngs、但不加分片注解的普通模型,两者初始值完全相同(jax_threefry_partitionable=True 保证分片生成的随机数和不分片时一样)。

class PlainMLP(nnx.Module):
    def __init__(self, din, dh, dout, *, rngs):
        init = nnx.initializers.lecun_normal()
        self.up = nnx.Linear(din, dh, use_bias=False, kernel_init=init, rngs=rngs)
        self.down = nnx.Linear(dh, dout, use_bias=False, kernel_init=init, rngs=rngs)
    def __call__(self, x):
        return self.down(nnx.relu(self.up(x)))

@nnx.jit
def nnx_mse_step(model, optimizer, x, y):
    loss, grads = nnx.value_and_grad(lambda m: jnp.mean((m(x) - y) ** 2))(model)
    optimizer.update(model, grads)
    return loss

plain = PlainMLP(D_TP, H_TP, D_TP, rngs=nnx.Rngs(0))
plain_opt = nnx.Optimizer(plain, optax.adam(1e-2), wrt=nnx.Param)
with jax.set_mesh(mesh2d):
    sharded = ShardedMLP(D_TP, H_TP, D_TP, rngs=nnx.Rngs(0))
    sharded_opt = nnx.Optimizer(sharded, optax.adam(1e-2), wrt=nnx.Param)
    assert max_diff(nnx.state(sharded, nnx.Param), nnx.state(plain, nnx.Param)) == 0.0   # 初始值完全相同
    xs, ys = jax.device_put(X32, P("data", None)), jax.device_put(Y32, P("data", None))
    l_sh = [float(nnx_mse_step(sharded, sharded_opt, xs, ys)) for _ in range(5)]
    print("分片后 up.kernel:", jax.typeof(sharded.up.kernel[...]),
          "| Adam mu:", jax.typeof(sharded_opt.opt_state[0].mu["up"]["kernel"][...]))
l_pl = [float(nnx_mse_step(plain, plain_opt, jnp.asarray(X32), jnp.asarray(Y32))) for _ in range(5)]
d = max_diff(nnx.state(sharded, nnx.Param), nnx.state(plain, nnx.Param))
print("分片 loss:", np.round(l_sh, 6), "\n单设备 loss:", np.round(l_pl, 6), f"\n参数最大差 {d:.2e}")
assert np.allclose(l_sh, l_pl, rtol=1e-5) and d < 1e-5
分片后 up.kernel: float32[64,256@model] | Adam mu: float32[64,256@model]
分片 loss: [1.62052  1.047089 0.719013 0.460227 0.314597] 
单设备 loss: [1.62052  1.047089 0.719013 0.460227 0.314597] 
参数最大差 2.68e-07

流水线并行:概念与调度示意

流水线并行在 JAX 里没有"一行注解"的写法(要用 shard_map + lax.ppermute 在阶段之间传激活,MaxText 等库有现成的实现),这里只讲调度和气泡。

GPipe 调度:$p$ 个阶段、$m$ 个微批次。前向时,第 $s$ 个阶段在时间步 $t$ 处理第 $t-s$ 个微批次;所有前向都结束后再做反向。总时间步数是 $m+p-1$(只算前向),每个阶段只有 $m$ 步在干活,所以气泡比例为

$$ \text{bubble}=\frac{p-1}{m+p-1}. $$

$m\gg p$ 时气泡趋于 0,但所有微批次的激活都要存到反向时用(内存随 $m$ 增长)。1F1B 调度让每个阶段做完一个前向就尽快做对应的反向,把同时在途的激活数限制在 $p$ 以内,气泡比例不变。

def gpipe_forward_schedule(p, m):
    """返回 grid[stage][t] = 微批次编号(-1 表示空闲)。"""
    T = m + p - 1
    grid = [[-1] * T for _ in range(p)]
    for s in range(p):
        for mb in range(m):
            grid[s][s + mb] = mb
    return grid

def show(grid):
    for s, row in enumerate(grid):
        print(f"阶段 {s} (设备 {s}) | " + " ".join(f"F{mb}" if mb >= 0 else " ·" for mb in row))

for p_, m_ in [(4, 1), (4, 4), (4, 16)]:
    g = gpipe_forward_schedule(p_, m_)
    busy = sum(mb >= 0 for row in g for mb in row)
    bubble = 1 - busy / (p_ * len(g[0]))
    print(f"\np = {p_} 个阶段,m = {m_} 个微批次:气泡比例 {bubble:.1%}(公式 (p-1)/(m+p-1) = {(p_ - 1) / (m_ + p_ - 1):.1%})")
    if m_ <= 4:
        show(g)
    assert np.isclose(bubble, (p_ - 1) / (m_ + p_ - 1))
p = 4 个阶段,m = 1 个微批次:气泡比例 75.0%(公式 (p-1)/(m+p-1) = 75.0%)
阶段 0 (设备 0) | F0  ·  ·  ·
阶段 1 (设备 1) |  · F0  ·  ·
阶段 2 (设备 2) |  ·  · F0  ·
阶段 3 (设备 3) |  ·  ·  · F0

p = 4 个阶段,m = 4 个微批次:气泡比例 42.9%(公式 (p-1)/(m+p-1) = 42.9%)
阶段 0 (设备 0) | F0 F1 F2 F3  ·  ·  ·
阶段 1 (设备 1) |  · F0 F1 F2 F3  ·  ·
阶段 2 (设备 2) |  ·  · F0 F1 F2 F3  ·
阶段 3 (设备 3) |  ·  ·  · F0 F1 F2 F3

p = 4 个阶段,m = 16 个微批次:气泡比例 15.8%(公式 (p-1)/(m+p-1) = 15.8%)
# 极简"流水线":4 个阶段的权重放在 4 个不同设备上,微批次依次流过(顺序执行,只为说明数据流)
devs = jax.devices()[:4]
r = np.random.default_rng(7)
stage_w = [jax.device_put(jnp.asarray(r.normal(size=(32, 32)) / np.sqrt(32), jnp.float32), devs[s]) for s in range(4)]
stage_fn = jax.jit(lambda w, h: jnp.tanh(h @ w))       # 每个阶段一层;权重作为参数传入

X_pipe = jnp.asarray(r.normal(size=(64, 32)), jnp.float32)
micro = jnp.split(X_pipe, 8)                            # 8 个微批次
outs = []
for mb in micro:
    h = mb
    for s in range(4):
        h = jax.device_put(h, devs[s])                  # 激活"发送"到下一个阶段所在的设备
        h = stage_fn(stage_w[s], h)
    outs.append(h)
y_pipe = jnp.concatenate([jax.device_put(o, devs[0]) for o in outs])
print("各阶段权重所在设备:", [list(w.devices())[0].id for w in stage_w], "| 最后一个微批次输出所在设备:", list(outs[-1].devices())[0].id)

# 与不切分的整模型前向对拍(NumPy float64)
h64 = np.asarray(X_pipe, np.float64)
for w in stage_w:
    h64 = np.tanh(h64 @ np.asarray(w, np.float64))
assert np.abs(np.asarray(y_pipe, np.float64) - h64).max() < 1e-5
print("流水线(按阶段放置 + 微批次)的输出与整模型前向一致 ✓")
各阶段权重所在设备: [0, 1, 2, 3] | 最后一个微批次输出所在设备: 3
流水线(按阶段放置 + 微批次)的输出与整模型前向一致 ✓

🔍 补充:怎么选并行策略

⚪ 选读:要为多机多卡训练设计并行方案时

策略切什么每层通信适用
数据并行 (DP)数据每步一次梯度 all-reduce模型放得下单设备
FSDP / ZeRO-3数据 + 参数/梯度/优化器状态每层前向 all-gather、反向 reduce-scatter模型放不下但单层放得下;通信量约为 DP 的 1.5 倍,可与计算重叠
张量并行 (TP)层内的权重矩阵每层前向、反向各 1–2 次 all-reduce单层太大或想降低每设备激活;通信频繁,限于高速互连的设备组内(如一台机器的 8 卡)
流水线并行 (PP)层与层之间阶段间点对点传激活跨慢速互连扩展;有气泡
序列 / 上下文并行序列维注意力中交换 K/V超长序列

实践中常见的组合是:机内 TP × 机间 FSDP(或 DP),需要时再加 PP。在 JAX 里,这只是一个多维 mesh(比如 ('data', 'fsdp', 'tensor'))加上每个参数的 PartitionSpec。同一份模型代码,换一组注解就换了并行策略。MaxText 用"逻辑轴名 → mesh 轴名"的规则表来管理这些注解(Flax 里的做法:nnx.with_partitioning 里写逻辑轴名,再用 nnx.logical_axis_rules 上下文把逻辑轴映射到 mesh 轴;Linen 对应 nn.logical_axis_rules)。


6.9 大规模优化算法

数据并行和梯度累积会带来更大的有效 batch,这会改变优化器的行为。带动量的 SGD 和 Adam 是基础,但规模上去以后,常常需要专门为超大 batch 或特定正则化需求设计的优化器。

AdamW:解耦权重衰减

在 Adam 里用"标准的" L2 正则化(把 $\lambda\theta$ 加进梯度),效果通常不理想:衰减项进入了自适应矩 $m_t$、$v_t$,历史梯度大的权重反而衰减得更少。AdamW 把权重衰减从梯度更新里解耦出来,在 Adam 步之后直接作用在权重上,更像 SGD 里的权重衰减:

  1. 计算梯度 $g_t=\nabla\mathcal L(\theta_{t-1})$;
  2. 更新矩估计 $m_t=\beta_1 m_{t-1}+(1-\beta_1)g_t$,$v_t=\beta_2 v_{t-1}+(1-\beta_2)g_t^2$;
  3. 偏差修正 $\hat m_t=m_t/(1-\beta_1^t)$,$\hat v_t=v_t/(1-\beta_2^t)$;
  4. Adam 步:$\theta'_t=\theta_{t-1}-\eta\,\hat m_t/(\sqrt{\hat v_t}+\epsilon)$;
  5. 权重衰减:$\theta_t=\theta'_t-\eta\lambda\,\theta_{t-1}$。

课程提到,这种解耦通常让 Transformer 这类大模型泛化更好、训练更稳,已经是大语言模型训练的标准选择。

LAMB:逐层自适应

batch 特别大的时候(几万甚至更大,通常靠多加速器数据并行实现),Adam 可能不稳定,或者要非常仔细地调学习率(尤其要有足够的预热)。LAMB(Layer-wise Adaptive Moments optimizer for Batch training)就是为这种场景设计的:先按 AdamW 算出每层的更新方向 $\Delta\theta_l$,再按这一层权重范数与更新范数之比(信任比率)来缩放:

$$ r_l=\frac{\lVert\theta_l\rVert}{\lVert\Delta\theta_l\rVert},\qquad \theta_{l,t}=\theta_{l,t-1}-\eta\, r_l\,\Delta\theta_l . $$

在大 batch、梯度方差变小时,它能防止某些层更新得过大或过小。课程提到,LAMB 让 BERT 能用超大 batch 训练,速度明显提升。

其他考量

  • 学习率调度:大模型训练非常依赖调度。预热:前几千步从小学习率线性增加,帮助早期稳定;衰减:之后按线性、余弦或逆平方根等方式下降。
  • 优化器状态内存:Adam(W)、LAMB 为每个参数维护两份状态,在大模型里可能和参数本身差不多大,甚至更大。分布式训练时,优化器状态也必须和参数、梯度一起分片。

Optax 中的实践(课程代码)

Optax 的典型用法分两步:先用参数初始化优化器状态;然后在训练步里,用梯度和当前状态算出更新和新状态。课程代码里调度器的参数直接写成了 ...:

# ❌ 旧写法(错误):warmup_cosine_decay_schedule(...) 的参数用省略号占位,无法运行
try:
    learning_rate_schedule = optax.warmup_cosine_decay_schedule(...)
    print("实测:可以运行")
except TypeError as e:
    print("实测报错:TypeError:", e)
实测报错:TypeError: warmup_cosine_decay_schedule() missing 3 required positional arguments: 'peak_value', 'warmup_steps', and 'decay_steps'
# ✅ 推荐写法:给出完整参数;update_step 的参数、梯度都作为 jit 参数传入
learning_rate_schedule = optax.warmup_cosine_decay_schedule(
    init_value=0.0, peak_value=1e-3, warmup_steps=1_000, decay_steps=100_000, end_value=1e-5)
optimizer = optax.adamw(learning_rate=learning_rate_schedule, weight_decay=0.01)

@jax.jit
def update_step(params, grads, opt_state):
    updates, opt_state = optimizer.update(grads, opt_state, params)
    return optax.apply_updates(params, updates), opt_state

p_demo = {"w": jnp.ones((4, 3)), "b": jnp.zeros(3)}
g_demo = {"w": jnp.full((4, 3), 0.5), "b": jnp.full(3, -0.2)}
s_demo = optimizer.init(p_demo)
p1, s1 = update_step(p_demo, g_demo, s_demo)
print("一步后 w[0]:", p1["w"][0], "| 第 0 步学习率 =", float(learning_rate_schedule(0)),
      "| 参数完全没变?", bool(jnp.all(p1["w"] == p_demo["w"])))
assert bool(jnp.all(p1["w"] == p_demo["w"]))          # η=0 时连权重衰减也不发生
p2, s2 = update_step(p1, g_demo, s1)
assert not bool(jnp.all(p2["w"] == p1["w"]))          # 第 1 步开始更新
一步后 w[0]: [1. 1. 1.] | 第 0 步学习率 = 0.0 | 参数完全没变? True

上面的输出说明了一个容易忽略的细节:Optax 的 adamw 里,权重衰减也要乘以学习率($\eta\lambda\theta$),所以预热第 0 步 $\eta=0$ 时连权重衰减都没有。下面用课程的 5 步公式手算一步 AdamW,再按信任比率公式手算一步 LAMB,和 Optax 对拍。

r = np.random.default_rng(0)
theta = {"w": jnp.asarray(r.normal(size=(8, 4)), jnp.float32), "b": jnp.asarray(r.normal(size=4), jnp.float32)}
grad = {"w": jnp.asarray(r.normal(size=(8, 4)), jnp.float32), "b": jnp.asarray(r.normal(size=4), jnp.float32)}
eta, lam, b1, b2, eps = 1e-2, 0.1, 0.9, 0.999, 1e-8

# —— AdamW:公式 1~5 ——
def adamw_one_step(th, g):
    m = (1 - b1) * g; v = (1 - b2) * g ** 2                  # m_0 = v_0 = 0
    m_hat, v_hat = m / (1 - b1), v / (1 - b2)
    th_prime = th - eta * m_hat / (jnp.sqrt(v_hat) + eps)    # 4) Adam 步
    return th_prime - eta * lam * th                         # 5) 解耦的权重衰减
manual = jax.tree.map(adamw_one_step, theta, grad)
ow = optax.adamw(eta, b1=b1, b2=b2, eps=eps, weight_decay=lam)
u, _ = ow.update(grad, ow.init(theta), theta)
print(f"AdamW:手写公式 vs optax.adamw,最大差 {max_diff(manual, optax.apply_updates(theta, u)):.1e}")
assert max_diff(manual, optax.apply_updates(theta, u)) < 1e-6

# 与"Adam + L2 正则"不同:L2 把 λθ 加进梯度,再被 1/sqrt(v) 归一化
l2 = optax.adam(eta, b1=b1, b2=b2, eps=eps)
u_l2, _ = l2.update(jax.tree.map(lambda g, t: g + lam * t, grad, theta), l2.init(theta), theta)
print(f"Adam+L2 与 AdamW 一步后的差:{max_diff(optax.apply_updates(theta, u_l2), manual):.3e}(二者不是一回事)")
assert max_diff(optax.apply_updates(theta, u_l2), manual) > 1e-4

# —— LAMB:每个参数张量("层")一个信任比率 ——
def lamb_one_step(th, g, wd=0.01):
    m = (1 - b1) * g; v = (1 - b2) * g ** 2
    adam_dir = (m / (1 - b1)) / (jnp.sqrt(v / (1 - b2)) + 1e-6)     # optax.lamb 默认 eps=1e-6
    delta = adam_dir + wd * th                                     # AdamW 方向
    r_l = jnp.linalg.norm(th) / jnp.linalg.norm(delta)             # 信任比率
    return th - eta * r_l * delta, r_l
lamb_manual = jax.tree.map(lambda t, g: lamb_one_step(t, g)[0], theta, grad)
ratios = jax.tree.map(lambda t, g: float(lamb_one_step(t, g)[1]), theta, grad)
ol = optax.lamb(eta, weight_decay=0.01)
u_l, _ = ol.update(grad, ol.init(theta), theta)
print(f"LAMB 信任比率:{ratios};手算 vs optax.lamb 最大差 {max_diff(lamb_manual, optax.apply_updates(theta, u_l)):.1e}")
assert max_diff(lamb_manual, optax.apply_updates(theta, u_l)) < 1e-6
AdamW:手写公式 vs optax.adamw,最大差 1.2e-07
Adam+L2 与 AdamW 一步后的差:2.325e-03(二者不是一回事)
LAMB 信任比率:{'b': 0.35019758343696594, 'w': 0.8128143548965454};手算 vs optax.lamb 最大差 7.5e-09

⚠️ 注意:容易以为 Optax 在 pmap 函数里会"自动处理优化器状态的分片"。其实 Optax 本身没有任何分片逻辑。优化器状态只是用 zeros_like 等从参数派生出来的 PyTree,所以它放在哪里完全跟着参数走。pmap 里参数是复制的,优化器状态也就是复制的(每个设备一整份,内存一点没省)。只有参数本身被分片了(6.8 节的 FSDP / 张量并行),状态才跟着分片。想要"优化器状态分片"(ZeRO-1/2),得自己给状态加分片注解。

big = {"w": jnp.zeros((1024, 256))}
# pmap 风格:参数复制到 8 个设备 → 状态也是 8 份完整拷贝
rep = jax.device_put(big, NamedSharding(jax.make_mesh((8,), ("d",), axis_types=(AxisType.Auto,)), P()))
st_rep = optax.adam(1e-3).init(rep)
print("复制的参数 → mu 的分片:", st_rep[0].mu["w"].sharding.spec,
      "| 每设备 mu 字节:", st_rep[0].mu["w"].addressable_shards[0].data.nbytes)
assert st_rep[0].mu["w"].addressable_shards[0].data.nbytes == big["w"].nbytes           # 每个设备一整份

# 参数分片 → 状态随之分片
with jax.set_mesh(mesh_fsdp):
    shd = {"w": jax.device_put(big["w"], P("fsdp", None))}
    st_shd = optax.adam(1e-3).init(shd)
    print("分片的参数 → mu:", jax.typeof(st_shd[0].mu["w"]),
          "| 每设备 mu 字节:", st_shd[0].mu["w"].addressable_shards[0].data.nbytes)
    assert st_shd[0].mu["w"].addressable_shards[0].data.nbytes * 8 == big["w"].nbytes

    # ZeRO-1:参数复制、只把优化器状态分片 —— 需要自己写注解
    st_zero1 = jax.tree.map(lambda a: jax.device_put(a, P("fsdp", None)) if a.ndim == 2 else a,
                            optax.adam(1e-3).init(jax.device_put(big, P())))
    print("ZeRO-1 风格(手动给状态加注解)→ mu:", jax.typeof(st_zero1[0].mu["w"]))
复制的参数 → mu 的分片: P() | 每设备 mu 字节: 1048576
分片的参数 → mu: float32[1024@fsdp,256] | 每设备 mu 字节: 131072
ZeRO-1 风格(手动给状态加注解)→ mu: float32[1024@fsdp,256]

✅ 大规模常用优化器:状态内存对比(Optax 0.2.9 实测可用)

优化器Optax API每个参数的状态特点 / 适用场景
SGD + 动量optax.sgd(lr, momentum=0.9)1 份CV 经典;大 batch 时配 LARS
Adam / AdamWoptax.adam / optax.adamw2 份($m$、$v$)默认首选;LLM 标准
AdamW(bf16 一阶矩)optax.adamw(..., mu_dtype=jnp.bfloat16)1.5 份省 25% 状态内存
Adafactoroptax.adafactor对矩阵:行和列两个向量($n+m$ 而非 $nm$),默认无动量状态内存极小;T5、PaLM 等使用
LAMBoptax.lamb2 份超大 batch(BERT 预训练)
LARSoptax.lars1 份(动量)超大 batch 的 SGD 变体(ResNet 大 batch 训练)
Lionoptax.lion1 份(动量),更新取符号状态比 Adam 少一半;学习率要比 AdamW 小 3–10 倍、权重衰减相应放大
SM3optax.sm3二阶统计量按行 + 列近似,但默认还带 1 份动量(momentum=0.9),合计约 1 份另一种省内存的自适应方法

另外,optax.contrib 里还有 Muon、Schedule-Free AdamW、Prodigy、DoG 等较新的方法(0.2.9 里都有)。下面算一算各优化器的状态是参数字节数的几倍,再单独看一个大矩阵在 Adafactor 下的状态形状。

opt_params = {"emb": jnp.zeros((4096, 512)), "w": jnp.zeros((512, 2048)), "b": jnp.zeros(2048)}
P_bytes = tree_bytes(opt_params)
candidates = {
    "sgd":                 optax.sgd(1e-2),
    "sgd+momentum":        optax.sgd(1e-2, momentum=0.9),
    "adamw":               optax.adamw(1e-3),
    "adamw (mu bf16)":     optax.adamw(1e-3, mu_dtype=jnp.bfloat16),
    "adafactor":           optax.adafactor(1e-3),
    "lamb":                optax.lamb(1e-3),
    "lars":                optax.lars(1e-2),
    "lion":                optax.lion(1e-4),
    "sm3":                 optax.sm3(1e-2),
}
state_ratio = {}
for name, o in candidates.items():
    state_ratio[name] = tree_bytes(o.init(opt_params)) / P_bytes
    print(f"{name:18s} 状态 / 参数 = {state_ratio[name]:.4f}")
assert state_ratio["sgd"] < 1e-3 and abs(state_ratio["adamw"] - 2) < 1e-3 and abs(state_ratio["lamb"] - 2) < 1e-3
assert abs(state_ratio["lion"] - 1) < 1e-3 and abs(state_ratio["lars"] - 1) < 1e-3
assert abs(state_ratio["adamw (mu bf16)"] - 1.5) < 1e-3
assert state_ratio["adafactor"] < 0.01

# Adafactor 对 (4096, 512) 矩阵只存一行一列的统计量
af_state = optax.adafactor(1e-3).init({"emb": jnp.zeros((4096, 512))})
shapes = [x.shape for x in jax.tree.leaves(af_state) if hasattr(x, "shape") and x.size > 1]
print("\nAdafactor 对 (4096, 512) 矩阵保存的非标量状态形状:", shapes)
assert (4096,) in shapes and (512,) in shapes
sgd                状态 / 参数 = 0.0000
sgd+momentum       状态 / 参数 = 1.0000
adamw              状态 / 参数 = 2.0000
adamw (mu bf16)    状态 / 参数 = 1.5000
adafactor          状态 / 参数 = 0.0029
lamb               状态 / 参数 = 2.0000
lars               状态 / 参数 = 1.0000
lion               状态 / 参数 = 1.0000
sm3                状态 / 参数 = 1.0029

Adafactor 对 (4096, 512) 矩阵保存的非标量状态形状: [(512,), (4096,)]

✅ 在小问题上实跑对比

同一个 4 层 MLP 回归任务、同一个初始化、同一个 batch,各优化器用常见的学习率量级(没有细调),训练 500 步,比较最终损失和每步耗时。注意这只是看"能不能跑、大致表现如何":小问题上的排名不能推广到大模型。Lion、LAMB、Adafactor 的优势主要在大 batch、大模型和内存紧张的场景。

r = np.random.default_rng(42)
X_opt = jnp.asarray(r.uniform(-2, 2, size=(1024, 16)), jnp.float32)
Y_opt = jnp.sin(X_opt[:, :1] * X_opt[:, 1:2]) + 0.5 * jnp.cos(X_opt[:, 2:3])
sizes = [16, 128, 128, 128, 1]
p_opt0 = [{"w": jnp.asarray(r.normal(size=(a, b)) * np.sqrt(2 / a), jnp.float32), "b": jnp.zeros(b)}
          for a, b in zip(sizes[:-1], sizes[1:])]

def opt_loss(p, x, y):
    h = x
    for layer in p[:-1]:
        h = jax.nn.gelu(h @ layer["w"] + layer["b"])
    return jnp.mean((h @ p[-1]["w"] + p[-1]["b"] - y) ** 2)

def make_runner(tx, steps=500):
    @jax.jit
    def run(p, x, y):
        def step(carry, _):
            p, s = carry
            l, g = jax.value_and_grad(opt_loss)(p, x, y)
            u, s = tx.update(g, s, p)
            return (optax.apply_updates(p, u), s), l
        (p, s), ls = lax.scan(step, (p, tx.init(p)), None, length=steps)
        return ls
    return run

sched = lambda peak: optax.warmup_cosine_decay_schedule(0.0, peak, 50, 500, peak * 0.01)
contenders = {
    "sgd+momentum": optax.sgd(sched(3e-2), momentum=0.9),
    "adamw":        optax.adamw(sched(3e-3), weight_decay=1e-4),
    "adafactor":    optax.adafactor(sched(3e-3), multiply_by_parameter_scale=False),
    "lamb":         optax.lamb(sched(1e-2), weight_decay=1e-4),
    "lars":         optax.lars(sched(1.0), weight_decay=1e-4),
    "lion":         optax.lion(sched(3e-4), weight_decay=1e-3),
}
init_loss = float(opt_loss(p_opt0, X_opt, Y_opt))
print(f"初始损失 {init_loss:.4f}\n{'优化器':14s} {'最终损失':>10s} {'状态/参数':>9s} {'500步耗时ms':>12s}")
final = {}
for name, tx_ in contenders.items():
    runner = make_runner(tx_)
    ls = runner(p_opt0, X_opt, Y_opt)
    final[name] = float(ls[-1])
    t_ms = bench(runner, p_opt0, X_opt, Y_opt, n=3)
    ratio = tree_bytes(tx_.init(p_opt0)) / tree_bytes(p_opt0)
    print(f"{name:14s} {final[name]:10.2e} {ratio:9.3f} {t_ms:12.1f}")
    assert np.isfinite(final[name]) and final[name] < 0.5 * init_loss, name     # 都能把损失降下来
初始损失 1.9866
优化器                  最终损失     状态/参数     500步耗时ms
sgd+momentum     5.07e-04     1.000        445.5
adamw            1.06e-06     2.000        410.3
adafactor        1.88e-06     0.087        430.1
lamb             1.68e-08     2.000        420.6
lars             1.98e-05     1.000        430.0
lion             1.97e-02     1.000        423.6

🔍 补充:大规模优化的几条经验

⚪ 选读:调大模型优化器的超参数时

  1. 全局梯度裁剪几乎是标配(optax.clip_by_global_norm(1.0)),能扛住 loss spike。在 FSDP / 张量并行下,global_norm 会自动跨分片计算(它只是对分片数组做 sum,编译器会插入 all-reduce)。
  2. 哪些参数不做权重衰减:LayerNorm 的 scale、bias 通常不衰减。Optax 用 mask 参数来控制(optax.adamw(..., mask=fn),fn 返回和参数同结构的布尔树)。
  3. 大 batch 的学习率:线性缩放规则(batch × k → lr × k)只在一定范围内成立,再往上就需要 LARS / LAMB 的逐层归一化,或者更长的预热。
  4. Lion 的超参数:它的更新是 $\mathrm{sign}(\cdot)$,每个分量的步长都是 $\eta$,所以学习率要小(约为 AdamW 的 1/3–1/10),权重衰减要相应放大,保持 $\eta\lambda$ 不变。
  5. 优化器状态的 dtype:mu_dtype=jnp.bfloat16 是最划算的省内存手段。二阶矩 $v$ 跨了很多个数量级,一般留在 fp32(或者用 Adafactor 的因子分解)。
# 例:LayerNorm / bias 不做权重衰减的 mask
p_mask_demo = {"dense": {"kernel": jnp.ones((3, 3)), "bias": jnp.ones(3)}, "ln": {"scale": jnp.ones(3), "bias": jnp.ones(3)}}
decay_mask = jax.tree.map_with_path(lambda path, _: path[-1].key == "kernel", p_mask_demo)
tx_masked = optax.adamw(1e-1, weight_decay=0.5, mask=decay_mask)
zero_g = jax.tree.map(jnp.zeros_like, p_mask_demo)
u_m, _ = tx_masked.update(zero_g, tx_masked.init(p_mask_demo), p_mask_demo)   # 梯度为 0,只剩权重衰减
print("mask:", decay_mask)
print("更新量:", jax.tree.map(lambda x: float(x.ravel()[0]), u_m))
assert np.isclose(float(u_m["dense"]["kernel"][0, 0]), -0.05) and float(u_m["ln"]["scale"][0]) == 0.0
mask: {'dense': {'bias': False, 'kernel': True}, 'ln': {'bias': False, 'scale': False}}
更新量: {'dense': {'bias': -0.0, 'kernel': -0.05000000074505806}, 'ln': {'bias': -0.0, 'scale': -0.0}}

6.10 实践:实现梯度检查点

这一节动手体会 jax.checkpoint 的内存—计算权衡。

原理回顾

设 $z=f(x)=f_2(f_1(x))$,记 $y=f_1(x)$。

  • 标准自动微分:前向同时算出 $y$ 和 $z$,并把 $y$ 留在内存里,供反向计算 $f_2$ 的梯度使用;
  • 对 $f_1$ 做检查点:前向算出 $y$ 后可以丢弃;反向需要 $y$ 时,用保存的输入 $x$ 重新执行 $f_1(x)$。这样就不必存储 $y$ 这样可能很大的中间张量。

用法就是把要做检查点的函数传给 jax.checkpoint:checkpointed_f1 = jax.checkpoint(f1),然后照常 y = checkpointed_f1(x)、z = f2(y)。对整个计算求梯度时,自动微分知道反向需要重算 checkpointed_f1。

课程示例

一个包含大中间激活的块:$y=\mathrm{GELU}(xW_1)$,$z=yW_2$,损失为均方误差。hidden_dim = 8192 让中间激活 $y$(batch × hidden)相对较大。

def compute_block(x, W1, W2):
    """一个可能包含大型中间激活的块。"""
    y = jnp.dot(x, W1)
    y = jax.nn.gelu(y)            # GELU 激活;y 就是希望避免存储的中间激活
    z = jnp.dot(y, W2)
    return z

def loss_fn_610(x, W1, W2, targets):
    z = compute_block(x, W1, W2)
    return jnp.mean((z - targets) ** 2)

grad_fn_standard = jax.jit(jax.value_and_grad(loss_fn_610, argnums=(1, 2)))

compute_block_checkpointed = jax.checkpoint(compute_block)
def loss_fn_checkpointed(x, W1, W2, targets):
    z = compute_block_checkpointed(x, W1, W2)
    return jnp.mean((z - targets) ** 2)
grad_fn_checkpointed = jax.jit(jax.value_and_grad(loss_fn_checkpointed, argnums=(1, 2)))

key = jax.random.key(42)
batch_size, input_dim, hidden_dim, output_dim = 64, 512, 8192, 512     # 大的隐藏维度
key, x_key, w1_key, w2_key, t_key = jax.random.split(key, 5)
x610 = jax.random.normal(x_key, (batch_size, input_dim))
W1_610 = jax.random.normal(w1_key, (input_dim, hidden_dim)) * 0.02
W2_610 = jax.random.normal(w2_key, (hidden_dim, output_dim)) * 0.02
t610 = jax.random.normal(t_key, (batch_size, output_dim))
# ❌ 旧写法(错误):用 time.time() 计时、且第一次调用就计时 → 测到的主要是编译时间
try:
    start_time = time.time()
    loss_std, (dW1_std, dW2_std) = grad_fn_standard(x610, W1_610, W2_610, t610)
    loss_std.block_until_ready(); dW1_std.block_until_ready(); dW2_std.block_until_ready()
    time_std = time.time() - start_time
    print("运行标准版本(编译 + 执行)...")
    print(f"标准损失: {loss_std:.4f}\n标准时间: {time_std:.4f} 秒")

    start_time = time.time()
    loss_ckpt, (dW1_ckpt, dW2_ckpt) = grad_fn_checkpointed(x610, W1_610, W2_610, t610)
    loss_ckpt.block_until_ready(); dW1_ckpt.block_until_ready(); dW2_ckpt.block_until_ready()
    time_ckpt = time.time() - start_time
    print("运行检查点版本(编译 + 执行)...")
    print(f"检查点损失: {loss_ckpt:.4f}\n检查点时间: {time_ckpt:.4f} 秒")

    print("比较梯度...")
    print(f"W1 最大绝对差: {jnp.max(jnp.abs(dW1_std - dW1_ckpt)):.2e}")
    print(f"W2 最大绝对差: {jnp.max(jnp.abs(dW2_std - dW2_ckpt)):.2e}")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
运行标准版本(编译 + 执行)...
标准损失: 1.2202
标准时间: 0.0452 秒
运行检查点版本(编译 + 执行)...
检查点损失: 1.2202
检查点时间: 0.0387 秒
比较梯度...
W1 最大绝对差: 0.00e+00
W2 最大绝对差: 0.00e+00
# ✅ 推荐写法:编译与执行分开计时;执行时间先预热、多次取平均;内存用 memory_analysis() 定量
def compile_time(f, *args):
    t0 = time.perf_counter()
    compiled = jax.jit(f).lower(*args).compile()
    return (time.perf_counter() - t0) * 1e3, compiled

args610 = (x610, W1_610, W2_610, t610)
report = {}
for name, f in [("标准", jax.value_and_grad(loss_fn_610, argnums=(1, 2))),
                ("检查点", jax.value_and_grad(loss_fn_checkpointed, argnums=(1, 2)))]:
    ct, compiled = compile_time(f, *args610)
    rt = bench(compiled, *args610, n=20)
    temp = compiled.memory_analysis().temp_size_in_bytes / MiB
    report[name] = (ct, rt, temp)
    print(f"{name:4s}: 编译 {ct:7.1f} ms | 执行 {rt:6.2f} ms/步 | 临时内存 {temp:6.3f} MiB")
saving_610 = 1 - report["检查点"][2] / report["标准"][2]
print(f"单个块的临时内存节省:{saving_610:.1%};执行时间比:{report['检查点'][1] / report['标准'][1]:.2f}×")

# 梯度一致性(课程预期"数值上非常接近")
assert float(jnp.max(jnp.abs(dW1_std - dW1_ckpt))) < 1e-6 and float(jnp.max(jnp.abs(dW2_std - dW2_ckpt))) < 1e-6
assert report["检查点"][2] < report["标准"][2]

print("\n—— 标准版保存的残差 ——");   print_saved_residuals(loss_fn_610, *args610)
print("\n—— 检查点版保存的残差 ——"); print_saved_residuals(loss_fn_checkpointed, *args610)
标准  : 编译    26.3 ms | 执行   2.80 ms/步 | 临时内存  6.125 MiB
检查点 : 编译    28.3 ms | 执行   3.31 ms/步 | 临时内存  4.125 MiB
单个块的临时内存节省:32.7%;执行时间比:1.18×

—— 标准版保存的残差 ——
f32[] from a literal
f32[] from a literal
f32[64,512] from the argument x
f32[512,8192] from the argument W1
f32[8192,512] from the argument W2
f32[64,8192] output of dot_general from /tmp/ipykernel_90669/3383945358.py:3:8 (compute_block)
f32[64,8192] output of mul from /tmp/ipykernel_90669/3383945358.py:4:8 (compute_block)
f32[64,8192] output of one_minus_square from /tmp/ipykernel_90669/3383945358.py:4:8 (compute_block)
f32[64,8192] output of mul from /tmp/ipykernel_90669/3383945358.py:4:8 (compute_block)
f32[64,8192] output of mul from /tmp/ipykernel_90669/3383945358.py:4:8 (compute_block)
f32[64,512] output of mul from /tmp/ipykernel_90669/3383945358.py:10:20 (loss_fn_610)

—— 检查点版保存的残差 ——
f32[64,512] from the argument x
f32[512,8192] from the argument W1
f32[8192,512] from the argument W2
f32[64,512] output of mul from /tmp/ipykernel_90669/3383945358.py:17:20 (loss_fn_checkpointed)

⚠️ 注意:

  1. 峰值内存是可以直接量的。jax.jit(f).lower(*args).compile().memory_analysis() 不用运行就能给出 XLA 分配的临时缓冲区大小,上面量出来单个块的临时内存省了约 33%。print_saved_residuals 列出了被省掉的 5 个 f32[64,8192] 残差(x @ W1 和 GELU 的中间量)。
  2. 第一次调用的耗时主要是编译时间,不能拿来比较运行速度,所以不要用含编译的单次 time.time() 计时。应该先预热,再多次计时取平均(并 block_until_ready)。

结果分析与使用时机

  1. 内存:检查点版不用为反向存大激活 $y$(batch_size × hidden_dim),反向时用 compute_block 重算。hidden_dim 越大,省得越多。
  2. 计算时间:检查点版应该更慢,因为反向时重跑了一部分前向。慢多少取决于前向和反向的相对成本,以及硬件。
  3. 梯度精度:数学上完全相同,最多只有浮点运算顺序带来的极小差异(混合精度下更明显)。

什么时候用:中间激活很大(宽线性层、Transformer 自注意力);重算的成本比省下的内存划算;内存吃紧,想在固定预算内训练更大的模型或用更大的 batch。可以只挑某些层或块来用,内存和计算之间的平衡要靠实验去找。Flax 里的包装器是 flax.linen.remat(Linen),NNX 里是 nnx.remat。

🔍 补充:为什么单个块只省了约 33%?

⚪ 选读:想弄清检查点的收益到底从哪里来时

单块模型里,前向一结束反向就开始,而反向的第一件事就是重算 $y$。重算出来的 $y$ 和原来存着的 $y$,活着的时间几乎一样长,所以峰值省不了多少。检查点的收益来自多层:不做检查点时,$L$ 层的激活要从前向一直存到反向,峰值 $\propto L$;逐层检查点时,只存 $L$ 个层输入,外加"当前正在重算的那一层"的激活。下面的完整练习用一个多层 Transformer 来验证这一点。

✅ 完整实践:深层 Transformer 的检查点版本

用纯 JAX 写一个 Pre-LN Transformer 块(多头因果自注意力 + GELU MLP),8 层参数沿第 0 维堆叠,用 lax.scan 逐层执行。对比五种做法:不做检查点、逐层全重算、dots_saveable、dots_with_no_batch_dims_saveable、只保存命名的 MLP 隐藏层。每种都 assert 梯度和不做检查点一致,并报告临时内存省了多少、耗时多少。

TF = dict(L=8, B=8, T=128, D=256, H=4, F=1024)

def layer_norm(x, g, b, eps=1e-5):
    mu = x.mean(-1, keepdims=True)
    var = ((x - mu) ** 2).mean(-1, keepdims=True)
    return (x - mu) * lax.rsqrt(var + eps) * g + b

def causal_attention(x, p, n_heads):
    B, T, D = x.shape
    hd = D // n_heads
    q, k, v = jnp.split(x @ p["wqkv"], 3, axis=-1)
    q, k, v = (t.reshape(B, T, n_heads, hd) for t in (q, k, v))
    s = jnp.einsum("bthd,bshd->bhts", q, k) / np.sqrt(hd)
    mask = jnp.tril(jnp.ones((T, T), bool))
    a = jax.nn.softmax(jnp.where(mask, s, -1e30), axis=-1)
    o = jnp.einsum("bhts,bshd->bthd", a, v).reshape(B, T, D)
    return o @ p["wo"]

def transformer_block(x, p):
    h = x + causal_attention(layer_norm(x, p["ln1_g"], p["ln1_b"]), p, TF["H"])
    m = jax.nn.gelu(layer_norm(h, p["ln2_g"], p["ln2_b"]) @ p["w1"])
    m = checkpoint_name(m, "mlp_hidden")                  # 给 MLP 隐藏层起名字
    return h + m @ p["w2"]

def init_transformer(key, L, D, F):
    ks = jax.random.split(key, 4)
    n = lambda k, shape, fan_in: jax.random.normal(k, (L,) + shape) / np.sqrt(fan_in)
    return {"wqkv": n(ks[0], (D, 3 * D), D), "wo": n(ks[1], (D, D), D),
            "w1": n(ks[2], (D, F), D), "w2": n(ks[3], (F, D), F),
            "ln1_g": jnp.ones((L, D)), "ln1_b": jnp.zeros((L, D)),
            "ln2_g": jnp.ones((L, D)), "ln2_b": jnp.zeros((L, D))}

def make_tf_loss(remat=False, policy=None):
    blk = jax.checkpoint(transformer_block, policy=policy) if remat else transformer_block
    def loss(params, x, y):
        out, _ = lax.scan(lambda h, p: (blk(h, p), None), x, params)
        return jnp.mean((out - y) ** 2)
    return loss

k_p, k_x, k_y = jax.random.split(jax.random.key(610), 3)
tf_params = init_transformer(k_p, TF["L"], TF["D"], TF["F"])
tf_x = jax.random.normal(k_x, (TF["B"], TF["T"], TF["D"]))
tf_y = jax.random.normal(k_y, (TF["B"], TF["T"], TF["D"]))
print("参数量:", sum(x.size for x in jax.tree.leaves(tf_params)), "| 参数字节:", tree_bytes(tf_params) / MiB, "MiB")
参数量: 6299648 | 参数字节: 24.03125 MiB
tf_variants = {
    "不做检查点":                         make_tf_loss(False),
    "逐层全重算 (nothing_saveable)":        make_tf_loss(True, cp.nothing_saveable),
    "dots_saveable":                      make_tf_loss(True, cp.dots_saveable),
    "dots_with_no_batch_dims_saveable":   make_tf_loss(True, cp.dots_with_no_batch_dims_saveable),
    "save_only_these_names('mlp_hidden')": make_tf_loss(True, cp.save_only_these_names("mlp_hidden")),
}
tf_args = (tf_params, tf_x, tf_y)
g_tf_ref = jax.jit(jax.grad(tf_variants["不做检查点"]))(*tf_args)
tf_rows = {}
for name, f in tf_variants.items():
    gf = jax.grad(f)
    temp = mem_of(gf, *tf_args).temp_size_in_bytes / MiB
    g = jax.jit(gf)(*tf_args)
    err = max_diff(g, g_tf_ref) / max(float(jnp.abs(l).max()) for l in jax.tree.leaves(g_tf_ref))
    tf_rows[name] = (temp, bench(jax.jit(gf), *tf_args, n=5), err)

base_t, base_ms, _ = tf_rows["不做检查点"]
print(f"{'策略':38s} {'临时内存MiB':>10s} {'节省':>7s} {'耗时ms':>8s} {'时间比':>6s} {'梯度相对误差':>11s}")
for name, (temp, ms_, err) in tf_rows.items():
    print(f"{name:38s} {temp:10.2f} {1 - temp / base_t:7.1%} {ms_:8.1f} {ms_ / base_ms:6.2f} {err:11.1e}")
    assert err < 1e-5, name                                   # 梯度与不做检查点一致

tf_saving = 1 - tf_rows["逐层全重算 (nothing_saveable)"][0] / base_t
print(f"\n8 层 Transformer:逐层检查点节省 {tf_saving:.1%} 的临时内存(单块示例只有 {saving_610:.1%})")
assert tf_saving > 0.5 and tf_saving > saving_610
assert tf_rows["逐层全重算 (nothing_saveable)"][0] <= tf_rows["dots_saveable"][0] <= base_t
策略                                        临时内存MiB      节省     耗时ms    时间比      梯度相对误差
不做检查点                                      318.92    0.0%    115.5   1.00     0.0e+00
逐层全重算 (nothing_saveable)                    50.07   84.3%    110.9   0.96     0.0e+00
dots_saveable                              133.04   58.3%     92.0   0.80     0.0e+00
dots_with_no_batch_dims_saveable           108.04   66.1%    112.6   0.98     0.0e+00
save_only_these_names('mlp_hidden')         81.07   74.6%    248.5   2.15     0.0e+00

8 层 Transformer:逐层检查点节省 84.3% 的临时内存(单块示例只有 32.7%)
# 看看 dots_with_no_batch_dims_saveable 到底保存了什么:投影矩阵乘(无 batch 维)被保存,注意力的 einsum(有 batch 维)被重算
print_saved_residuals(tf_variants["dots_with_no_batch_dims_saveable"], *tf_args)
f32[8,256] from the argument params['ln1_b']
f32[8,256] from the argument params['ln1_g']
f32[8,256] from the argument params['ln2_b']
f32[8,256] from the argument params['ln2_g']
f32[8,256,1024] from the argument params['w1']
f32[8,1024,256] from the argument params['w2']
f32[8,256,256] from the argument params['wo']
f32[8,256,768] from the argument params['wqkv']
f32[8,8,128,768] output of scan from /tmp/ipykernel_90669/1377811808.py:36:17 (make_tf_loss.<locals>.loss)
f32[8,8,128,256] output of scan from /tmp/ipykernel_90669/1377811808.py:36:17 (make_tf_loss.<locals>.loss)
f32[8,8,128,1024] output of scan from /tmp/ipykernel_90669/1377811808.py:36:17 (make_tf_loss.<locals>.loss)
f32[8,8,128,256] output of scan from /tmp/ipykernel_90669/1377811808.py:36:17 (make_tf_loss.<locals>.loss)
f32[8,128,256] output of mul from /tmp/ipykernel_90669/1377811808.py:37:24 (make_tf_loss.<locals>.loss)

读结果:

  • 逐层全重算省得最多(每层只存层输入 [8,128,256]),代价是反向时把每层的前向重跑一遍;
  • dots_saveable 保存所有矩阵乘结果(包括注意力的 $QK^\top$ 和 $AV$),逐元素运算(LayerNorm、GELU、softmax)重算,内存在前两者之间。理论上重算量也在两者之间,但 CPU 上的实际耗时不一定按这个顺序排(见下面的说明);
  • dots_with_no_batch_dims_saveable 只保存权重投影(x @ W 这类没有 batch 维的 dot),注意力里带 batch 维的 einsum 被重算。这是 LLM 训练里常用的折中,因为注意力分数矩阵 $O(T^2)$ 最占内存;
  • 命名策略让你只保存某一个指定的张量(这里是 MLP 隐藏层),适合根据 profile 结果手动调优。

CPU 上的耗时差异只供参考(上表里不做检查点甚至可能比全重算还慢,这和 CPU 上内存分配、缓存的开销有关)。在 GPU / TPU 上,矩阵乘更快,内存带宽更关键,各策略的相对排名可能不一样。

✅ 用 NNX 写同一个 Transformer,用 nnx.remat 做逐层检查点

class NNXBlock(nnx.Module):
    def __init__(self, D, H, F, *, rngs):
        self.n_heads = H
        self.ln1, self.ln2 = nnx.LayerNorm(D, rngs=rngs), nnx.LayerNorm(D, rngs=rngs)
        self.qkv = nnx.Linear(D, 3 * D, use_bias=False, rngs=rngs)
        self.proj = nnx.Linear(D, D, use_bias=False, rngs=rngs)
        self.fc1, self.fc2 = nnx.Linear(D, F, rngs=rngs), nnx.Linear(F, D, rngs=rngs)

    def attn(self, x):
        B, T, D = x.shape
        hd = D // self.n_heads
        q, k, v = (t.reshape(B, T, self.n_heads, hd) for t in jnp.split(self.qkv(x), 3, axis=-1))
        s = jnp.einsum("bthd,bshd->bhts", q, k) / np.sqrt(hd)
        a = jax.nn.softmax(jnp.where(jnp.tril(jnp.ones((T, T), bool)), s, -1e30), axis=-1)
        return self.proj(jnp.einsum("bhts,bshd->bthd", a, v).reshape(B, T, D))

    def __call__(self, x):
        h = x + self.attn(self.ln1(x))
        return h + self.fc2(jax.nn.gelu(self.fc1(self.ln2(h))))

def run_block(block, x):
    return block(x)
run_block_remat = nnx.remat(run_block)              # 逐层检查点

class NNXTransformer(nnx.Module):
    def __init__(self, L, D, H, F, *, use_remat, rngs):
        self.blocks = nnx.List([NNXBlock(D, H, F, rngs=rngs) for _ in range(L)])
        self.use_remat = use_remat
    def __call__(self, x):
        f = run_block_remat if self.use_remat else run_block
        for blk in self.blocks:
            x = f(blk, x)
        return x

def nnx_tf_loss(model, x, y):
    return jnp.mean((model(x) - y) ** 2)

nnx_tf = {}
for use_remat in [False, True]:
    m = NNXTransformer(TF["L"], TF["D"], TF["H"], TF["F"], use_remat=use_remat, rngs=nnx.Rngs(0))
    gdef, st = nnx.split(m)
    pure = lambda st, x, y: jax.grad(lambda s: nnx_tf_loss(nnx.merge(gdef, s), x, y))(st)
    temp = mem_of(pure, st, tf_x, tf_y).temp_size_in_bytes / MiB
    grad_step = nnx.jit(nnx.grad(nnx_tf_loss))
    g = grad_step(m, tf_x, tf_y)
    nnx_tf[use_remat] = (temp, bench(grad_step, m, tf_x, tf_y, n=5), g)
    print(f"nnx.remat={use_remat!s:5s}: 临时内存 {temp:7.2f} MiB, 耗时 {nnx_tf[use_remat][1]:6.1f} ms")
nnx_saving = 1 - nnx_tf[True][0] / nnx_tf[False][0]
err = max_diff(nnx_tf[True][2], nnx_tf[False][2])
print(f"NNX 逐层检查点:内存节省 {nnx_saving:.1%},梯度最大差 {err:.1e}")
assert err < 1e-5 and nnx_saving > 0.5
nnx.remat=False: 临时内存  227.19 MiB, 耗时  160.1 ms
nnx.remat=True : 临时内存   34.03 MiB, 耗时  189.0 ms
NNX 逐层检查点:内存节省 85.0%,梯度最大差 0.0e+00

✍️ 动手练习

课程本章的练习就是 6.10 的实践(实现梯度检查点,比较内存、时间和梯度),上面已经完整做过,还扩展到了 8 层 Transformer。下面再给 4 道有分量的练习,每道先自己写,再看参考答案。

练习 1:按 token 数加权的梯度累积。 语言模型每个微批次里的有效 token 数不一样(有 padding)。损失定义为"全局 batch 中所有有效 token 的平均损失"。写一个 lax.scan 版的累积函数,让结果和一次性在整个 batch 上算的梯度完全一致;再验证"对每个微批次的均值直接取平均"是错的。

自检接口:tok_loss_sum_count(w, x, y, m) 返回(有效 token 的损失和,有效 token 数);accum_token_weighted(w, x, y, m, n_micro) 用 lax.scan 累积并返回对全局 token 平均损失的梯度。形状:x (B, T, D),y、m 为 (B, T),w (D,)。

练习 2:$\sqrt L$ 分组检查点。 64 层的残差网络(每层 $x\leftarrow x+\tanh(xW)$),每 $k$ 层作为一组做一次 jax.checkpoint(组内正常保存激活),也就是两层嵌套的 scan。对 $k=1,2,4,\dots,64$ 测临时内存,找出最省内存的 $k$,并验证梯度一致。设每层输入占 $a_{\text{in}}$,一层内部要保存的激活占 $a_{\text{layer}}$,那么内存 $\approx \frac{L}{k}a_{\text{in}}+k\,a_{\text{layer}}$,最优 $k^{*}=\sqrt{L\,a_{\text{in}}/a_{\text{layer}}}$。一层的激活和层输入同一个量级时,就是经典的 $k\sim\sqrt L$。

自检接口:grouped_loss(params, x, k=None):params = {"w": (64, D, D)};k=None 不做检查点,否则每 k 层一组、每组一个 jax.checkpoint;返回 jnp.mean(x_out ** 2)。

练习 3:用 jax.shard_map 手写 FSDP。 参数按第 0 维切到 8 个设备,函数体里用 lax.all_gather(..., tiled=True) 取回完整权重再计算,batch 也按设备切分,损失用 lax.pmean 求全局平均。验证:前向损失和单设备一致;对它求 jax.grad 得到的梯度也和单设备一致,而且梯度仍然是分片的(all_gather 的转置就是 reduce-scatter)。

自检接口:fsdp_loss(p, x, y):经 jax.shard_map 包装的函数,p = {"w1": (D, H), "w2": (H, D)},模型为 $\mathrm{mean}\big((\mathrm{relu}(xW_1)W_2-y)^2\big)$;参数和 batch 都按 P("fsdp", None) 分片,在 jax.set_mesh(mesh_fsdp) 下调用,返回标量损失。

练习 4:为什么必须保留 fp32 主权重? 参数初值为 1.0,用 SGD、学习率 $10^{-3}$、梯度恒为 1 更新 100 步。比较:(a) 参数直接存成 bf16;(b) fp32 主权重、计算时转 bf16。解释 (a) 的结果。

自检接口:w_bf16:方案 (a) 100 步后的参数(bf16 标量);w_master:方案 (b) 100 步后的 fp32 主权重。

# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
    tok_loss_sum_count, accum_token_weighted
except NameError as e:
    print(f"⏳ 练习 1 尚未作答:{e}")
else:
    _r = np.random.default_rng(99)
    _B, _T, _D = 24, 10, 5
    _x = jnp.asarray(_r.normal(size=(_B, _T, _D)), jnp.float32)
    _y = jnp.asarray(_r.normal(size=(_B, _T)), jnp.float32)
    _len = np.where(np.arange(_B) % 3 == 0, 1, _T)                  # 有效长度悬殊:1 或 T
    _m = jnp.asarray(np.arange(_T)[None, :] < _len[:, None], jnp.float32)
    _w = jnp.asarray(_r.normal(size=(_D,)), jnp.float32)
    _s, _n = tok_loss_sum_count(_w, _x, _y, _m)
    assert float(_n) == float(_len.sum()), f"有效 token 数应为 {_len.sum()},实际 {float(_n)}"
    _s1, _ = tok_loss_sum_count(_w, _x[:10], _y[:10], _m[:10])
    _s2, _ = tok_loss_sum_count(_w, _x[10:], _y[10:], _m[10:])
    assert np.isclose(float(_s1 + _s2), float(_s), rtol=1e-5), "第一个返回值应是损失的「和」(可以按样本拆分相加),不是均值"
    _g_full = jax.grad(lambda w: tok_loss_sum_count(w, _x, _y, _m)[0] / tok_loss_sum_count(w, _x, _y, _m)[1])(_w)
    for _nm in (2, 4, 8):
        _g = accum_token_weighted(_w, _x, _y, _m, n_micro=_nm)
        assert max_diff(_g, _g_full) < 1e-5, f"n_micro={_nm} 时与全 batch 梯度不一致(差 {max_diff(_g, _g_full):.2e})"
    assert "scan" in str(jax.make_jaxpr(partial(accum_token_weighted, n_micro=4))(_w, _x, _y, _m)), "题目要求用 lax.scan 做累积"
    print("✅ 练习 1 自检通过")
⏳ 练习 1 尚未作答:name 'tok_loss_sum_count' is not defined
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
    grouped_loss
except NameError as e:
    print(f"⏳ 练习 2 尚未作答:{e}")
else:
    _r = np.random.default_rng(21)
    _D = 128
    _p = {"w": jnp.asarray(_r.normal(size=(64, _D, _D)) / np.sqrt(_D), jnp.float32)}
    _x = jnp.asarray(_r.normal(size=(128, _D)), jnp.float32)
    _xs = np.asarray(_x, np.float64)                              # 前向独立参考:64 层 x ← x + tanh(x W)
    for _l in range(64):
        _xs = _xs + np.tanh(_xs @ np.asarray(_p["w"][_l], np.float64))
    assert np.isclose(float(grouped_loss(_p, _x)), np.mean(_xs ** 2), rtol=1e-4), "k=None 时的损失与 64 层残差网络的定义不符"
    _g0 = jax.jit(jax.grad(grouped_loss))(_p, _x)
    _mem = {}
    for _k in (1, 8, 64):
        _f = partial(grouped_loss, k=_k)
        assert "checkpoint" in str(jax.make_jaxpr(_f)(_p, _x)) or "remat" in str(jax.make_jaxpr(_f)(_p, _x)), f"k={_k} 时应使用 jax.checkpoint"
        assert max_diff(jax.jit(jax.grad(_f))(_p, _x), _g0) < 1e-4, f"k={_k} 时梯度与不做检查点时不一致"
        _mem[_k] = mem_of(jax.grad(_f), _p, _x).temp_size_in_bytes
    print({k: f"{v / MiB:.2f} MiB" for k, v in _mem.items()})
    assert _mem[8] < _mem[1] and _mem[8] < _mem[64], "k=8(≈√L)的临时内存应少于 k=1 和 k=64"
    print("✅ 练习 2 自检通过")
⏳ 练习 2 尚未作答:name 'grouped_loss' is not defined
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
    fsdp_loss
except NameError as e:
    print(f"⏳ 练习 3 尚未作答:{e}")
else:
    _r = np.random.default_rng(77)
    _D, _H, _B = 32, 64, 32
    _p = {"w1": jnp.asarray(_r.normal(size=(_D, _H)) * 0.2, jnp.float32), "w2": jnp.asarray(_r.normal(size=(_H, _D)) * 0.2, jnp.float32)}
    _x = jnp.asarray(_r.normal(size=(_B, _D)), jnp.float32)
    _y = jnp.asarray(_r.normal(size=(_B, _D)), jnp.float32)
    _ref = lambda p, x, y: jnp.mean((jax.nn.relu(x @ p["w1"]) @ p["w2"] - y) ** 2)
    _l_ref, _g_ref = jax.value_and_grad(_ref)(_p, _x, _y)
    with jax.set_mesh(mesh_fsdp):
        _ps = {k: jax.device_put(v, P("fsdp", None)) for k, v in _p.items()}
        _xs, _ys = jax.device_put(_x, P("fsdp", None)), jax.device_put(_y, P("fsdp", None))
        _l = jax.jit(fsdp_loss)(_ps, _xs, _ys)
        _g = jax.jit(jax.grad(fsdp_loss))(_ps, _xs, _ys)
        _hlo = jax.jit(jax.grad(fsdp_loss)).lower(_ps, _xs, _ys).compile().as_text()
    assert np.isclose(float(_l), float(_l_ref), rtol=1e-5), f"损失 {float(_l):.6f} 与单设备 {float(_l_ref):.6f} 不一致(各设备的局部损失要做全局平均)"
    assert max_diff(_g, _g_ref) < 1e-5, f"梯度与单设备不一致(差 {max_diff(_g, _g_ref):.2e})"
    for _k in ("w1", "w2"):
        assert _g[_k].sharding.spec == P("fsdp", None), f"梯度 {_k} 应保持按第 0 维分片,实际 {_g[_k].sharding.spec}"
    _cc = collectives(_hlo)
    assert _cc["all-gather"] >= 2, f"前向应 all_gather 取回两个权重:{_cc}"
    assert _cc["reduce-scatter"] >= 2, f"反向应出现 reduce-scatter(all_gather 的转置):{_cc}"
    print("✅ 练习 3 自检通过")
⏳ 练习 3 尚未作答:name 'fsdp_loss' is not defined
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
    w_bf16, w_master
except NameError as e:
    print(f"⏳ 练习 4 尚未作答:{e}")
else:
    assert jnp.asarray(w_bf16).dtype == jnp.bfloat16, f"方案 (a) 的参数应一直存成 bf16,实际 dtype 为 {jnp.asarray(w_bf16).dtype}"
    assert jnp.asarray(w_master).dtype == jnp.float32, f"方案 (b) 的主权重应为 fp32,实际 dtype 为 {jnp.asarray(w_master).dtype}"
    assert np.isclose(float(w_master), 1.0 - 100 * 1e-3, atol=1e-5), f"fp32 主权重 100 步后应约为 0.9,实际 {float(w_master)}"
    assert float(w_bf16) == 1.0, f"bf16 参数 100 步后应仍为 1.0(每步更新都被舍入掉),实际 {float(w_bf16)}"
    print("✅ 练习 4 自检通过")
⏳ 练习 4 尚未作答:name 'w_bf16' is not defined

练习 4 解释:bf16 只有 8 位有效二进制位,在 1.0 附近相邻可表示数相差 $2^{-8}\approx0.0039$。每步更新 $10^{-3}$ 小于半个间距,1.0 - 0.001 被舍入回 1.0,100 步后参数一点没变。fp32 主权重能把这些小更新累积起来($1-100\times10^{-3}=0.9$),计算时再转成 bf16 就行。这就是混合精度必须"参数 fp32、计算 bf16"的原因。


📝 测验

下面 8 道题,答案和解析在最后的折叠区。

1. 一个 10 亿参数的模型,用 fp32 参数 + fp32 梯度 + Adam(fp32 的 $m$、$v$)训练,不计激活,至少需要多少内存?

  • A. 4 GB B. 8 GB C. 12 GB D. 16 GB

2. 下面哪种做法不能降低激活内存的峰值?

  • A. 对每个 Transformer 层做 jax.checkpoint
  • B. 把整个模型的损失函数包进一个 jax.checkpoint
  • C. 用梯度累积把 batch 拆成 4 个微批次
  • D. 用 bf16 计算激活

3. 关于 jax.checkpoint(f, policy=jax.checkpoint_policies.dots_saveable),说法正确的是?

  • A. 保存所有中间结果,等价于不做检查点
  • B. 只保存矩阵乘(dot_general)的输出,逐元素运算在反向时重算
  • C. 只保存被 checkpoint_name 标记的值
  • D. 把残差卸载到主机内存

4. 在 Flax 0.12.12 的 NNX 中,下列哪一行会报 TypeError?

  • A. nnx.Optimizer(model, optax.adam(1e-3), wrt=nnx.Param)
  • B. nnx.Optimizer(model, optax.adam(1e-3))
  • C. optimizer.update(model, grads)
  • D. nnx.state(model, nnx.BatchStat)

5. 用 fp32 参数、bf16 计算(astype(bfloat16) 后做矩阵乘),对参数求 jax.grad,梯度的 dtype 是?

  • A. bfloat16 B. float16 C. float32 D. 取决于 preferred_element_type

6. 在 ('data', 'model') 二维 mesh 上做 Megatron 式 MLP:$W_1$ 为 P(None, 'model'),$W_2$ 为 P('model', None),输入为 P('data', None)。前向需要几次跨设备通信?

  • A. 0 次 B. 1 次 all-reduce(第二个矩阵乘之后) C. 2 次 all-gather D. 每个矩阵乘各一次 all-reduce

7. 关于 FSDP(ZeRO-3),说法错误的是?

  • A. 参数、梯度、优化器状态都按设备分片
  • B. 前向用到某层时先 all-gather 完整权重
  • C. 每个设备只计算模型的一部分层
  • D. 在 JAX 中可以通过给参数标注 P('fsdp', None) 并在使用前 reshard(w, P()) 实现

8. 用 optax.MultiSteps(optax.adam(1e-3), every_k_schedule=4) 训练,每个微批次调用一次 update。下列说法正确的是?

  • A. 每次调用都更新参数,只是学习率除以 4
  • B. 前 3 次返回全零更新,第 4 次用 4 个微批次的平均梯度做一次 Adam 更新
  • C. 需要手动把梯度除以 4
  • D. 优化器状态不变,因为它只是一个调度器
点击展开:答案与解析
  1. D。每个参数 4(参数)+ 4(梯度)+ 8($m$、$v$)= 16 字节,$10^9\times16$ B = 16 GB(6.1 节)。
  2. B。整个模型只包一个检查点,反向开始时会把整个前向重算一遍,并保存全部激活,峰值和不做检查点一样(6.6 节的注意里测得 83.25 MiB vs 83.25 MiB)。A 逐层检查点有效;C 每次只有一个微批次的激活;D 激活字节数减半。
  3. B。A 是 everything_saveable,C 是 save_only_these_names,D 是 offload_* 系列(6.6 补充的策略表)。
  4. B。Flax 0.11 起 wrt 是必填关键字参数,不传会报 TypeError: Missing required argument wrt(6.2 补充)。
  5. C。astype 的转置把余切转回参数的 dtype,所以参数梯度是 fp32;反向中流动的激活梯度才是 bf16(6.7 节的注意)。
  6. B。列切分的第一层不需要通信,行切分的第二层产生部分和,一次 all-reduce 求和(6.8 节统计到 all-reduce: 1)。
  7. C。FSDP 里每个设备仍然计算所有层(处理自己的数据分片),只是参数平时分片存放,用的时候再 all-gather;"每个设备只算一部分层"是流水线并行。
  8. B。MultiSteps 前 $k-1$ 次只累积,第 $k$ 次用平均梯度(use_grad_mean=True)更新,与大 batch 的一次 Adam 更新一致(6.5 节测得参数差 $<10^{-6}$);它的状态里多了一份累积缓冲区。
# 验证测验第 1、4、5 题
print("Q1:", 1e9 * (4 + 4 + 8) / 1e9, "GB")
try:
    nnx.Optimizer(nnx.Linear(2, 2, rngs=nnx.Rngs(0)), optax.adam(1e-3))
except TypeError as e:
    print("Q4: B →", type(e).__name__)
w_q5 = jnp.ones((3, 2))
print("Q5:", jax.grad(lambda w: jnp.sum(jnp.ones((1, 3), jnp.bfloat16) @ w.astype(jnp.bfloat16)).astype(jnp.float32))(w_q5).dtype)
assert jax.grad(lambda w: jnp.sum(jnp.ones((1, 3), jnp.bfloat16) @ w.astype(jnp.bfloat16)).astype(jnp.float32))(w_q5).dtype == jnp.float32
Q1: 16.0 GB
Q4: B → TypeError
Q5: float32

本章更新说明

下面汇总本章在 JAX 0.12.0.1 / Flax 0.12.12 / Optax 0.2.9(CPU)下需要注意的写法和结论。除了硬件规格和计时,每条都在正文对应位置用 assert 验证过。

编号小节要点位置
16.1Adam/AdamW 的优化器状态恰好是参数的 2 倍($m$、$v$);只有把混合精度的 fp32 主权重也算进去,才到 3 倍6.1 内存估算单元后
26.2Flax 的推荐 API 是 NNX(Linen 是旧 API,但没有废弃);Haiku 自 2023-07 起进入维护模式,DeepMind 推荐改用 Flax。主线用 NNX 写,并给出 Linen / NNX / Haiku 对照表6.2 开头的注意 + 对照表
36.2/6.3jax.random.PRNGKey、jax.tree_util.tree_map 改用 jax.random.key、jax.tree.map;NNX 里 Variable 的 .value 已弃用(DeprecationWarning),改用 var[...]6.2 补充
46.3打包的训练状态(参数 + Adam 状态 + key + step)共 21 个叶子:参数 6 + Adam 状态 13(count 1 + mu 6 + nu 6)+ key 1 + step 16.3 PyTree 示例
56.3jax.device_put_replicated 在 0.12 已移除(AttributeError);恢复检查点后用 jax.device_put + NamedSharding,或让 Orbax 按带分片的抽象状态直接恢复6.3 检查点
66.4jax.device_put_replicate 是拼写错误,而正确拼写的 device_put_replicated 也已移除;复制模型状态改用 jax.device_put(堆叠数组, NamedSharding(...))6.4 复制模型状态
76.4数据并行不再需要 pmap + 手工 reshape 设备维 + lax.pmean:用 jit + NamedSharding(batch 维 P('data')),编译器自动插入梯度 all-reduce,结果与单设备一致6.4 现代写法
86.5均值损失、等大微批次、没有 BatchNorm 时,梯度累积的平均梯度与完整 batch 的梯度完全相等(差 $\sim10^{-7}$);求和损失、不等大微批次、BN 才会不同6.5 开头的注意 + 补充
96.5在 accumulated_train_step 内部定义 @jax.jit def apply_update,每次调用都是新函数对象,5 步重新追踪 5 次;应把 jit 函数定义在外层,或把整个累积放进一个 jit + lax.scan(重新编译的触发条件总表见 入门 02 JIT编译 §2.3)6.5 课程实现
106.6w1、w2 不能用同一个 key 生成:两者元素个数相同,展平后是完全相同的随机序列;应先 split6.6 课程示例
116.6整个模型包进一个 checkpoint 不降低峰值内存(83.25 vs 83.25 MiB),逐层检查点才有效(37 MiB)。FLOPs 至多约 +33%(多一次前向,$3F\to4F$),不会翻倍:逐层 +16.8%;整模型一个 checkpoint 只求 grad 时 +0%(原始前向被 DCE),value_and_grad 时约 +33%6.6 注意
126.6检查点的进阶工具:0.12 可用的全部 jax.checkpoint_policies、checkpoint_name、print_saved_residuals(需显式 import jax.ad_checkpoint)、memory_analysis()、nnx.remat6.6 补充
136.7混合精度下对 fp32 参数求导,得到的是 fp32 梯度(Linen、NNX 都一样);减半的是激活和激活梯度的内存6.7 注意
146.7Optax 0.2.9 没有 loss scaling 工具,只有 apply_if_finite / skip_not_finite;Flax 有 flax.training.dynamic_scale.DynamicScale;本章手写了动态 loss scaling6.7 loss scaling
156.8张量并行、FSDP 及其组合都是多维 Mesh + PartitionSpec 注解,jit 自动插入通信,不用 pmap 设备子集或 device_put 手动放置;手写通信用 jax.shard_map。0.12 的 make_mesh 默认 Explicit 轴,收缩维被切分的矩阵乘要给 out_sharding6.8 注意
166.9optax.warmup_cosine_decay_schedule(...) 用省略号占位会直接 TypeError;要给出 init_value, peak_value, warmup_steps, decay_steps6.9 课程代码
176.9Optax 没有分片逻辑,优化器状态放在哪完全跟着参数:pmap 中参数复制,状态也是每设备一整份;只有参数分片时状态才分片6.9 注意
186.10jit(f).lower(...).compile().memory_analysis() 可以直接得到临时内存(单块节省 32.7%,8 层 Transformer 节省 84.3%);比较速度不能用含编译的单次 time.time(),要先预热、block_until_ready、多次取平均6.10 注意
196.1单个加速器的 HBM 已不止 16–80 GB:新一代 GPU 到了 141–192 GB(H200 / B200 / MI300X,硬件规格,未实测);但 100 亿参数 + Adam 的 120 GB 静态内存仍接近或超过单卡容量,结论不变6.1 内存限制

📌 本章小结

  • 瓶颈三分:内存(参数 + 梯度 + 优化器状态 + 激活 + 工作区)、计算($\approx6\Phi T$ FLOPs)、通信(数据并行每步约 2 倍模型大小的 all-reduce)。混合精度 Adam 约 16 B/参数,单靠混合精度降不了静态内存。
  • 生态库:Flax 推荐 NNX。模块是可变对象,nnx.Param / nnx.BatchStat / 自定义 nnx.Variable 区分状态,nnx.split/merge/state 随时转成纯 PyTree;nnx.Optimizer(model, tx, wrt=nnx.Param) + optimizer.update(model, grads)。Haiku 已进入维护模式。
  • Optax:chain(clip_by_global_norm, adamw(schedule)) 是大模型的标配;MultiSteps 把梯度累积做进优化器;状态的分片跟随参数。
  • 数据并行:jit + NamedSharding 的全局视角取代了 pmap。数据用 P('data'),参数不加注解就是复制的,编译器自动插入 all-reduce;只要一个 PRNG key。
  • 梯度累积:lax.scan 遍历微批次,均值损失 + 等大微批次时与大 batch 梯度完全一致;求和损失、不等大微批次要加权,BatchNorm 无法等价。
  • 梯度检查点:要逐层(或每 $k$ 层)做,整模型一个 checkpoint 不降峰值;用 policy 在内存与计算间取舍(dots_saveable、dots_with_no_batch_dims_saveable、save_only_these_names);用 memory_analysis().temp_size_in_bytes 和 print_saved_residuals 定量验证。FLOPs 开销至多约 +33%(多一次前向)。
  • 混合精度:bf16 动态范围同 fp32、精度低;fp16 精度高但范围窄、需要 loss scaling(动态缩放 + 溢出跳过)。参数 fp32、计算 bf16、preferred_element_type=float32 累加;参数梯度是 fp32。
  • 模型并行:Megatron MLP = 列切分(无通信)+ 行切分(一次 all-reduce);FSDP = 参数/梯度/优化器状态分片 + 用时 all-gather;流水线并行的气泡比例 $(p-1)/(m+p-1)$。验证分片正确性时注意 Adam 会放大舍入差异。
  • 优化器:AdamW 是默认;LAMB / LARS 面向超大 batch;Adafactor 用因子分解把矩阵的二阶矩从 $nm$ 降到 $n+m$;Lion 只有一份动量;mu_dtype=bf16 省 25% 状态。

🗂️ 速查表

API作用
nnx.Module / nnx.Rngs(params=0, dropout=1)定义 NNX 模块 / 命名的随机数流
nnx.Param / nnx.BatchStat / class X(nnx.Variable)可训练参数 / BN 统计量 / 自定义状态
var[...]、var[...] = v读写 Variable(替代已弃用的 .value)
nnx.split(m, nnx.Param, ...) / nnx.merge(gdef, state)拆成 GraphDef + State / 合回模块
nnx.state(m, 过滤器) / nnx.update(m, state) / nnx.to_pure_dict取状态 / 写回状态 / 转普通字典
nnx.Optimizer(m, tx, wrt=nnx.Param)、opt.update(m, grads)NNX 优化器(wrt 必填)
nnx.jit / nnx.grad / nnx.value_and_grad / nnx.rematNNX 提升版变换(自动写回模块状态)
nnx.with_partitioning(init, (None, 'model'))给参数加分片注解(需在 jax.set_mesh 中构造)
nnx.eval_shape(lambda: Model(...))只构造抽象模型(检查点恢复模板)
optax.chain(optax.clip_by_global_norm(1.0), optax.adamw(sched, weight_decay=...))裁剪 + AdamW
optax.warmup_cosine_decay_schedule(init, peak, warmup, decay, end)预热 + 余弦衰减
optax.MultiSteps(opt, every_k_schedule=k)梯度累积
optax.adafactor / lamb / lars / lion / adamw(mu_dtype=bf16)大规模优化器 / 省内存
optax.apply_if_finite(opt, max_consecutive_errors)跳过 inf/NaN 更新
jax.make_mesh((2, 4), ('data', 'model'))创建设备网格(0.12 默认 Explicit 轴)
jax.set_mesh(mesh) / jax.device_put(x, P('data', None))设置当前 mesh / 按 PartitionSpec 放置数组
jax.typeof(x) / jax.debug.visualize_array_sharding(x)查看带分片的类型(f32[8@data,4])/ 可视化分片
jnp.matmul(a, b, out_sharding=P(...))Explicit 模式下指定输出分片(行并行的部分和)
jax.sharding.reshard(x, P())改变分片(如 FSDP 的 all-gather)
jax.shard_map(f, in_specs, out_specs) + lax.psum/all_gather/pmean单设备视角、手写通信
jax.checkpoint(f, policy=..., prevent_cse=..., static_argnums=...)梯度检查点(jax.remat 为别名)
jax.checkpoint_policies.dots_saveable 等选择保存哪些残差
jax.ad_checkpoint.checkpoint_name(x, 'name')给中间值起名,配合 save_only_these_names
jax.ad_checkpoint.print_saved_residuals(f, *args)打印反向会保存的残差
jax.jit(f).lower(*a).compile().memory_analysis()编译期内存分析(temp_size_in_bytes)
jax.jit(f).lower(*a).compile().cost_analysis()['flops']编译期 FLOPs 统计
jnp.dot(a, b, preferred_element_type=jnp.float32)低精度输入、fp32 累加
jnp.finfo(dtype)查看浮点格式的位数、eps、范围
orbax.checkpoint.StandardCheckpointer().save/restore保存 / 恢复训练状态 PyTree

🧭 后续学习路线

方向资源说明
规模化总论How to Scale Your Model(JAX 官方 scaling book)从 roofline、TPU/GPU 架构讲到数据/张量/流水线/专家并行的通信代价,本章所有并行策略的定量版本
分片与并行JAX 文档 Distributed arrays and automatic parallelization、Explicit sharding、Manual parallelism with shard_map、Migrating from pmap0.12 的 jit + NamedSharding / shard_map 权威说明
检查点(再物化)JAX 文档 Gradient checkpointing with jax.checkpoint策略、checkpoint_name、卸载(offload)到主机内存
训练检查点Orbax(orbax.checkpoint)异步保存、多主机分片检查点、CheckpointManager 管理保留策略
数据加载GrainJAX 生态的确定性、可存档(可恢复)的数据管线,支持多主机分片
神经网络库Flax NNX 文档(含 Scale up on multiple devices、Linen to NNX 迁移指南)NNX 的分片注解、nnx.scan 堆叠层、逻辑轴规则
优化器Optax 文档优化器、调度、梯度变换、optax.contrib 中的新方法(Muon、Schedule-Free 等)
参考实现MaxText、EasyLM、Gemma / PaliGemma 的 JAX 实现生产级 LLM 训练代码:逻辑轴分片规则、scan + remat、混合精度、Orbax、Grain 的完整组合
性能分析jax.profiler、TensorBoard / Perfetto、XLA dump找出激活内存与通信瓶颈,再决定检查点策略和并行方式

附录:练习参考答案

先在正文的「✍️ 作答区」自己写,用「🧪 自检」检查;自检通过或者实在卡住了,再来这里对照。 下面的答案按顺序运行,会覆盖你在作答区定义的同名函数。

练习 1

题目见正文「✍️ 动手练习」。

# 参考答案 · 练习 1
r = np.random.default_rng(11)
NB, T_, Dm = 32, 12, 8
tok_x = jnp.asarray(r.normal(size=(NB, T_, Dm)), jnp.float32)
tok_y = jnp.asarray(r.normal(size=(NB, T_)), jnp.float32)
lengths = r.integers(1, T_ + 1, size=NB)
tok_mask = jnp.asarray(np.arange(T_)[None, :] < lengths[:, None], jnp.float32)   # 有效 token 为 1
tok_w = jnp.asarray(r.normal(size=(Dm,)), jnp.float32)

def tok_loss_sum_count(w, x, y, m):
    per_tok = (jnp.tanh(x @ w) - y) ** 2
    return jnp.sum(per_tok * m), jnp.sum(m)          # 返回(损失和,有效 token 数)

def global_mean_loss(w, x, y, m):
    s, n = tok_loss_sum_count(w, x, y, m)
    return s / n

@partial(jax.jit, static_argnames="n_micro")
def accum_token_weighted(w, x, y, m, n_micro):
    split = lambda a: a.reshape(n_micro, -1, *a.shape[1:])
    def body(carry, mb):
        g_sum, n_sum = carry
        # 对"损失和"求导(has_aux 带出 token 数),累积梯度和 token 数
        g, n = jax.grad(tok_loss_sum_count, has_aux=True)(w, *mb)
        return (g_sum + g, n_sum + n), None
    (g_sum, n_sum), _ = lax.scan(body, (jnp.zeros_like(w), 0.0), (split(x), split(y), split(m)))
    return g_sum / n_sum                                                 # 最后除以总 token 数

g_full = jax.grad(global_mean_loss)(tok_w, tok_x, tok_y, tok_mask)
g_acc = accum_token_weighted(tok_w, tok_x, tok_y, tok_mask, n_micro=4)
# 错误做法:每个微批次各自求 token 均值,再简单平均
mb_slices = [slice(i * 8, (i + 1) * 8) for i in range(4)]
g_wrong = sum(jax.grad(global_mean_loss)(tok_w, tok_x[sl], tok_y[sl], tok_mask[sl]) for sl in mb_slices) / 4
print(f"按 token 加权累积 vs 全 batch:{max_diff(g_acc, g_full):.2e}  |  简单平均:{max_diff(g_wrong, g_full):.2e}")
assert max_diff(g_acc, g_full) < 1e-6 and max_diff(g_wrong, g_full) > 1e-4
按 token 加权累积 vs 全 batch:4.47e-08  |  简单平均:1.02e-02

练习 2

题目见正文「✍️ 动手练习」。

# 参考答案 · 练习 2
L2, B2, D2 = 64, 256, 256
r = np.random.default_rng(12)
ex2_p = {"w": jnp.asarray(r.normal(size=(L2, D2, D2)) / np.sqrt(D2), jnp.float32)}
ex2_x = jnp.asarray(r.normal(size=(B2, D2)), jnp.float32)

def one_layer(x, p):
    return x + jnp.tanh(x @ p["w"]), None          # 每层要存 x@w 与 tanh 输出,约 2~3 个 a_in

def grouped_loss(params, x, k=None):
    if k is None:                                   # 不做检查点
        x, _ = lax.scan(one_layer, x, params)
        return jnp.mean(x ** 2)
    grouped = jax.tree.map(lambda a: a.reshape(L2 // k, k, *a.shape[1:]), params)
    @jax.checkpoint                                 # 每组一个检查点:只存组的输入
    def group(x, gp):
        x, _ = lax.scan(one_layer, x, gp)           # 组内正常保存激活
        return x
    x, _ = lax.scan(lambda x, gp: (group(x, gp), None), x, grouped)
    return jnp.mean(x ** 2)

g_ref2 = jax.jit(jax.grad(grouped_loss))(ex2_p, ex2_x)
mem2 = {"无": mem_of(jax.grad(grouped_loss), ex2_p, ex2_x).temp_size_in_bytes / MiB}
for k in [1, 2, 4, 8, 16, 32, 64]:
    f = partial(grouped_loss, k=k)
    mem2[k] = mem_of(jax.grad(f), ex2_p, ex2_x).temp_size_in_bytes / MiB
    assert max_diff(jax.jit(jax.grad(f))(ex2_p, ex2_x), g_ref2) < 1e-4
for k, v in mem2.items():
    print(f"k = {str(k):>2s}: 临时内存 {v:7.2f} MiB")
best_k = min((k for k in mem2 if k != "无"), key=lambda k: mem2[k])
print(f"最省内存的 k = {best_k}(√L = {np.sqrt(L2):.0f};k=L 即整体一个检查点,与不做检查点相当)")
assert 1 < best_k < L2 and mem2[best_k] < mem2[1] and mem2[best_k] < mem2[L2]
assert mem2[L2] >= 0.95 * mem2["无"]
k =  无: 临时内存   33.25 MiB
k =  1: 临时内存   17.25 MiB
k =  2: 临时内存   11.25 MiB
k =  4: 临时内存    9.25 MiB
k =  8: 临时内存   11.25 MiB
k = 16: 临时内存   18.25 MiB
k = 32: 临时内存   33.75 MiB
k = 64: 临时内存   33.25 MiB
最省内存的 k = 4(√L = 8;k=L 即整体一个检查点,与不做检查点相当)

练习 3

题目见正文「✍️ 动手练习」。

# 参考答案 · 练习 3
D3, H3, B3 = 64, 256, 64
r = np.random.default_rng(13)
ex3_p = {"w1": jnp.asarray(r.normal(size=(D3, H3)) * 0.1, jnp.float32),
         "w2": jnp.asarray(r.normal(size=(H3, D3)) * 0.1, jnp.float32)}
ex3_x = jnp.asarray(r.normal(size=(B3, D3)), jnp.float32)
ex3_y = jnp.asarray(r.normal(size=(B3, D3)), jnp.float32)

def single_loss(p, x, y):
    return jnp.mean((jax.nn.relu(x @ p["w1"]) @ p["w2"] - y) ** 2)

def fsdp_local_loss(p_blk, x_blk, y_blk):
    w1 = lax.all_gather(p_blk["w1"], "fsdp", tiled=True)     # (D/8, H) → (D, H)
    w2 = lax.all_gather(p_blk["w2"], "fsdp", tiled=True)     # (H/8, D) → (H, D)
    local = jnp.mean((jax.nn.relu(x_blk @ w1) @ w2 - y_blk) ** 2)
    return lax.pmean(local, "fsdp")                          # 各设备 batch 等大 → 全局均值

with jax.set_mesh(mesh_fsdp):
    spec_p = {"w1": P("fsdp", None), "w2": P("fsdp", None)}
    fsdp_loss = jax.shard_map(fsdp_local_loss, in_specs=(spec_p, P("fsdp", None), P("fsdp", None)), out_specs=P())
    p3 = {k: jax.device_put(v, spec_p[k]) for k, v in ex3_p.items()}
    x3, y3 = jax.device_put(ex3_x, P("fsdp", None)), jax.device_put(ex3_y, P("fsdp", None))
    l3 = jax.jit(fsdp_loss)(p3, x3, y3)
    g3 = jax.jit(jax.grad(fsdp_loss))(p3, x3, y3)
    print("shard_map FSDP 梯度:", jax.typeof(g3["w1"]), jax.typeof(g3["w2"]))
    hlo3 = jax.jit(jax.grad(fsdp_loss)).lower(p3, x3, y3).compile().as_text()
l_ref3 = single_loss(ex3_p, ex3_x, ex3_y)
g_ref3 = jax.grad(single_loss)(ex3_p, ex3_x, ex3_y)
print(f"损失 {float(l3):.6f} vs {float(l_ref3):.6f};梯度最大差 {max_diff(g3, g_ref3):.2e};通信 {collectives(hlo3)}")
assert np.isclose(float(l3), float(l_ref3), rtol=1e-6) and max_diff(g3, g_ref3) < 1e-6
assert g3["w1"].sharding.spec == P("fsdp", None)          # 梯度仍是分片的
shard_map FSDP 梯度: float32[64@fsdp,256] float32[256@fsdp,64]
损失 1.798891 vs 1.798891;梯度最大差 7.45e-09;通信 {'all-reduce': 0, 'all-gather': 2, 'reduce-scatter': 2, 'all-to-all': 0, 'collective-permute': 0}

练习 4

题目见正文「✍️ 动手练习」。

# 参考答案 · 练习 4
lr4, steps4 = 1e-3, 100
w_bf16 = jnp.bfloat16(1.0)
w_master = jnp.float32(1.0)
for _ in range(steps4):
    w_bf16 = (w_bf16 - jnp.bfloat16(lr4) * jnp.bfloat16(1.0)).astype(jnp.bfloat16)   # (a) 参数存 bf16
    w_master = w_master - lr4 * jnp.float32(1.0)                                        # (b) fp32 主权重
print(f"(a) bf16 参数:{float(w_bf16):.4f}   (b) fp32 主权重:{float(w_master):.4f}(转成 bf16 参与计算时为 {float(w_master.astype(jnp.bfloat16)):.4f})")
print(f"bf16 在 1.0 附近的间距 = 2^-7 = {2**-7:.4f},1.0 以下 = 2^-8 = {2**-8:.4f};单步更新 {lr4} < 间距的一半 → 每次都被舍入回原值")
assert float(w_bf16) == 1.0                         # 100 次更新全部丢失
assert np.isclose(float(w_master), 0.9, atol=1e-5)
(a) bf16 参数:1.0000   (b) fp32 主权重:0.9000(转成 bf16 参与计算时为 0.8984)
bf16 在 1.0 附近的间距 = 2^-7 = 0.0078,1.0 以下 = 2^-8 = 0.0039;单步更新 0.001 < 间距的一半 → 每次都被舍入回原值