DCP 分布式检查点使用指南
HyperParallel 提供 DCP(Distributed Checkpoint)能力:每个 rank 只保存自己持有的分片,配合一份描述"谁存了哪一块"的全局元数据;加载时按目标切分策略重新计算需要读哪些片段,因此并行策略变化后无需离线转换。此外支持异步落盘、副本张量读一次 + 广播、Plan 缓存,以及与 Hugging Face safetensors 格式的离线互转。
核心概念
| 概念 | 说明 |
|---|---|
| Planner(规划器) | 决定"写什么 / 读什么":把 state_dict 翻译成带全局坐标的 WriteItem / ReadItem 列表 |
| Storage(存储后端) | 决定"怎么写 / 怎么读":把规划落到具体文件格式(默认 safetensors) |
| Metadata(元数据) | fqn → 全局 shape + chunk 列表,是跨切分策略读取的唯一依据;.metadata 由 coordinator 最后写出 |
| Chunk | 一个张量分片在全局张量中的 offsets + sizes,即一个 N 维半开区间盒 |
| 重切分(reshard) | "我要的区域" ∩ "磁盘上存过的区域",交集即需要读取的片段 |
模块一览
DCP 模块位于 hyper_parallel/core/distributed_checkpoint/:
| 文件 | 说明 |
|---|---|
api.py |
对外主接口:save / async_save / load |
planner.py |
Planner 抽象接口与 SavePlan / LoadPlan / WriteItem / ReadItem 数据结构 |
standard_planner.py |
默认实现 StandardSavePlanner / StandardLoadPlanner |
storage.py |
存储后端抽象接口 StorageWriter / StorageReader |
filesystem_storage.py |
文件系统实现 FileSystemWriter / FileSystemReader(safetensors) |
metadata.py |
Metadata / ChunkStorageMetadata / ChunkInfo / BroadcastInfo 等元数据结构 |
async_persist.py |
异步保存:staging(DataCopier)与子进程持久化 |
ragged.py |
RaggedShard(非均匀切分)的几何适配 |
offline_transform.py |
Hugging Face safetensors ⇄ DCP 离线互转 |
utils.py |
去冗余、同分片组推导、区间求交、分阶段计时等公共能力 |
broadcast.py |
副本分片的广播:通信组的建立与销毁、发送与批量合并 |
checkpoint_io.py |
单个 checkpoint 文件的读写(safetensors / pickle) |
快速上手
from hyper_parallel.core.distributed_checkpoint import save, load
# 保存:每个 rank 写自己的分片,rank0 写 .metadata
save(state_dict, checkpoint_id="/ckpt/step_1000")
# 加载:原地写回 state_dict,返回 None
load(state_dict, checkpoint_id="/ckpt/step_1000")
注意:
load是原地修改传入的state_dict,不是返回一个新字典。传入的 state_dict 必须预先构造好目标形状的张量(DCP 只会往已存在的 key 里填数据),否则会静默读不到内容——优化器状态尤其需要先做一次"预热"让optimizer.state非空。
接口详解
save
save(
state_dict: dict[str, Any],
*,
checkpoint_id: Optional[Union[Path, str]] = None,
storage_writer: Optional[StorageWriter] = None,
planner: Optional[SavePlanner] = None,
no_dist: bool = False,
use_collectives: bool = True,
) -> Metadata
| 参数 | 说明 |
|---|---|
checkpoint_id |
checkpoint 目录。未传 storage_writer 时必填,会据此构造 FileSystemWriter |
storage_writer |
自定义存储后端;与 checkpoint_id 同时传入时以 checkpoint_id 初始化 |
planner |
自定义规划器,默认 StandardSavePlanner() |
no_dist |
True 表示单进程保存(强制关闭 use_collectives) |
use_collectives |
True(默认):跨 rank 交换 plan,写出全局 .metadata;False:各 rank 互不通信,各写各的 {rank}.metadata |
返回全局 Metadata;函数末尾会做一次 barrier,返回时全部 rank 均已落盘。
async_save
async_save(
state_dict, *, checkpoint_id=None, storage_writer=None, planner=None,
no_dist=False, use_collectives=True, use_gloo=False, callback=None,
) -> AsyncSaveResponse
staging 是同步的,持久化是异步的:函数内先在训练进程中把张量拷贝到 Host 内存,然后把规划、通信、写盘全部交给一个后台子进程。因此:
async_save一返回,原state_dict就可以被训练继续改写;- 但磁盘上还没有
.metadata,要确认落盘必须等AsyncSaveResponse:
resp = async_save(state_dict, checkpoint_id="/ckpt/step_1000", callback=lambda: print("landed"))
# ... 训练继续 ...
metadata = resp.get_result(timeout=600) # 等价于 resp.persist_completion.result(timeout=600)
子进程与 join 线程都刻意不是 daemon:训练脚本退出时会等在途的 checkpoint 落完,而不是丢掉它。
load
load(
state_dict, *, checkpoint_id=None, storage_reader=None, planner=None,
no_dist=False, use_collectives=True,
broadcast_replicated_tensors=True, broadcast_groups=None,
broadcast_batch_bytes=6 * 1024 * 1024,
) -> None
| 参数 | 说明 |
|---|---|
use_collectives |
默认 True(与 save 一致):跨 rank 交换 plan,据此推导副本组。设 False 则各 rank 独立读自己需要的数据,不做跨 rank 协同 |
broadcast_replicated_tensors |
默认 True:加载内容完全相同的若干 rank 只由其中一个读盘,其余通过组内广播拿数据,读盘量按副本数成比例下降;读盘卡按已分配字节数均衡挑选,不固定在最小 rank |
broadcast_groups |
预建的广播通信组 {rank_tuple: group};不传则按需创建。必须与 broadcast_replicated_tensors=True 同时使用,否则抛 ValueError |
broadcast_batch_bytes |
小于该阈值的分片攒成一次广播发送,大于等于的各发各的。默认 6 MiB,设 0 关闭。仅在开启广播时生效。各 rank 必须传相同的值——攒批位置是各卡各自算出来的,不一致会死锁 |
use_collectives=True(默认)要求默认通信组里每个 rank 都调用load——plan 交换跑在该组上,缺席的 rank 会让其余 rank 卡在一个永远凑不齐的 gather 上。没有数据要加载的 rank 也要参与,传空 state_dict 即可。只想让部分 rank 加载时请显式传use_collectives=False。save一直是同样的要求。广播只在
use_collectives=True时生效:副本组是从各 rank allgather 上来的 load plan 中「读请求完全相同」推导出来的,不做这次 allgather 就无从判断谁和谁读的是同一份数据。
读取元数据时若找不到 .metadata,会自动回退到 {rank}.metadata(即 use_collectives=False 保存出来的 checkpoint),并同步把 use_collectives 关掉。
落盘产物
/ckpt/step_1000/
├── .metadata # use_collectives=True:coordinator 写出的全局元数据
├── 0.metadata # use_collectives=False:每 rank 各写一份 rank 局部元数据
├── _rank0_.safetensors # 张量分片
├── _rank0_.bytes # 非张量数据(标量、pickle 对象等)
├── _rank1_.safetensors
└── ...
.metadata 由 finalize_checkpoint 最后写出,可作为"这个目录写完整了"的判据——写到一半被打断的目录没有这个文件。
异步保存的三种协同模式
子进程无法直接复用训练侧的 HCCL/NCCL 通信域(fork 之后其 C++ 全局状态已失效),因此提供三档协同方式:
| 组合 | 行为 | 适用场景 |
|---|---|---|
use_collectives=True, use_gloo=True |
子进程重建一个独立的 CPU gloo 通信域交换 plan 与写结果 | 有可用端口,希望协同开销最低 |
use_collectives=True, use_gloo=False |
通过存储介质交换:各 rank 写 LOCAL_PLAN_{rank}.pkl / STORAGE_DATA_{rank}.pkl,带写完标记,多线程轮询并行读 |
不便再开通信域时的默认选择 |
use_collectives=False |
完全不跨 rank,各写各的 {rank}.metadata |
单机调试、或上层自行保证一致性 |
使用 use_gloo=True 需要环境变量 MASTER_ADDR / MASTER_PORT,否则抛 AssertionError;使用存储介质协同则必须提供 checkpoint_id(需要一个目录当"信箱")。
# gloo 协同
async_save(sd, checkpoint_id=path, use_collectives=True, use_gloo=True)
# 存储介质协同
async_save(sd, checkpoint_id=path, use_collectives=True, use_gloo=False)
# 不协同
async_save(sd, checkpoint_id=path, use_collectives=False)
加载时重切分(跨并行策略续训)
不需要任何额外接口:save 端忠实记录每个分片的全局坐标,load 端按目标 layout 计算自己需要的区域,与元数据中的 chunk 求交后只读重叠部分。
# 用 TP=4 保存
save(state_dict_tp4, checkpoint_id="/ckpt/step_1000")
# 换成 TP=2 直接加载,state_dict 按新切分构造好即可
load(state_dict_tp2, checkpoint_id="/ckpt/step_1000")
已覆盖的典型倒换:TP4 → TP2、TP4 → DP2×TP2、DP2×TP2 → TP2,以及 fully_shard 分片之间的重切分。
副本张量读一次 + 广播
在有复制维度的场景(如 DP 复制、TP 上的 Replicate() 参数),多张卡持有同一份数据。打开广播后,每份数据在组内只有一张卡真正读盘,其余 rank 通过一次组内 broadcast 拿到,把磁盘读放大从 N× 降到 1×:
import torch.distributed as dist
load(state_dict, checkpoint_id=path) # 两者默认都是开的
# 多次加载时可预建通信组,省掉加载路径上的建组开销
groups = {ranks: dist.new_group(ranks=list(ranks)) for ranks in my_group_ranks if rank in ranks}
load(state_dict, checkpoint_id=path, use_collectives=True,
broadcast_replicated_tensors=True, broadcast_groups=groups)
谁读哪一份由 StandardLoadPlanner.build_global_plan 决定,各 rank 用同一份 allgather 结果跑同一个算法,因此算出的结果一致,不需要额外通信来对齐:按字节数从大到小依次分配,每份交给组内当前已分配字节数最少的 rank。分配结果写在 plan 的 ReadItem.source 上,FileSystemReader 照着执行。
分组粒度是分片,不是整个张量。 一个分片就是 to_local() 拿到的那块本地缓冲区,由 dest_index(fqn + offset + index)唯一标识;持有同一个 dest_index 的 rank 组成一个组,一次 broadcast 发整块缓冲区(dist.broadcast 要求连续内存,而 narrow 出来的子区域通常带 stride)。同一个张量的不同分片各广播各的,互不牵扯——0/2 卡持有分片 1、1/3 卡持有分片 2,就是 0→2 和 1→3 两次独立广播。BYTE_IO(存的是 pickle 出来的 Python 对象而非张量存储)永不参与。
读取与广播是流水化的。 execute_read 按 (fqn, offset, index) 的全序遍历分片,轮到自己读的读完立刻发起异步广播,不等它完成就继续下一片,因此一次广播总是和下一片的读取重叠;最多同时有 8 个广播在飞。各卡跑的是同一份 all_plans、走的是同一个全序,所以入队顺序天然一致——同一个通信组上的集合通信按入队顺序在流上串行执行,这既是数据不错乱的保证,也是不死锁的前提。没有别的卡等着的分片放在最后读:早读只会挡住广播,放最后还能填满尾部广播仍在飞的时间。
小分片会合批。 一次 broadcast 的固定开销约 145 µs,与它搬多少数据几乎无关(64 KiB 和 1 MiB 都是这个数),要到几 MiB 才跑满 33 GiB/s。也就是说小于交叉点(实测约 4.6 MiB)的分片,时间全花在「发起广播」而不是「搬数据」上——而 checkpoint 里恰恰有大量小张量:各种 norm、bias、scalar。所以 broadcast_batch_bytes 以下的分片会先拷进一块连续 staging、合成一次广播发出,落地后再散回各自 buffer;以上的仍然各发各的。
只有通信组、源卡、dtype 三者都相同的分片才能同批——一次广播只面向一个组、一个源、一块连续 buffer。各卡遍历分片的顺序相同,所以攒批和发批的位置天然一致,这和单发时不需要额外通信对齐是同一个道理。
实测数据(4 卡、512 × 64 KiB):单发 81.5 ms,合成一次 1.1 ms。散回用 torch._foreach_copy_ 融合成一次 kernel,1.4 ms(逐片 copy 要 11.8 ms)。
代价是显存:一个批要留到它的广播落地为止,所以最多占
broadcast_batch_bytes× 在飞上限(8)。默认配置下约 48 MiB。另外torch.distributed._coalescing_manager在 torch_npu 上实测无效(81.0 vs 81.5 ms),所以只能走 staging 这条路。
读盘本身还会在一个后台线程上提前读一两片(仅 torch 后端)。读的那一半只碰 checkpoint 文件,不碰 planner 也不碰设备,所以能和主线程的 H2D 拷贝、广播下发并行;批次按交出去的顺序回来,发送顺序不受任何影响。收益来自把 safe_open 的开销挪出主线程,外加建 view 时顺带触发的内核预读(见下)——一张卡要在多于 _TORCH_FILES_KEPT(8)个 checkpoint 文件之间来回读时(常见于重切分加载),实测快 14~17%;文件局部性好、句柄缓存不失效时基本持平。
读序和广播序保持一致是有意的,别拆。 试过让读线程按文件序走、主线程仍按全局序消费(中间用完成表代替 FIFO),想借此修掉跨文件跳的开销 —— 30B 实测反而慢 38%(15.3 s → 21.1 s,三个变体交错跑两轮,轮间偏差 0.2 s 内)。两个原因:一是 checkpoint 文件本就按 key 字典序首尾相接排布,和全局序
(fqn, offset, index)完全吻合(实测 9218/9218、零空洞),单文件内的读已经是顺序访问,没有可修的;二是拆成两个序之后,读线程按文件序碰页、主线程按全局序copy_,两个访问序在同一个文件上交错,打乱内核的顺序检测,预读失效。另外光是把 FIFO 换成完成表、顺序一个字不动,也要付 7.6% —— 18867 次字典插入加查找不是白来的。推论:跨文件跳在当前架构下没有便宜解法。要让读盘按文件走,
copy_就得跟着按文件走,而copy_的顺序就是广播顺序,动它会把各个读者串行化(总时间从max(各读者)退化成sum(各读者))。
读盘的时间花在哪:mmap 与页缓存
torch 后端读 checkpoint 走的是 mmap,不是 read()——safe_open 内部是 MmapOptions::map_copy_read_only,整条读路径上没有一次读系统调用。数据进入进程靠的是访问映射区时的缺页中断,所以「什么时候读盘」等价于「什么时候第一次碰那块内存」。
_fetch_torch_tensor_file 里的 tensor_file.get_slice(key)[tensor_slices] 拿到的是直接落在 mmap 上的零拷贝 view:它的 data_ptr() 落在 /proc/self/maps 里那块文件映射区内,同一个张量取两次地址相同。所以取数据这一步几乎不花时间,大张量的字节是后面 _apply_fetched 里的 copy_ 读进来的(实测 155.6 MiB 的 embedding,事先 posix_fadvise(DONTNEED) 清掉页缓存):
| 语句 | 耗时 | 磁盘读 |
|---|---|---|
get_slice(key) |
0.03 ms | 0 |
[tensor_slices] |
0.74 ms | 0 |
target_tensor.copy_(tensor) |
127.60 ms | 147.7 MiB(179 次 major fault) |
同样的 copy_ 再来一次 |
25.29 ms | 0 |
最后两行差 5 倍,差的就是页缓存:内核把读过的文件页留在空闲内存里(free 的 buff/cache 列,可随时回收),再访问同样的字节直接从内存拿,磁盘计数器纹丝不动。
[tensor_slices]那一行是 0,不代表建 view 不缺页——embedding 排在文件最前面,它的首页在safe_open解析 header 时就被预读带进了页缓存,所以那次是 minor fault,磁盘计数器不动。建 view 总会碰一页,只是这一页未必要走盘。
但这张表只是大张量的情形。 建 view 本身会碰一下映射区(一页),触发内核预读(本机 read_ahead_kb=4096,一次最多带进 4 MiB)。张量小于预读窗口时,建 view 就把它整个带进来了,copy_ 一个扇区都不用读;远大于窗口时,建 view 只带进开头一段,剩下的由 copy_ 边读边缺页。从文件中段取 500 个张量(共 381 MiB,MoE 专家权重每个约 0.76 MiB,避开头部被 header 预读覆盖的影响):
| 路线 | get_tensor(k) / get_slice(k) |
[slice] / [spec] |
copy_ |
|---|---|---|---|
get_tensor(k)[slice] |
170.54 ms,387.5 MiB | 3.41 ms,0 | 0 |
get_slice(k)[spec] |
0.27 ms,0 | 181.22 ms,387.5 MiB | ~0 |
两条路线读盘量完全一致,只是落在不同语句上:get_tensor(k) 自己就把 view 建好了(碰映射区在这一步),get_slice(k) 只取元数据、要等 [spec] 才建。而 [slice] 作用在已经建好的 torch 张量上,只改 sizes / strides / storage_offset,永远不会缺页。
所以后台预读线程确实在预读,只是靠的是内核。 它不拷贝任何字节,只是建 view 时碰一下映射区,剩下的是内核预读顺手做的。checkpoint 里张量普遍小于预读窗口时(MoE 尤其如此),这等于把整批数据提前拉进了页缓存,主线程的 copy_ 只走内存:2486 个张量共 2.0 GiB 的场景,建 view 阶段就拉进 1903 MiB,copy_ 只需再读 155 MiB。反过来,单个远大于窗口的张量(比如 embedding),copy_ 仍要自己边读边缺页。
这份预读依赖两个条件:预读窗口够大、且张量在文件里排布紧密。要做成确定性的,读线程里得显式
madvise(MADV_WILLNEED),或者真把字节拷出来(后者要多付一份主机内存)。
get_slice(k)[spec] 与 get_tensor(k)[spec] 实测等价(safetensors 0.8.0):两者都返回 mmap 上的 view,地址相同,建 view 都是零点几毫秒、0 磁盘,后续 copy_ 的耗时和读盘量也一致。差别只在 get_slice 可以先 get_shape() 而完全不碰数据——校验 key 和形状时用得上。
但怎么切很要紧。 dim 0 的前缀切(行并行)只读要的那部分:半个 embedding 是 74.98 MiB,整个是 148.88 MiB。dim 1 的切(列并行)拿到的是 strided view,copy_ 要跨过每一页,整个张量都会被读上来(148.88 MiB,和不切一样)。重切分加载时列切方向省不到 I/O。
这一点也直接决定了广播能省多少。同一台机器上的 dp 副本读的是同一个文件的同一段字节,关掉广播时第二个 rank 的读全部命中页缓存,磁盘并不会读第二遍:8 卡实测无论广播开关,/proc/diskstats 都是 56.9 GiB,完全相同。所以单机布局下广播省不到磁盘 I/O,端到端只有 1 秒量级的出入(读播流水化之前甚至是负收益)。真正让广播值钱的是页缓存兜不住的场景——跨节点 dp、共享存储 / NFS,或者 checkpoint 大到装不进内存。
想自己数字节数只能用
/proc/diskstats,别拿majflt乘 4 KiB 反推:一次阻塞式缺页会顺带把后面一整个预读窗口拉进页缓存,上面 1.866 GiB 只报了 951 次 major fault。另外统计设备时正则要锚定^nvme[0-9]+n[0-9]+$,否则整盘和分区各算一遍,数字直接翻倍。还有posix_fadvise(DONTNEED)会跳过仍被映射在页表里的页——测冷读必须先丢掉safe_open的句柄,最稳妥是一个配置起一个进程。
去冗余与 Plan 缓存
StandardSavePlanner 的两个默认优化,均只在 use_collectives=True 时生效:
StandardSavePlanner(
enable_plan_caching=True, # 缓存最终 plan 与 metadata
remove_redundancy=True, # 跨 rank 去重
save_to_minimum_rank=False, # 去重后归属策略
)
- 去冗余:同一分片被多个 rank 持有时只写一份。归属默认按已规划字节数最小的 plan 分配(负载均衡,避免副本全堆到 rank0);置
save_to_minimum_rank=True则统一归给最小 rank。 - Plan 缓存:以 state_dict 的 key 集合为缓存键,缓存在类级别。模型结构不变时,第二次及以后的
save会跳过build_local_plan/all_gather/build_global_plan整个规划阶段——这一步是唯一随 world_size 线性增长的通信。异步保存路径下,子进程算出的缓存会随结果回传给父进程复用。
两种自动降级(无需干预,但需知情):
use_collectives=False时,去冗余与 Plan 缓存都会自动关闭(没有全局视图);- state_dict 中含
ragged_shard的 DTensor 时,Plan 缓存自动关闭(几何结构可能逐步变化)。
与 FSDP / HSDP 配合
DCP 与 fully_shard 天然配合:分片后的参数已经是 DTensor,带着完整的 layout 信息。
from hyper_parallel import fully_shard, init_device_mesh
from hyper_parallel.core.distributed_checkpoint import save
mesh = init_device_mesh("npu", (dp_size,), mesh_dim_names=("dp",))
model = fully_shard(model, mesh=mesh)
save(model.state_dict(), checkpoint_id="/ckpt/step_1000")
离线格式转换(Hugging Face ⇄ DCP)
offline_transform 提供单进程的离线互转,用于"HF 权重初始化训练"和"训练产物导出成 HF 格式"两个方向:
from hyper_parallel.core.distributed_checkpoint.offline_transform import (
convert_full_checkpoint_to_dcp, # 完整权重文件/HF 目录 → DCP
full_state_dict_to_dcp_format, # 内存中的完整 state_dict → DCP
dcp_to_full_state_dict, # DCP 分片 → 合并后的完整 state_dict
parse_checkpoint_from_huggingface, # 读 HF safetensors(单文件或分片 + index)
save_state_dict_as_huggingface_format, # 写成 HF 风格 safetensors(可分片)
)
# HF → DCP
convert_full_checkpoint_to_dcp("/hf/Qwen3-30B", "/ckpt/dcp_init", src_platform="huggingface")
# DCP → HF
full_sd = dcp_to_full_state_dict("/ckpt/step_1000")
save_state_dict_as_huggingface_format("/export/hf", full_sd, max_shard_size="5GB")
src_platform 可取 "huggingface"(HF 目录)或 "torch"(PyTorch 完整 checkpoint 文件,pickle 或 .safetensors)。
自定义扩展
- 自定义存储后端:继承
StorageWriter/StorageReader,实现execute_write/execute_read/load_metadata等方法,通过storage_writer=/storage_reader=传入。 - 自定义规划器:继承
SavePlanner/LoadPlanner,或从StandardSavePlanner/StandardLoadPlanner派生后覆写。 - 自定义对象的异步 staging:
async_save通过DataCopier按类型分派拷贝方法,未注册的类型会走通用深拷贝并打 warning。自定义类型请显式注册:
from hyper_parallel.core.distributed_checkpoint.async_persist import DataCopier
@DataCopier.register(MyState)
def _copy_my_state(obj):
return MyState(...) # 返回 Host 内存中的副本
注意
DTensor是torch.Tensor的子类,分派按"精确类型 → 最派生的注册基类"顺序解析。自定义类型如果也存在继承关系,请确认命中的是预期的 handler。
调试与性能
打开分阶段耗时日志(定位存盘瓶颈的第一手段):
export HP_LOG_CONFIG=DCP:INFO
# 输出形如:
# [INFO] [HP-DCP]: [rank=0] >>> func build_local_plan cost 0.0003 seconds
# [INFO] [HP-DCP]: [rank=0] >>> func execute_write cost 0.0061 seconds
覆盖 save / _save_impl / build_local_plan / all_gather_object / build_global_plan / execute_write / execute_read / build_staged_state_dict 等关键阶段,每个 rank 单独计时。命中 Plan 缓存时会打印 Hit final plan and metadata cache.。
性能建议:
- 用
async_save让落盘与训练计算重叠,训练进程只付 staging 的开销; - 保持
enable_plan_caching=True,让规划阶段的通信只在第一次存盘时付出; - 有复制维度时保持
broadcast_replicated_tensors开启(默认),用通信换 IO; - 变更并行策略优先走 load 时重切分;确需离线合并再切分时才用
offline_transform。
注意事项
load只填已存在的 key:目标 state_dict 必须先构造出正确形状的张量;优化器状态需要先预热(让optimizer.state非空)再加载。- 相同 FQN 会被跨 rank 去重:权重、优化器状态天然一致,没问题;但 RNG state 这类每卡不同的数据不能以相同 key 内嵌保存,否则只会留下一份。这类状态请每 rank 单独存,或给 key 加 rank 后缀。
async_save返回不代表落盘完成:staging 已完成(原 state_dict 可继续改写),但需等get_result()才确认磁盘写完。save与load的use_collectives默认值相反(True/False),跨函数复制参数时容易搞错。no_dist=True会强制关闭use_collectives。
流水并行(PP)下的广播加载
PP 打破了「各卡 state_dict 键相同」这个前提:一个 stage 只持有分给它的层,embed 只在首
stage、head 只在末 stage,两个 stage 几乎没有共同的键。这套流程能扛住,靠的是三点:
build_local_plan是纯本地的,all_gather_object本来就允许各卡内容不同;build_global_plan按dest_index(fqn+offset+index)聚合副本组,所以 stage0 的 参数天然只组成 stage0 内部的组,stage1 的只组成 stage1 内部的组,两组不相交;- 顺序不变量只需要在组内成立,而一个组的成员必然在同一个 stage、遍历同一个子序列, 所以跨 stage 不需要对齐,一个 stage 可以远远跑在另一个前面。
通信组:建出来、用一次、销毁
一个 stage 内部的副本组(stage 的 4 张卡、或某个 tp 列的 2 张卡)通常不在任何已有通信域里,
得现建。建组走的是 dist.new_group(ranks=...)——建的就是给它的那几个 rank,
建完就还给调用方。
这里不用 create_group(core/dtensor/_utils.py),它做的事超出所需:把 rank 列表当模板展开成对全世界
的一个划分,并把展开出的每个组都塞进进程级缓存。两个后果对 PP 都不合适——缓存会让这些只用
一次的通信域活到任务结束(一个 communicator 一直占着显存),而模板展开会拒绝 PP 会产生的
rank 列表,比如绑定在两个不相邻 stage 上的参数([0,1,6,7] → ValueError: Template must have consistent intra-group step)。走 new_group 这两个问题都不存在。
组在 load() 里用 broadcast_groups_for_load(...) 圈住:读完就按 rank 元组顺序销毁(这个顺序
各卡算出来一致,而销毁只由组内成员发起,它们是一起走到读取末尾的)。调用方通过
broadcast_groups= 预建传进来的组不属于这次加载,原样留着不动。
建组是全局集合调用。 建组的进入条件是
broadcast_replicated_tensors and use_collectives and world_size > 1——三个全卡一致的量,
不是「我有没有要广播的分片」。一个全部参数都被切满、一个副本分片都没有的 stage,仍然必须
进来陪着建组,否则另一个 stage 会一直等它。这些 rank 从 new_group 拿到的是后端的非成员标记
(torch 上是 -100),既不会被存进组字典,也不参与销毁。
相关 ST
# 无 NPU 也可验证(CPU + gloo 后端)
export HYPER_PARALLEL_TEST_DEVICE_TYPE=cpu
python -m torch.distributed.run --nproc-per-node=4 -m pytest -s \
tests/torch/distributed_checkpoint/dcp_async_save.py::test_dcp_async_save_twice_reuses_the_plan_cache
| 用例文件 | 覆盖内容 |
|---|---|
tests/torch/distributed_checkpoint/dcp_save_and_load.py |
DTensor + 普通 Tensor + 标量混合存取、跨切分加载 |
tests/torch/distributed_checkpoint/dcp_async_save.py |
三种异步协同模式、Plan 缓存复用 |
tests/torch/distributed_checkpoint/dcp_broadcast_load.py |
副本张量广播加载(预建 / 懒建通信组) |
tests/torch/distributed_checkpoint/dcp_pipeline_load.py |
读播流水:超出在飞上限、通信组交错、非张量状态、重复加载 |
tests/torch/distributed_checkpoint/dcp_pp_stage_load.py |
PP 下的广播加载(8 卡 pp=2 × dp=2 × tp=2):各 stage 参数不同、某个 stage 无副本分片、两 stage 在分片序中交错、跨 stage 绑定参数 |
tests/torch/distributed_checkpoint/dcp_resharding_api.py |
多种 mesh 组合下的重切分读取 |
tests/torch/distributed_checkpoint/_test_dcp_tp_dp.py |
fully_shard + 优化器状态 + flatten_state_dict |
tests/torch/distributed_checkpoint/dcp_plan_cache_minimal_api.py |
Plan 缓存命中与 model / optimizer 缓存隔离 |
用例文件本身不带 test_ 前缀(或以 _ 开头),由同名的 test_*.py 包装器通过 parallel_run 拉起多进程执行;直接用 torch.distributed.run 跑上表中的文件即可。