flowchart TD
subgraph Dataset["全量训练数据集 (N 条记录)"]
Raw["Samples [0, 1, 2, ..., N-1]"]
end
subgraph Sampler["DistributedSampler 确定性分发"]
Seed["随机种子 + Epoch 序号"] --> ShuffleIndices["确定性伪随机置换序列"]
ShuffleIndices --> Shard0["Rank 0 数据分片: [idx0, idx4, ...]"]
ShuffleIndices --> Shard1["Rank 1 数据分片: [idx1, idx5, ...]"]
end
Dataset --> Sampler
第 5 课:数据分片、Shuffle 与精确 Resume
参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。
本课目标与完成标准
本课产出:实现无重复的全局样本位置分配,并说明 epoch、seed、world-size 变化下的恢复语义。
通过要求:唯一代码填空题通过给定检查;Q1~Q3 都给出因果链;能指出一个正确性边界和一个性能/运营取舍;总分至少 8/10。
与既有六条主线的边界
train/ 把 batch 当数字;本课关注样本如何在 rank/worker 间唯一分配、何时重复/丢失,以及 checkpoint 如何恢复数据位置。
前置:Python、PyTorch、train 第 1~5 课、CUDA 基础。本课只补 AI Infra 缺口,不重复已经完成的 kernel 或并行算法推导。
核心概念与运行机制
1. 它是什么,解决什么问题
在万亿 Token 级别的大模型预训练与持续多轮微调中,数据加载流水线(Data Pipeline)并非简单的单机 Python 迭代器,而是直接关系到模型收敛确定性与集群万小时级容灾能力的核心分布式子系统。
分布式数据流水线必须解决三大核心痛点:
- 全局无重复正交分片(Zero-Redundancy Sharding):如图 5-1 所示,
图 5-1:分布式数据分片采样与可复现 Shuffle 逻辑映射模型
如何保证数千张卡在同一时刻获取的数据样本互不重叠,且全体样本的并集恰好覆盖全量数据集;
确定性伪随机可复现(Deterministic Reproducibility):在引入分布式 Shuffle 洗牌打乱数据分布的同时,保证多次运行或断点恢复时数据生成的序列严格一致;
断点精确续训(Exact Resume & Fast-Forward):如图 5-2 所示,
flowchart LR
Crash["训练异常中断 / 节点抢占故障"] --> LoadCkpt["加载恢复 Checkpoint 元数据"]
LoadCkpt --> RestoreState["恢复 Epoch 计数器、随机种子与已消费 Batch 偏移量"]
RestoreState --> FastForward["Sampler 快速跳过当前分片已完成样本 (O(1) 索引计算)"]
FastForward --> ResumePipe["无损无缝对齐数据加载管道,避免重复消费或样本遗漏"]
图 5-2:断点续训 (Resume) 精确状态恢复与跳步定位工作流
当集群遭遇硬件损坏或抢占中断时,如何不从头遍历 TB/PB 级数据集,以 的时间复杂度精准跳过已训练样本,实现无缝对齐恢复,杜绝样本重复吞吐与梯度漂移。
2. 它如何工作
分布式数据分片与精确恢复依托于严格的分层索引映射与状态机持久化设计(如图 5-1 与 图 5-2 所示):
- 确定性两级分片映射(图 5-1):
- 全局置换生成:
DistributedSampler依据固定公式 初始化确定性伪随机数生成器,对大小为 的数据集索引生成全局置换数组 ; - 对齐填充约束(Padding Invariant):若样本总数 不能被集群总卡数 整除,采样器必须对尾部补全至 的整数倍,以确保所有 Rank 在当前 Epoch 拥有完全相等的 Batch 步数,从根本上防止因某张卡少一个 Batch 导致 DDP 全局通信挂起死锁;
- 跨卡交织切分(Interleaved Slicing):对于 Rank ,其分配到的样本子集严格遵循交错步长采样: 全局样本位置 与本地样本索引 的双向映射为:
- 全局置换生成:
- 多进程 Worker 二级分发:
- 在每个 Rank 内部,PyTorch
DataLoader通常启动 个工作进程(num_workers)。 - 本地样本集进一步在多进程间切分,由各 Worker 进程异步执行数据解码、分词(Tokenization)与张量组装,通过共享内存环形队列传递给主进程。
- 在每个 Rank 内部,PyTorch
- 断点精准快速跳步恢复(图 5-2):
- 在保存 Checkpoint 时,引擎不仅持久化模型权重与优化器状态,还原子记录数据进度游标(Data State Cursor):
current_epoch、已完成迭代步数global_step、以及数据加载器的伪随机种子; - 发生故障重启时,采样器重新构建当前 Epoch 的确定性全局置换 ;
- 随后计算已消费的样本偏移量:;
- 采样器直接从索引偏移处切片切分(Fast-Forward 切片切分),零 I/O 开销瞬间跳过数百 GB 的已学数据,实现无缝精准恢复。
- 在保存 Checkpoint 时,引擎不仅持久化模型权重与优化器状态,还原子记录数据进度游标(Data State Cursor):
3. 正确性条件与常见误区
DistributedSampler.set_epoch(epoch)必调契约:- 致命陷阱:在每轮 Epoch 训练循环开头忘记显式调用
sampler.set_epoch(epoch); - 底层物理实况:采样器内部的随机数种子将永久固定在初始值
seed + 0。尽管单 Epoch 内各卡数据无重复,但在接下来的数十个 Epoch 中,模型将在完全相同的样本先后顺序和完全相同的局部 Batch 组合下被重复喂养。模型泛化能力严重劣化,过拟合风险剧增,且该 Bug 在单 Epoch 冒烟测试中绝对无法被察觉。
- 致命陷阱:在每轮 Epoch 训练循环开头忘记显式调用
IterableDataset与多 Worker 的“八倍数据重复灾难”:- 常见误区:直接将流式数据加载器
IterableDataset配合num_workers=8启动训练,误以为框架会自动分片; - 实际后果:在 PyTorch 架构中,
IterableDataset默认会在每个 Worker 进程中完整克隆一份实例。若用户未在数据集的__iter__中通过torch.utils.data.get_worker_info()主动按worker_id切分数据流,8 个 Worker 进程将各自从头开始读取完整数据流,导致该卡上的所有样本被完全重复计算 8 次!
- 常见误区:直接将流式数据加载器
- 弹性伸缩(Elastic Scaling)下的局部 Offset 失效:
- 若将集群规模从 64 卡动态缩容至 32 卡,原先各卡保存的私有局部步数(
local_step)完全失去拓扑意义。必须依赖全局不变样本坐标(Global Sample Index / Token Offset)重新解构分片拓扑。
- 若将集群规模从 64 卡动态缩容至 32 卡,原先各卡保存的私有局部步数(
4. 性能与工程取舍
- Map 索引随机加载 vs. Iterable 流式预取:
- Map-style Dataset(索引式):天然支持 确定性断点跳转与灵活多卡切分;但要求底层存储支持高效随机点查(Random Seek),在千亿级文本的小文件随机读取中会打崩网络文件系统(NFS / Lustre)的元数据服务器;
- IterableDataset(流式打包):将数据预先分块打包为大尺寸压缩分卷(如 WebDataset / Tar / Parquet),吞吐逼近物理网卡线速;但断点续训状态维护极其复杂,需要显式持久化文件分卷编号与内部分卷 Byte Offset。
- 严格零重复保证 vs. 异步预取缓冲丢弃:
- 为了维持 GPU 算力饱和,DataLoader 会在后台预取数个 Batch。当触发 Checkpoint 保存时,预取队列中已出队但未实际前向计算的张量,在恢复时必须被严谨对齐;
- 生产中通常采用“按最后提交的已反向 Step”作为物理截断点,在 Resume 时清空后台预取缓冲并从该 Step 精确重启。
具体演示
设总卡数 ,全局生成了一条确定性置换序列:
- 全局位置 的归属判定:
- 宿主卡计算:(由 Rank 2 消费);
- 该卡内的局部序列索引:(该样本是 Rank 2 在该 Epoch 分配到的第 3 个样本,从 0 计);
- 故障断点恢复推演:
- 训练进行到第 2 个 Step 中途崩溃,每个 Step 包含全局 Batch 为 4 个样本(每卡 1 个);
- Checkpoint 读取到最后提交的已完成 Step 为 1(即前 4 个全局样本 已完全训练);
- 重启时,各卡采样器直接从本地索引 1(即全局位置 4 开始)提取数据,零冗余无缝衔接。
实践任务:唯一代码填空题
补齐全局位置到 rank 的确定性分配,并返回本 rank 的局部序号。
只能修改 TODO/______ 位置,不得删除断言或放宽通过条件。
Code
def owner_and_local_index(global_position, world_size):
if global_position < 0 or world_size <= 0:
raise ValueError("invalid shard position")
# TODO:round-robin 分片,owner=g%P,local=g//P。
return ______
assert owner_and_local_index(10, 4) == (2, 2)
assert owner_and_local_index(0, 8) == (0, 0)检查方法
运行本单元格,所有断言必须通过;再补一个边界输入并解释预期。
Q1
忘记 DistributedSampler.set_epoch(epoch) 会怎样?
你的答案:
Q2
IterableDataset + num_workers=8 为什么可能把数据重复 8 次?
你的答案:
Q3
弹性恢复 world size 改变时,怎样减少重复/丢失?
你的答案:
评分规则
- 代码 4 分:正常输入 2 分、边界输入 1 分、解释 1 分;
- Q1~Q3 各 2 分;
- 一票否决:混淆测量与推测、忽略租户/请求隔离、用平均值掩盖尾延迟、删除失败路径。