数据并行(Data Parallelism)原理
数据并行,说白了就是「把数据切开,分给多个工人一起干」。这是分布式训练里最基础、也最常用的一种并行方式。我个人习惯把它比作「流水线上的分拣工」——每个人手里拿到的零件不一样,但干的活是一样的。
数据并行的核心思想
假设你有一张巨大的训练集,比如 100 万张图片。单机训练,跑一轮可能要一天。数据并行怎么做?把数据切成 N 份,每份交给一个 GPU 去算。
每个 GPU 上放的是同一个模型副本。模型参数一模一样,只是喂进去的数据不同。每个 GPU 独立计算前向传播、反向传播,算出各自的梯度。
嗯,这里要注意:每个 GPU 算出来的梯度是不一样的。因为数据不同嘛。那问题来了——模型参数怎么更新?
答案就是:把所有 GPU 算出来的梯度做个平均,然后用这个平均梯度去更新模型参数。这样每个 GPU 上的模型就保持一致了。
核心公式(简化版):
θ_new = θ_old - lr * (1/N) * Σ(g_i)
其中 g_i 是第 i 个 GPU 算出的梯度,N 是 GPU 数量。
我在项目中遇到过一个问题:数据切分不均匀会导致某些 GPU 空等。比如数据量不能被 GPU 数量整除,最后一块 GPU 分到的数据少,算得快,但得等其他人算完才能做 AllReduce。所以数据切分一定要均匀,最好用 DataLoader 的 drop_last 参数。
梯度同步机制(AllReduce)
梯度同步是数据并行的灵魂。没有它,每个 GPU 就是「各玩各的」。AllReduce 是一种高效的通信原语,专门用来把多个节点上的数据「归约」成一个结果,再广播回去。
常见的 AllReduce 实现有两种:
- Ring AllReduce:把 GPU 排成一个环,数据在环上转圈。每个 GPU 只和邻居通信。通信量是 O(N),但带宽利用率高。
- Tree AllReduce:用树形结构做归约和广播。通信量是 O(logN),但容易有单点瓶颈。
我个人习惯用 Ring AllReduce。为什么?因为它在大规模集群上表现更稳定。我记得有一次在 64 卡集群上做测试,Tree AllReduce 在最后几层树节点上出现了网络拥塞,导致整体慢了 30%。换成 Ring 之后,问题就解决了。
下面这张图展示了 Ring AllReduce 的基本流程:
你想想看,如果没有 AllReduce,每个 GPU 各自为政,模型参数会迅速发散。有了 AllReduce,大家步调一致,模型才能收敛。
同步更新 vs 异步更新
这是数据并行里一个经典的「二选一」问题。我直接说结论:同步更新更稳定,异步更新更快,但容易出幺蛾子。
| 对比维度 | 同步更新 | 异步更新 |
|---|---|---|
| 梯度一致性 | 所有GPU梯度严格对齐 | 每个GPU独立更新,可能有延迟 |
| 收敛稳定性 | 高,等价于扩大batch size | 低,可能出现梯度陈旧 |
| 训练速度 | 受最慢GPU拖累 | 快,不用等别人 |
| 实现复杂度 | 简单,AllReduce后统一更新 | 复杂,需要处理参数版本冲突 |
| 适用场景 | GPU性能相近、网络稳定 | 异构集群、GPU性能差异大 |
同步更新的逻辑很直观:等所有 GPU 算完梯度,做 AllReduce,拿到平均梯度,然后大家一起更新参数。下一轮迭代开始前,所有 GPU 上的模型参数是完全一样的。
异步更新就不一样了。每个 GPU 算完梯度后,直接去更新全局参数。不用等别人。这听起来很高效,对吧?但有个坑——梯度陈旧问题。
我曾经踩过的坑:有一次在 32 卡集群上跑异步更新,训练 loss 死活降不下去。排查了三天,发现是某个 GPU 因为网络延迟,拿到的参数版本落后了 5 轮。它用旧参数算出的梯度去更新新参数,相当于「用昨天的天气预报决定今天穿什么衣服」。后来我改成同步更新,问题立刻解决。
那什么时候用异步更新呢?我个人建议:
- GPU 数量少(≤8)且网络好:用同步更新,省心。
- GPU 数量多(≥64)且异构:可以考虑异步更新,但要做好梯度裁剪和版本控制。
- 训练大模型(百亿参数以上):同步更新是主流,因为异步更新的参数服务器会成为瓶颈。
小技巧:如果你不确定用哪种,先试试同步更新。PyTorch 的 DistributedDataParallel(DDP)默认就是同步模式。等跑通了,再根据实际瓶颈考虑是否切换到异步。
嗯,最后说一句:数据并行不是银弹。当模型大到单卡放不下时,就得考虑模型并行或流水线并行了。但那是后面章节的内容,今天先到这里。
想要更多资料,联系可可