diff --git a/_quarto-typst-zh.yml b/_quarto-typst-zh.yml
index 18dee75..8935eeb 100644
--- a/_quarto-typst-zh.yml
+++ b/_quarto-typst-zh.yml
@@ -158,3 +158,4 @@ book:
- "zh/ch19-llm-training-engineering/ch19.7-modern-attention-apis.qmd"
- "zh/ch19-llm-training-engineering/ch19.8-triton.qmd"
- "zh/ch19-llm-training-engineering/ch19.9-distributed-training.qmd"
+ - "zh/ch19-llm-training-engineering/ch19.10-large-model-checkpoint.qmd"
diff --git a/zh/README.md b/zh/README.md
index f7147c7..7a1a457 100644
--- a/zh/README.md
+++ b/zh/README.md
@@ -124,3 +124,4 @@
- 19.7 现代 Attention API 与 Hugging Face Kernels
- 19.8 Triton 入门:什么时候需要自己写 Kernel
- 19.9 分布式训练入门:DDP、ZeRO 与 FSDP 的直觉
+- 19.10 大模型 Checkpoint:模型、优化器与分布式状态如何恢复
diff --git a/zh/ch19-llm-training-engineering/ch19.10-large-model-checkpoint.qmd b/zh/ch19-llm-training-engineering/ch19.10-large-model-checkpoint.qmd
new file mode 100644
index 0000000..5a34f78
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/ch19.10-large-model-checkpoint.qmd
@@ -0,0 +1,757 @@
+---
+author: jshn9515
+date: 2026-09-17
+date-modified: 2026-09-17
+open-graph:
+ title: ch19.10-large-model-checkpoint
+ description: "19.10 Large-Model Checkpointing: Restoring DDP and FSDP Training State"
+title: "19.10 大模型 Checkpoint:模型、优化器与分布式状态如何恢复"
+order: 10
+---
+
+Chapter 2 里我们已经介绍过最基本的 checkpoint:保存 `model.state_dict()`、`optimizer.state_dict()`,然后在新的训练进程里重新构造对象并恢复状态。
+
+到了大模型训练,这套基本原则没有改变,真正变复杂的是:
+
+> **训练状态开始分布在多个 rank 上,而且不同并行方式下,状态的布局并不一样。**
+
+对于 DDP,每个 rank 都保存完整模型副本;对于 FSDP,parameters、gradients 和 optimizer states 会被分片。于是 checkpoint 的问题不再只是保存哪些 dictionary,而是:
+
+- 哪些状态是 replicated 的;
+- 哪些状态是 rank-local 的;
+- 哪些状态已经被 shard;
+- 保存时是否需要 gather;
+- 恢复时怎样重新映射到新的 distributed layout。
+
+这一节,我们来重点讨论 DDP 和 FSDP 下 checkpoint 的差异,以及 PyTorch Distributed Checkpoint 如何处理 sharded state。
+
+```{python}
+import os
+import random
+from typing import Any
+
+import dnnlpy
+import numpy as np
+import torch
+import torch.accelerator as accl
+import torch.distributed as dist
+import torch.distributed.checkpoint as dcp
+import torch.distributed.checkpoint.state_dict as state
+import torch.distributed.fsdp as fsdp
+import torch.nn as nn
+import torch.optim as optim
+import torch.optim.lr_scheduler as lr
+from torch.distributed.checkpoint.stateful import Stateful
+from torch.nn.parallel import DistributedDataParallel
+
+rng = np.random.default_rng(42)
+
+print('PyTorch version:', torch.__version__)
+```
+
+```{python}
+device = dnnlpy.get_default_device()
+print('Using device:', device)
+```
+
+## 19.10.1 回顾:单卡训练的 Checkpoint
+
+一个比较完整的训练 checkpoint,至少可以分成几类状态。
+
+第一类是**模型状态**:
+
+```python
+model.state_dict()
+```
+
+它通常包含 parameters 和 registered buffers。
+
+第二类是**优化器状态**:
+
+```python
+optimizer.state_dict()
+```
+
+对于 AdamW,这里面会包含每个参数对应的 moving averages,以及 parameter groups 中的 learning rate、weight decay 等信息。
+
+第三类是**训练控制状态**,例如:
+
+- Global step;
+- Current epoch;
+- Tokens seen;
+- Learning rate scheduler;
+- Gradient scaler。
+
+第四类是**随机状态和数据状态**。如果训练里存在 dropout、随机采样或 shuffle,那么重新启动时 RNG 和数据位置也会影响接下来看到什么数据、产生什么随机 mask。
+
+先从单卡情况开始。假设有一个简单模型:
+
+```{python}
+model = nn.Sequential(
+ nn.Linear(16, 64),
+ nn.GELU(),
+ nn.Linear(64, 4),
+)
+optimizer = optim.AdamW(model.parameters(), lr=3e-4)
+lr_scheduler = lr.CosineAnnealingLR(optimizer, T_max=1000)
+```
+
+如果使用 FP16 mixed precision,还可能有 `GradScaler`。BF16 训练通常不需要 loss scaling,所以这里把 scaler 当成可选状态。
+
+可以把 checkpoint 组织成一个普通 dictionary:
+
+```{python}
+def save_checkpoint(
+ path: str | os.PathLike[str],
+ *,
+ model: nn.Module,
+ optimizer: optim.Optimizer,
+ lr_scheduler: lr.LRScheduler,
+ scaler: torch.GradScaler | None = None,
+ global_step: int | None = None,
+ current_epoch: int | None = None,
+ num_tokens_seen: int | None = None,
+):
+ checkpoint = {
+ 'model': model.state_dict(),
+ 'optimizer': optimizer.state_dict(),
+ 'lr_scheduler': lr_scheduler.state_dict(),
+ 'scaler': scaler.state_dict() if scaler is not None else None,
+ 'global_step': global_step,
+ 'current_epoch': current_epoch,
+ 'num_tokens_seen': num_tokens_seen,
+ 'rng_states': {
+ 'python': random.getstate(),
+ 'numpy': rng.bit_generator.state,
+ 'torch.cpu': torch.get_rng_state(),
+ },
+ }
+
+ if accl.is_available():
+ checkpoint['rng_states']['torch.accl'] = accl.random.get_rng_state()
+
+ torch.save(checkpoint, path)
+```
+
+然后保存:
+
+```{python}
+save_checkpoint(
+ 'checkpoint.pt',
+ model=model,
+ optimizer=optimizer,
+ lr_scheduler=lr_scheduler,
+ global_step=int(5e4),
+ current_epoch=3,
+ num_tokens_seen=int(8e9),
+)
+```
+
+恢复训练时,第一步通常不是读取文件,而是先重新构造**相同结构**的 model、optimizer 和 scheduler:
+
+```python
+model = nn.Transformer(...)
+optimizer = optim.AdamW(model.parameters(), lr=...)
+lr_scheduler = lr.CosineAnnealingLR(optimizer, T_max=...)
+```
+
+然后再加载状态。
+
+一个典型流程可以写成:
+
+```{python}
+def load_checkpoint(
+ path: str | os.PathLike[str],
+ *,
+ model: nn.Module,
+ optimizer: optim.Optimizer,
+ lr_scheduler: lr.LRScheduler,
+ scaler: torch.GradScaler | None = None,
+):
+ checkpoint = torch.load(path, map_location='cpu', weights_only=True)
+ model.load_state_dict(checkpoint['model'])
+
+ # Scheduler should already be constructed before optimizer state is loaded.
+ optimizer.load_state_dict(checkpoint['optimizer'])
+ lr_scheduler.load_state_dict(checkpoint['lr_scheduler'])
+
+ if scaler is not None and 'scaler' in checkpoint:
+ scaler.load_state_dict(checkpoint['scaler'])
+
+ rng_states = checkpoint['rng_states']
+ random.setstate(rng_states['python'])
+ rng.bit_generator.state = rng_states['numpy']
+ torch.set_rng_state(rng_states['torch.cpu'])
+
+ # TODO: torch.accelerator.random.set_rng_state()
+ device = accl.current_accelerator(check_available=True)
+ if device is not None and rng_states['torch.accl'] is not None:
+ backend = torch.get_device_module(device)
+ backend.set_rng_state(rng_states['torch.accl'])
+
+ return {
+ 'global_step': checkpoint['global_step'],
+ 'current_epoch': checkpoint['current_epoch'],
+ 'num_tokens_seen': checkpoint['num_tokens_seen'],
+ }
+```
+
+加载一下之前保存的 checkpoint:
+
+```{python}
+model = nn.Sequential(
+ nn.Linear(16, 64),
+ nn.GELU(),
+ nn.Linear(64, 4),
+).to(device)
+optimizer = optim.AdamW(model.parameters(), lr=3e-4)
+lr_scheduler = lr.CosineAnnealingLR(optimizer, T_max=1000)
+
+checkpoint = load_checkpoint(
+ 'checkpoint.pt',
+ model=model,
+ optimizer=optimizer,
+ lr_scheduler=lr_scheduler,
+)
+print(checkpoint)
+```
+
+这里有几个细节值得单独说明。
+
+首先,我们使用 `map_location='cpu'`,让 checkpoint 先加载到 CPU。对于很大的 checkpoint,这可以避免 `torch.load()` 直接按照原保存位置把大量 tensor 恢复到 GPU,引起额外的显存峰值。模型本身应该由训练代码决定最终放到哪一个 device。
+
+其次,scheduler 应该先构造出来,再加载 optimizer state。PyTorch 的 optimizer 文档明确提醒:如果在创建 scheduler 之前就加载 optimizer state,scheduler 初始化可能再次改写 optimizer 中恢复出来的 learning rate。因此,推荐顺序是先依次构造 optimizer、scheduler,再依次加载它们的 state。
+
+最后,我们需要保存每个 device 的 RNG state。对于单机训练,通常只需要保存 CPU 和当前 GPU 的 RNG state;对于多机训练,每个 rank 可能有多个 GPU,因此需要保存每个 GPU 的 RNG state。PyTorch 2.14 引入了 `get_rng_state_all()` 和 `set_rng_state_all()`,可以一次性获取和设置所有 accelerator 的 RNG state。
+
+当然,上面的讨论主要针对单机单卡场景。进入分布式训练后,checkpoint 的设计会明显复杂一些,其中一个关键问题是区分两类状态:
+
+- Replicated state:每个 rank 都有完整副本;
+- Sharded state:每个 rank 只持有一部分。
+
+DDP 主要属于前一种,而 FSDP 主要属于后一种。先理解 replicated 与 sharded 这一区别,后面再看分布式 checkpoint 的保存、加载和 resharding 就会清楚很多。
+
+## 19.10.2 DDP Checkpoint:模型是 Replicated 的
+
+DDP 的结构相对简单。假设 world size 为 4,每个 rank 都有一份完整模型:
+
+```text
+Rank 0: full model
+Rank 1: full model
+Rank 2: full model
+Rank 3: full model
+```
+
+Backward 时 DDP 会同步梯度,因此一次 optimizer step 之后,各 rank 上的 model parameters 仍然保持一致。如果 optimizer 配置和更新过程也一致,那么 optimizer state 也通常是 replicated 的。
+
+因此,从 model $(M)$ 和 optimizer state $(O)$ 的角度看:
+
+```text
+Rank 0: M + O
+Rank 1: M + O
+Rank 2: M + O
+Rank 3: M + O
+```
+
+这里的 $M$ 和 $O$ 是重复副本。
+
+所以最常见的保存方式是只让 rank 0 写 checkpoint:
+
+```{python}
+def save_ddp_checkpoint(
+ path: str | os.PathLike[str],
+ *,
+ model: DistributedDataParallel,
+ optimizer: optim.Optimizer,
+ lr_scheduler: lr.LRScheduler,
+ scaler: torch.GradScaler | None = None,
+ global_step: int | None = None,
+ current_epoch: int | None = None,
+ num_tokens_seen: int | None = None,
+):
+ rank = dist.get_rank()
+ world_size = dist.get_world_size()
+
+ local_rng_state = {
+ 'python': random.getstate(),
+ 'numpy': rng.bit_generator.state,
+ 'torch.cpu': torch.get_rng_state(),
+ 'torch.accl': accl.random.get_rng_state() if accl.is_available() else None,
+ }
+ global_rng_states = [None] * world_size if rank == 0 else None
+ dist.gather_object(local_rng_state, global_rng_states, dst=0)
+
+ if rank == 0:
+ checkpoint = {
+ 'model': model.module.state_dict(),
+ 'optimizer': optimizer.state_dict(),
+ 'lr_scheduler': lr_scheduler.state_dict(),
+ 'scaler': scaler.state_dict() if scaler is not None else None,
+ 'global_step': global_step,
+ 'current_epoch': current_epoch,
+ 'num_tokens_seen': num_tokens_seen,
+ 'rng_states': global_rng_states,
+ }
+ torch.save(checkpoint, path)
+
+ dist.barrier() # Sync all ranks before continuing
+```
+
+这里使用 `model.module.state_dict()` 是因为 `DistributedDataParallel` 只是包在原始模型外面的一层 wrapper。如果直接用 `model.state_dict()`,参数名会带上 `module.` 前缀。此时直接加载到未经过 DDP 包装的原始模型中,参数名就无法直接对应。保存 `model.module.state_dict()` 可以让 checkpoint 更接近原始未包装模型的结构,也更容易在非 DDP 环境中加载。
+
+有一个点需要注意:
+
+> **Model state replicated 不代表所有 distributed state 都 replicated。**
+
+例如:
+
+- 每个 rank 的 RNG state 可能不同;
+- `DistributedSampler` 的局部数据位置可能不同;
+- 每个 rank 可能维护自己的 dataloader worker state;
+- 某些自定义 metric / cache 也可能是 rank-local。
+
+因此,如果目标只是恢复相同模型参数继续训练,rank 0 保存通常已经够用;如果目标是尽可能精确地恢复整个 distributed job,还需要额外处理这些 rank-local state。
+
+## 19.10.3 DDP Resume:恢复以后为什么还能保持一致
+
+DDP 的恢复流程通常是:
+
+{height=550px}
+
+一种常见方式是在每个 rank 都读取同一个 checkpoint:
+
+```{python}
+def load_ddp_checkpoint_v1(
+ path: str | os.PathLike[str],
+ *,
+ model: DistributedDataParallel,
+ optimizer: optim.Optimizer,
+ lr_scheduler: lr.LRScheduler,
+ scaler: torch.GradScaler | None = None,
+):
+ rank = dist.get_rank()
+ checkpoint = torch.load(path, map_location='cpu', weights_only=True)
+
+ model.module.load_state_dict(checkpoint['model'])
+ optimizer.load_state_dict(checkpoint['optimizer'])
+ lr_scheduler.load_state_dict(checkpoint['lr_scheduler'])
+
+ if scaler is not None:
+ scaler.load_state_dict(checkpoint['scaler'])
+
+ local_rng_state = checkpoint['rng_states'][rank]
+
+ random.setstate(local_rng_state['python'])
+ rng.bit_generator.state = local_rng_state['numpy']
+ torch.set_rng_state(local_rng_state['torch.cpu'])
+
+ # TODO: torch.accelerator.random.set_rng_state()
+ device = accl.current_accelerator()
+ if device is not None and local_rng_state['torch.accl'] is not None:
+ backend = torch.get_device_module(device)
+ backend.set_rng_state(local_rng_state['torch.accl'])
+
+ return {
+ 'global_step': checkpoint['global_step'],
+ 'current_epoch': checkpoint['current_epoch'],
+ 'num_tokens_seen': checkpoint['num_tokens_seen'],
+ }
+```
+
+由于所有 rank 加载相同 model 和 optimizer state,恢复后的起点仍然一致。后续每次 backward 又会继续进行 gradient synchronization,所以 replicas 会继续保持一致。
+
+另一种思路是只让 rank 0 读取 checkpoint,再 broadcast 到其他 rank:
+
+```{python}
+def load_ddp_checkpoint_v2(
+ path: str | os.PathLike[str],
+ *,
+ model: DistributedDataParallel,
+ optimizer: optim.Optimizer,
+ lr_scheduler: lr.LRScheduler,
+ scaler: torch.GradScaler | None = None,
+):
+ rank = dist.get_rank()
+
+ if rank == 0:
+ checkpoint = torch.load(path, map_location='cpu', weights_only=True)
+ else:
+ checkpoint = None
+
+ objects = [checkpoint]
+ dist.broadcast_object_list(objects, src=0)
+
+ checkpoint = objects[0]
+
+ model.module.load_state_dict(checkpoint['model'])
+ optimizer.load_state_dict(checkpoint['optimizer'])
+ lr_scheduler.load_state_dict(checkpoint['lr_scheduler'])
+
+ if scaler is not None:
+ scaler.load_state_dict(checkpoint['scaler'])
+
+ local_rng_state = checkpoint['rng_states'][rank]
+
+ random.setstate(local_rng_state['python'])
+ rng.bit_generator.state = local_rng_state['numpy']
+ torch.set_rng_state(local_rng_state['torch.cpu'])
+
+ # TODO: torch.accelerator.random.set_rng_state()
+ device = accl.current_accelerator()
+ if device is not None and local_rng_state['torch.accl'] is not None:
+ backend = torch.get_device_module(device)
+ backend.set_rng_state(local_rng_state['torch.accl'])
+
+ return {
+ 'global_step': checkpoint['global_step'],
+ 'current_epoch': checkpoint['current_epoch'],
+ 'num_tokens_seen': checkpoint['num_tokens_seen'],
+ }
+```
+
+对于普通 DDP checkpoint,让所有 rank 直接从共享存储读取同一个文件通常已经足够简单。是否改为由 rank 0 读取后再 broadcast,主要取决于底层存储系统的性能以及 checkpoint 的大小。需要注意的是,`broadcast_object_list()` 会对 Python 对象进行序列化和反序列化。当 checkpoint 较大时,这部分额外开销可能抵消减少磁盘读取所带来的收益。
+
+另外一个值得注意的是 **sampler**。使用 `DistributedSampler` 时,恢复以后需要保证 epoch / seed / offset 和训练进度匹配。否则模型虽然恢复到了正确的 step,但数据却重新从 0 开始,严格意义上已经不是原来的训练轨迹。
+
+所以 DDP checkpoint 的核心难点通常不是 model shard,而是:
+
+> **Replicated model state 很简单,rank-local runtime state 才是容易遗漏的地方。**
+
+## 19.10.4 为什么 FSDP Checkpoint 完全不同
+
+FSDP2 的目标之一,就是让每个 rank 不再长期保存完整模型状态。
+
+假设一个参数被切成 4 个 shard:
+
+```text
+Rank 0: P0
+Rank 1: P1
+Rank 2: P2
+Rank 3: P3
+```
+
+Gradients 和 optimizer states 也可能保持相同的 sharded layout:
+
+```text
+Rank 0: P0 | G0 | O0
+Rank 1: P1 | G1 | O1
+Rank 2: P2 | G2 | O2
+Rank 3: P3 | G3 | O3
+```
+
+这时候就不能简单地说:
+
+```python
+if rank == 0:
+ torch.save(...)
+```
+
+因为 rank 0 根本没有 P1, P2, P3 对应的完整状态。
+
+最直接的解决方案是 checkpoint 前把所有参数 gather 成完整 tensor,然后再保存。这种方法的优点是得到一个普通 full state dict,加载和模型发布都很方便。但缺点也非常明显:
+
+> **保存 checkpoint 时重新构造完整参数和 optimizer state,会产生很大的内存峰值,而且 I/O 也集中在少数 rank。**
+
+所以 FSDP checkpoint 一般存在两种思路:
+
+1. Full State Dict:Gather 成完整状态再保存;
+2. Sharded State Dict:保持分片,多个 rank 协同保存。
+
+前者更方便与普通 PyTorch 模型互操作,后者更适合真正的大规模训练恢复。
+
+对于 **full state dict**,保存时:
+
+{height=330px}
+
+最后得到的 checkpoint 更接近普通 `model.state_dict()`。
+
+优点是易于离线处理,易于转换成推理 checkpoint,不依赖保存时的 sharding layout,可以直接被非分布式模型读取;缺点是 gather 本身需要额外 memory,而且 optimizer state 可能比 model parameters 更大,因此并不总是适合大规模训练。
+
+至于 **sharded state dict**,在保存时,每个 rank 直接保存自己持有的 shard:
+
+```text
+Rank 0 → P0 / O0
+Rank 1 → P1 / O1
+Rank 2 → P2 / O2
+Rank 3 → P3 / O3
+```
+
+这样我们就不需要在某个 rank 上重新构造整个 model state,memory 和 I/O 都更容易扩展。但恢复时就多了一个问题:
+
+> **如果新的 world size 或 sharding layout 和保存时不同,旧 shard 要怎样重新映射到新 shard?**
+
+这正是 PyTorch DCP 重点解决的问题。
+
+## 19.10.5 Distributed Checkpoint:不再把 Shard 当普通文件
+
+PyTorch 提供 `torch.distributed.checkpoint` API,通常简称 **DCP**。
+
+DCP 的核心是让 checkpoint 系统理解:
+
+> **这些 tensor 是一个逻辑 distributed state 的不同 shard。**
+
+具体流程如下:
+
+{height=430px}
+
+其中:
+
+- `get_state_dict()`:把 model / optimizer state 转换成统一的 distributed state-dict 表示;
+- Planner:决定某个 rank 应该保存或读取哪些 tensor chunk;
+- StorageWriter / StorageReader:真正负责 storage I/O;
+- Metadata:描述 checkpoint 中有哪些 tensor、shape、chunk 和逻辑 key;
+- `set_state_dict()`:把加载后的 state 写回当前 model / optimizer。
+
+这里最重要的是把**逻辑 state**和**物理 shard 文件**分开。
+
+应用层不应该依赖:
+
+```text
+rank0.pt
+rank1.pt
+rank2.pt
+```
+
+这样的具体文件布局,而应该依赖:
+
+```text
+model.layers.7.mlp.up_proj.weight
+```
+
+这样的逻辑参数名称。
+
+## 19.10.7 Stateful:统一 Model 和 Optimizer 的分布式表示
+
+FSDP 下直接处理 `state_dict` 会比较麻烦,因为会受到不同布局的影响。PyTorch 为此提供:
+
+```python
+from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict
+```
+
+`get_state_dict()` 函数会把 model 和 optimizer state 转换成更统一的表示,并使用模型原始结构中的**规范 FQNs (Fully Qualified Names)** 来标识参数,例如:
+
+```text
+transformer.blocks.7.attn.q_proj.weight
+```
+
+这很重要,因为 optimizer state 不应该永远依赖某次 Python 进程中的 parameter ID,也不应该和某一种 wrapper 后的参数名称强绑定。
+
+一个常见写法是把 model 和 optimizer 封装成 `Stateful`:
+
+```{python}
+class TrainingState(Stateful):
+ def __init__(self, model: nn.Module, optimizer: optim.Optimizer):
+ self.model = model
+ self.optimizer = optimizer
+
+ def state_dict(self) -> dict[str, Any]:
+ model_state, optim_state = state.get_state_dict(self.model, self.optimizer)
+ return {'model': model_state, 'optimizer': optim_state}
+
+ def load_state_dict(self, state_dict: dict[str, Any]) -> None:
+ state.set_state_dict(
+ self.model,
+ self.optimizer,
+ model_state_dict=state_dict['model'],
+ optim_state_dict=state_dict['optimizer'],
+ )
+```
+
+这里的 `get_state_dict()` 和 `set_state_dict()` 是 PyTorch 为分布式训练提供的一组统一状态接口。
+
+`get_state_dict()` 用来从当前的 model 和 optimizer 中导出可保存的状态,并统一处理 DDP、FSDP 等并行方式下的参数命名、参数身份以及 optimizer state 与模型参数之间的对应关系;`set_state_dict()` 则执行相反的过程,把加载得到的状态重新写回当前的 model 和 optimizer。
+
+需要注意的是:
+
+> **`get_state_dict()` 并没有把所有 shard gather 成完整 tensor。**
+
+对于 sharded model,导出的 state 仍然可以保持分布式表示。它主要统一的是 state-dict 的语义和参数映射关系,而不是强制取消 sharding。这样 DCP 就可以在不同分布式训练配置下,用统一的方式保存和恢复训练状态。
+
+保存时,所有相关 rank 一起调用:
+
+```python
+state_dict = {'train': TrainingState(model, optimizer)}
+dcp.save(state_dict, checkpoint_id='checkpoints/step_50000')
+```
+
+DCP 再根据 distributed state 生成各 rank 的 save plan,并协调写入 shard 和 metadata。
+
+## 19.10.8 DCP Save:每个 Rank 到底做了什么
+
+把 `dcp.save()` 再拆细一点,大致可以理解成:
+
+{height=580px}
+
+`Planner` 并不负责真正写文件。它的职责是确定这个 rank 需要写哪些 logical tensor,以及这些 tensor 的哪些 chunk。真正 I/O 交给 `StorageWriter`。
+
+如果我们把上面的 `Planner` 和 `StorageWriter` 显式写出来,就是:
+
+```python
+state_dict = {'train': TrainingState(model, optimizer)}
+planner = dcp.DefaultSavePlanner()
+writer = dcp.FileSystemWriter('checkpoints/step_50000')
+
+dcp.save(state_dict, planner=planner, storage_writer=writer)
+```
+
+这和手动写:
+
+```python
+torch.save(local_state, f'rank_{rank}.pt')
+```
+
+有本质区别。后者只是把各 rank 的局部 Python object 分开保存,checkpoint 系统并不知道这些 shard 在逻辑上如何组成完整 tensor;DCP 则保存了额外 metadata,使 load planner 能够理解 shard 的对应关系。
+
+因此,可以把 DCP checkpoint 理解为三部分信息:实际保存的参数数据 shards、描述完整逻辑 tensor 的 metadata,以及这些数据在保存时如何分布到各个 rank 上的 sharding metadata。正因为 checkpoint 中保留了这些逻辑和分片信息,加载时 DCP 才能根据当前的 world size 和并行布局重新映射数据,而不必严格复现保存 checkpoint 时的原始 sharding 方式。这也是后续 resharding 能够实现的基础。
+
+## 19.10.9 DCP Load-Time Resharding:从旧布局恢复到新布局
+
+假设某个参数:
+
+$$
+W \in \mathbb{R}^{8\times 4}
+$$
+
+保存时 world size 为 4:
+
+```text
+Rank 0: W[0:2]
+Rank 1: W[2:4]
+Rank 2: W[4:6]
+Rank 3: W[6:8]
+```
+
+但恢复时 world size 变成 2:
+
+```text
+Rank 0: W[0:4]
+Rank 1: W[4:8]
+```
+
+最笨的方式就是 gather 所有 shard 到某个 rank,然后再重新 split。但这样会制造一个完整 tensor。
+
+PyTorch DCP 的做法是先构造**当前** FSDP model,让系统知道现在每个 rank 的目标 shard,然后结合保存的 checkpoint metadata 计算读取计划:
+
+1. 读取 checkpoint 中保存的各个分片信息;
+2. 根据当前进程组和模型结构确定目标分片布局;
+3. Load Planner 对比旧分片与当前目标布局,规划数据该如何重组;
+4. 确定当前 rank 的目标分片具体需要读取哪些旧 checkpoint 数据块;
+5. 从存储中读取这些数据块,并直接填充到当前 rank 的目标分片中。
+
+因此 `dcp.load()` 是 **in-place load**。必须先按照当前 world size 和 parallelism 创建好 model 和 optimizer:
+
+```python
+model = nn.Transformer()
+for layer in model.encoder.layers:
+ fsdp.fully_shard(layer)
+for layer in model.decoder.layers:
+ fsdp.fully_shard(layer)
+fsdp.fully_shard(model)
+optimizer = optim.AdamW(model.parameters(), lr=3e-4)
+
+state_dict = {'train': TrainingState(model, optimizer)}
+dcp.load(state_dict, checkpoint_id='checkpoints/step_50000')
+```
+
+对上面的例子来说,新 rank 0 可以直接读取旧 checkpoint 中对应的 chunk,再把它们放进当前 rank 0 的 `W[0:4]`。不需要先让某个 rank 持有完整 $W$。这就是 **load-time resharding**。
+
+和 `dcp.save()` 一样,我们把 `dcp.load()` 拆开:
+
+{height=620px}
+
+把 `Planner` 和 `StorageReader` 显式写出来:
+
+```python
+model = nn.Transformer()
+for layer in model.encoder.layers:
+ fsdp.fully_shard(layer)
+for layer in model.decoder.layers:
+ fsdp.fully_shard(layer)
+fsdp.fully_shard(model)
+optimizer = optim.AdamW(model.parameters(), lr=3e-4)
+
+state_dict = {'train': TrainingState(model, optimizer)}
+planner = dcp.DefaultLoadPlanner()
+reader = dcp.FileSystemReader('checkpoints/step_50000')
+
+dcp.load(state_dict, planner=planner, storage_reader=reader)
+```
+
+虽然 DCP 可以自动处理不同 world size,但当 world size 变化以后,还要单独检查训练语义。例如:
+
+- Global batch size 是否变化;
+- Gradient accumulation 是否需要调整;
+- Sampler 如何重新分片;
+- Learning rate schedule 是按 step 还是 token count 推进。
+
+所以,DCP 能解决的是:
+
+> **旧 checkpoint 的 distributed tensor 如何映射到新的 distributed layout。**
+
+它不会自动决定新的训练策略。
+
+## 19.10.10 本章小结
+
+最后把 DDP 和 FSDP 放在一起看。
+
+DDP 的特点是:
+
+1. Model state:复制;
+2. Optimizer state:通常复制;
+3. Checkpoint:通常只需要一份完整副本。
+
+因此最简单的方案就是 rank 0 保存完整 checkpoint,其他 rank 不保存,然后额外处理 rank-local RNG、sampler 和 data position。
+
+FSDP 的特点则是:
+
+1. Model state:分片;
+2. Optimizer state:分片;
+3. Checkpoint:每个 rank 只保存自己持有的 shard。
+
+Rank 0 自己并没有完整的 model state。这时可以选择 gather 成 full state dict,但大规模训练中更常见的是保留 sharded state,并通过 DCP 并行保存和恢复。
+
+可以把两者总结成:
+
+::: {.list-table}
+表 19.10.10 DDP 和 FSDP checkpoint 对比
+
+- - 类型
+ - DDP
+ - FSDP
+
+- - Parameters
+ - Replicated
+ - Sharded
+
+- - Optimizer State
+ - Replicated
+ - Sharded
+
+- - 单个 Rank 是否有完整状态
+ - 通常有
+ - 没有
+
+- - Rank 0 单独保存
+ - 通常可行
+ - Sharded state 下不完整
+
+- - 是否需要 Reshard
+ - 通常不需要
+ - 可能需要
+
+- - DCP 的价值
+ - 可用但不是必须
+ - 非常重要
+:::
+
+因此,分布式 checkpoint 最核心的问题其实和分布式训练本身是同一个问题:
+
+> **每个 rank 到底拥有什么状态?**
+
+如果每个 rank 都有训练状态的完整副本,checkpoint 很接近普通单机训练;如果训练状态已经被 shard,那么 checkpoint 也必须理解这些 shard,而不能简单退回到 rank 0 写一个文件。这也是为什么 DDP 和 FSDP 虽然都属于 data parallel,但 checkpoint 方式会有明显区别:前者主要是在处理 **replicated state**,后者则是在处理 **distributed state**。
+
+到这里,我们把大模型训练工程中的关键资源与工具串了起来:先用显存账理解参数、激活与优化器状态,再通过 profiling 找到真正的瓶颈;用 mixed precision、gradient accumulation 和 activation checkpointing 调整单卡上的精度、batch 与显存;用现代 attention API 和 Triton 改善算子效率;最后用 DDP、ZeRO、FSDP 与 distributed checkpointing 管理多卡环境中的计算、通信和分布式状态。
+
+这些技术解决的问题并不相同,也不存在一种始终最优的组合。实际训练中,更重要的是先判断当前受限的是显存、计算、通信还是存储,再选择对应的优化手段:
+
+> **先找到真正的瓶颈,再决定应该优化什么。**
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.mmd b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.mmd
new file mode 100644
index 0000000..3f25235
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.mmd
@@ -0,0 +1,40 @@
+---
+config:
+ startOnLoad: false
+ theme: base
+ htmlLabels: false
+ fontFamily: Arial, Microsoft YaHei, Noto Serif CJK SC, sans-serif
+ themeVariables:
+ fontSize: 24px
+ primaryColor: "#e9f2fc"
+ primaryBorderColor: "#296bb7"
+ primaryTextColor: "#333333"
+ lineColor: "#0b0b0b"
+
+ flowchart:
+ wrappingWidth: 400
+
+ themeCSS: |
+ .node text,
+ .nodeLabel,
+ .label text {
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ .node rect,
+ .flowchart-link {
+ stroke-width: 1px;
+ }
+---
+
+flowchart TD
+ A["Each rank"] --> B["TrainingState.load_state_dict()"]
+ B --> C["Prepare current distributed state"]
+ C --> D["Create local load plan"]
+ D --> E["Collect / coordinate plans"]
+ E --> F["Create global load plan"]
+ F --> G["Read assigned checkpoint chunks"]
+ G --> H["Reshard / map into current layout"]
+ H --> I["set_state_dict()"]
+ I --> J["Restore model and optimizer state"]
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.svg b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.svg
new file mode 100644
index 0000000..497fb16
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-load.svg
@@ -0,0 +1,364 @@
+
\ No newline at end of file
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.mmd b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.mmd
new file mode 100644
index 0000000..9979252
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.mmd
@@ -0,0 +1,39 @@
+---
+config:
+ startOnLoad: false
+ theme: base
+ htmlLabels: false
+ fontFamily: Arial, Microsoft YaHei, Noto Serif CJK SC, sans-serif
+ themeVariables:
+ fontSize: 24px
+ primaryColor: "#e9f2fc"
+ primaryBorderColor: "#296bb7"
+ primaryTextColor: "#333333"
+ lineColor: "#0b0b0b"
+
+ flowchart:
+ wrappingWidth: 400
+
+ themeCSS: |
+ .node text,
+ .nodeLabel,
+ .label text {
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ .node rect,
+ .flowchart-link {
+ stroke-width: 1px;
+ }
+---
+
+flowchart TD
+ A["Each rank"] --> B["TrainingState.state_dict()"]
+ B --> C["get_state_dict()"]
+ C --> D["Local distributed state"]
+ D --> E["Create local save plan"]
+ E --> F["Collect / coordinate plans"]
+ F --> G["Create global plan"]
+ G --> H["Each rank writes its assigned chunks"]
+ H --> I["Commit metadata"]
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.svg b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.svg
new file mode 100644
index 0000000..ee6737f
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp-save.svg
@@ -0,0 +1,322 @@
+
\ No newline at end of file
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.mmd b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.mmd
new file mode 100644
index 0000000..b3cf5cd
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.mmd
@@ -0,0 +1,42 @@
+---
+config:
+ startOnLoad: false
+ theme: base
+ htmlLabels: false
+ fontFamily: Arial, Microsoft YaHei, Noto Serif CJK SC, sans-serif
+ themeVariables:
+ fontSize: 20px
+ primaryColor: "#e9f2fc"
+ primaryBorderColor: "#296bb7"
+ primaryTextColor: "#333333"
+ lineColor: "#0b0b0b"
+
+ flowchart:
+ wrappingWidth: 300
+
+ themeCSS: |
+ .node text,
+ .nodeLabel,
+ .label text {
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ .node rect,
+ .flowchart-link {
+ stroke-width: 1px;
+ }
+---
+
+flowchart TD
+ A["FSDP Model / Optimizer"] --> B["get_state_dict()"]
+ B --> C["Distributed State Dict"]
+ C --> D["Save Planner"]
+ D --> E["StorageWriter"]
+ E --> F["Shards + Metadata"]
+
+ G["Shards + Metadata"] --> H["StorageReader"]
+ H --> I["Load Planner"]
+ I --> J["Current Sharding Layout"]
+ J --> K["set_state_dict()"]
+ K --> L["FSDP Model / Optimizer"]
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.svg b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.svg
new file mode 100644
index 0000000..a8342e0
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-dcp.svg
@@ -0,0 +1,387 @@
+
\ No newline at end of file
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.mmd b/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.mmd
new file mode 100644
index 0000000..eb73d74
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.mmd
@@ -0,0 +1,40 @@
+---
+config:
+ startOnLoad: false
+ theme: base
+ htmlLabels: false
+ fontFamily: Arial, Microsoft YaHei, Noto Serif CJK SC, sans-serif
+ themeVariables:
+ fontSize: 16px
+ primaryColor: "#e9f2fc"
+ primaryBorderColor: "#296bb7"
+ primaryTextColor: "#333333"
+ lineColor: "#0b0b0b"
+
+ flowchart:
+ wrappingWidth: 300
+
+ themeCSS: |
+ .node text,
+ .nodeLabel,
+ .label text {
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ .node rect,
+ .flowchart-link {
+ stroke-width: 1px;
+ }
+---
+
+flowchart TD
+ A[所有 Rank 启动]
+ B[初始化 Process Group]
+ C[重新构造相同模型]
+ D[加载同一个 Model Checkpoint]
+ E[DDP Wrap]
+ F[恢复 Optimizer]
+ G[继续训练]
+
+ A --> B --> C --> D --> E --> F --> G
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.svg b/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.svg
new file mode 100644
index 0000000..cb914d2
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-ddp-resume.svg
@@ -0,0 +1,252 @@
+
\ No newline at end of file
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.mmd b/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.mmd
new file mode 100644
index 0000000..5d3e684
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.mmd
@@ -0,0 +1,34 @@
+---
+config:
+ startOnLoad: false
+ theme: base
+ htmlLabels: false
+ fontFamily: Arial, Microsoft YaHei, Noto Serif CJK SC, sans-serif
+ themeVariables:
+ fontSize: 16px
+ primaryColor: "#e9f2fc"
+ primaryBorderColor: "#296bb7"
+ primaryTextColor: "#333333"
+ lineColor: "#0b0b0b"
+
+ themeCSS: |
+ .node text,
+ .nodeLabel,
+ .label text {
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ .node rect,
+ .flowchart-link {
+ stroke-width: 1px;
+ }
+---
+
+flowchart TD
+ A[P0 / P1 / P2 / P3]
+ B[All-Gather]
+ C[Full Parameter]
+ D[Rank 0 Save]
+
+ A --> B --> C --> D
diff --git a/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.svg b/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.svg
new file mode 100644
index 0000000..807d816
--- /dev/null
+++ b/zh/ch19-llm-training-engineering/figures/ch19.10-full-gather-ckpt.svg
@@ -0,0 +1,176 @@
+
\ No newline at end of file