数据并行训练深度解析:为什么梯度要取平均?

张开发
2026/4/18 18:33:39 15 分钟阅读

分享文章

数据并行训练深度解析:为什么梯度要取平均?
数据并行训练深度解析为什么梯度要取平均一、引言在大模型训练时代单张GPU已经无法满足训练需求。数据并行Data Parallelism是最常用、最直观的分布式训练策略。但很多初学者会有一个疑问梯度同步时为什么要取平均而不是直接求和本文将通过详细的数值例子彻底讲清楚这个问题。二、数据并行的基本原理2.1 整体架构数据并行的核心思想非常简单┌─────────────────────────────────────────────────┐ │ 训练数据集 │ │ [x1, x2, x3, x4, x5, x6, x7, x8, ...] │ └───────┬──────────┬──────────┬──────────┬─────────┘ ▼ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ GPU 0 │ │ GPU 1 │ │ GPU 2 │ │ GPU 3 │ │ Model副本│ │ Model副本│ │ Model副本│ │ Model副本│ │[x1, x2] │ │[x3, x4] │ │[x5, x6] │ │[x7, x8] │ │ ↓前向 │ │ ↓前向 │ │ ↓前向 │ │ ↓前向 │ │ Grad0 │ │ Grad1 │ │ Grad2 │ │ Grad3 │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ └────────────┴─────┬──────┴────────────┘ ▼ All-Reduce 同步 梯度取平均 or 求和 ▼ 每个GPU独立更新参数每个GPU都持有完整的模型副本处理不同的数据子集最终通过同步梯度保持模型一致。2.2 工作流程六步Step 1: 数据分发 → 将 Global Batch 均匀切分到 N 个 GPU Step 2: 前向传播 → 每个 GPU 用自己的数据独立计算输出 Step 3: 损失计算 → 每个 GPU 独立计算损失值 Step 4: 反向传播 → 每个 GPU 独立计算本地梯度 Step 5: 梯度同步 → 通过 All-Reduce 聚合所有 GPU 的梯度 Step 6: 参数更新 → 每个 GPU 用同步后的梯度独立更新参数三、核心问题为什么取平均而不是求和这是本文的重点。我们通过严格的数值例子来对比说明。3.1 问题设定假设我们有一个极简的线性模型模型y w * x 损失函数L (1/B) * Σ (y_i - t_i)² MSE LossB 为 batch size 当前参数w 1.0 学习率lr 0.01训练数据共 4 个样本样本x真实标签 ts12.03.0s23.05.0s31.02.0s44.06.03.2 基准单GPU处理全部4个样本这是我们的黄金标准数据并行的结果应该与它等价。单GPUbatch size 4处理 [s1, s2, s3, s4] 前向传播w 1.0 y1 1.0 × 2.0 2.0 y2 1.0 × 3.0 3.0 y3 1.0 × 1.0 1.0 y4 1.0 × 4.0 4.0 损失MSE L (1/4) × [(2.0-3.0)² (3.0-5.0)² (1.0-2.0)² (4.0-6.0)²] (1/4) × [1 4 1 4] (1/4) × 10 2.5 梯度 ∂L/∂w ∂L/∂w (1/4) × Σ 2(w·xi - ti)·xi (1/4) × [2(2.0-3.0)×2.0 2(3.0-5.0)×3.0 2(1.0-2.0)×1.0 2(4.0-6.0)×4.0] (1/4) × [2×(-1)×2 2×(-2)×3 2×(-1)×1 2×(-2)×4] (1/4) × [-4 (-12) (-2) (-16)] (1/4) × (-34) -8.5 参数更新 w_new w - lr × ∂L/∂w 1.0 - 0.01 × (-8.5) 1.085✅ 基准结果梯度 -8.5更新后 w 1.0853.3 方案A2个GPU 梯度取平均正确做法GPU 0 处理 [s1, s2]local batch size 2 GPU 1 处理 [s3, s4]local batch size 2GPU 0 的计算前向y1 2.0, y2 3.0 损失L0 (1/2) × [(2.0-3.0)² (3.0-5.0)²] (1/2) × [14] 2.5 梯度∂L0/∂w (1/2) × [-4 (-12)] (1/2) × (-16) -8.0GPU 1 的计算前向y3 1.0, y4 4.0 损失L1 (1/2) × [(1.0-2.0)² (4.0-6.0)²] (1/2) × [14] 2.5 梯度∂L1/∂w (1/2) × [-2 (-16)] (1/2) × (-18) -9.0取平均同步∂L/∂w (∂L0/∂w ∂L1/∂w) / 2 (-8.0 (-9.0)) / 2 -8.5 ✅ w_new 1.0 - 0.01 × (-8.5) 1.085 ✅✅ 与单GPU基准完全一致3.4 方案B2个GPU 梯度直接求和错误做法∂L/∂w ∂L0/∂w ∂L1/∂w -8.0 (-9.0) -17.0 ❌ w_new 1.0 - 0.01 × (-17.0) 1.17 ❌❌ 梯度变成了 -17.0是正确值 -8.5 的 2 倍更新后 w 1.17 而非 1.0853.5 直观理解让我们从数学上看清楚为什么会这样单GPU全量计算4个样本batch4 ∂L/∂w (1/4) × Σ_{i1}^{4} [2(w·xi - ti)·xi] (1/4) × (-34) -8.5 GPU 0 本地计算2个样本batch2 ∂L0/∂w (1/2) × Σ_{i1}^{2} [2(w·xi - ti)·xi] (1/2) × (-16) ← 注意分母是2不是4 -8.0 GPU 1 本地计算2个样本batch2 ∂L1/∂w (1/2) × Σ_{i3}^{4} [2(w·xi - ti)·xi] (1/2) × (-18) -9.0关键在于每个GPU计算损失时分母是本地batch size2而不是全局batch size4。如果求和-8.0 (-9.0) -17.0 等价于(-16)/2 (-18)/2 (-16-18)/2 -34/2 -17.0 这相当于分母只有 2而不是 4 如果取平均(-8.0 (-9.0)) / 2 -17.0 / 2 -8.5 等价于[(-16)/2 (-18)/2] / 2 (-16-18) / (2×2) -34/4 -8.5 这才相当于全局 batch size 4取平均 补偿了分母从全局batch size变成本地batch size的差异。四、更深入的数学推导4.1 严格等价性证明全局损失函数单GPU情况下Global Batch Size N × BL_global (1/(N×B)) × Σ_{i1}^{N×B} ℓ(x_i)其中ℓ(x_i)是单个样本的损失。每个GPU的本地损失第k个GPULocal Batch Size BL_k (1/B) × Σ_{j∈Batch_k} ℓ(x_j)取平均时的等价性(1/N) × Σ_{k1}^{N} L_k (1/N) × Σ_{k1}^{N} [(1/B) × Σ_{j∈Batch_k} ℓ(x_j)] (1/(N×B)) × Σ_{i1}^{N×B} ℓ(x_i) L_global ✅对梯度同理(1/N) × Σ_{k1}^{N} ∇L_k ∇L_global ✅如果直接求和Σ_{k1}^{N} L_k Σ_{k1}^{N} [(1/B) × Σ_{j∈Batch_k} ℓ(x_j)] (N/(N×B)) × Σ_{i1}^{N×B} ℓ(x_i) N × L_global ❌ 多了 N 倍4.2 一张表总结操作等效梯度等效学习率是否等价单GPU取平均∇L_globallr✅ 完全等价直接求和N × ∇L_global相当于 N × lr❌ 不等价五、求和真的完全不行吗严格来说直接求和在数学上可以补救但会带来麻烦5.1 方案求和 缩小学习率如果使用梯度求和可以将学习率除以 N 来补偿 lr_adjusted lr / N 0.01 / 2 0.005 Grad_sum -17.0 w_new 1.0 - 0.005 × (-17.0) 1.0 0.085 1.085 ✅数学上等价了但这样做有很多实际问题5.2 为什么实践中不这样做问题1学习率语义混乱单GPU训练lr 0.01batch_size 32 → 迁移到 4 GPU 数据并行 取平均方案lr 0.01不用改直觉一致 求和方案 lr 0.01/4 0.0025必须手动调整如果GPU数量变了比如从4卡扩到8卡取平均方案无需改学习率求和方案每次都要改。问题2与优化器的耦合现代优化器Adam、AdaGrad等内部维护了梯度的统计量# Adam 优化器核心逻辑mβ1*m(1-β1)*grad# 一阶矩梯度均值vβ2*v(1-β2)*grad²# 二阶矩梯度方差ww-lr*m/(√vε)如果梯度是求和的放大了N倍那么m和v都会被放大m/√v的比值也会改变不是简单除以N就能修正的梯度取平均grad g m ∝ g, v ∝ g², 更新量 ∝ g/√(g²) sign(g) × 常数 梯度求和grad N*g m ∝ N*g, v ∝ N²*g², 更新量 ∝ N*g/√(N²*g²) sign(g) × 常数 → 对 Adam 来说恰好等价不完全 → ε 项的相对影响变了N*g / (N*|g| ε) ≠ g / (|g| ε)问题3梯度裁剪Gradient Clipping失效# 常见的梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(),max_norm1.0)取平均梯度量级正常max_norm1.0 的阈值有意义 求和 梯度被放大 N 倍同样的阈值会过早裁剪 → 需要把 max_norm 也乘以 N又多一个要调的超参数问题4混合精度训练中的数值溢出FP16 的表示范围±65504 取平均梯度量级正常如 grad ≈ 100 求和64 GPUgrad ≈ 6400还在范围内 求和1024 GPUgrad ≈ 102400 → 溢出5.3 总结对比考量因素取平均求和数学正确性✅ 天然正确⚠️ 需调学习率学习率语义✅ GPU数量无关❌ 随GPU数量变化Adam等优化器✅ 天然兼容❌ ε项受影响梯度裁剪✅ 阈值不变❌ 阈值需调整数值稳定性✅ 梯度量级不变❌ 大规模时可能溢出代码迁移性✅ 单卡→多卡无缝❌ 需修改多处超参数六、完整数值例子4 GPU 数据并行让我们做一个更完整的例子包含参数更新的全过程。6.1 设定模型y w1·x1 w2·x2两个参数 当前参数w1 0.5, w2 -0.3 学习率lr 0.1 损失函数MSE GPU数量4训练数据8个样本每GPU分2个GPU样本x1x2真实标签 t0s11.02.01.00s22.01.02.01s30.51.50.51s41.50.51.52s53.01.03.02s61.03.01.03s72.02.02.53s80.50.50.56.2 各GPU独立计算GPU 0样本 s1, s2前向传播 y1 0.5×1.0 (-0.3)×2.0 0.5 - 0.6 -0.1 y2 0.5×2.0 (-0.3)×1.0 1.0 - 0.3 0.7 损失 L0 (1/2) × [(-0.1-1.0)² (0.7-2.0)²] (1/2) × [(-1.1)² (-1.3)²] (1/2) × [1.21 1.69] 1.45 梯度∂L0/∂w1, ∂L0/∂w2 ∂L0/∂w1 (1/2) × [2×(-1.1)×1.0 2×(-1.3)×2.0] (1/2) × [-2.2 (-5.2)] (1/2) × (-7.4) -3.7 ∂L0/∂w2 (1/2) × [2×(-1.1)×2.0 2×(-1.3)×1.0] (1/2) × [-4.4 (-2.6)] (1/2) × (-7.0) -3.5GPU 1样本 s3, s4前向传播 y3 0.5×0.5 (-0.3)×1.5 0.25 - 0.45 -0.2 y4 0.5×1.5 (-0.3)×0.5 0.75 - 0.15 0.6 梯度 ∂L1/∂w1 (1/2) × [2×(-0.2-0.5)×0.5 2×(0.6-1.5)×1.5] (1/2) × [2×(-0.7)×0.5 2×(-0.9)×1.5] (1/2) × [-0.7 (-2.7)] -1.7 ∂L1/∂w2 (1/2) × [2×(-0.7)×1.5 2×(-0.9)×0.5] (1/2) × [-2.1 (-0.9)] -1.5GPU 2样本 s5, s6前向传播 y5 0.5×3.0 (-0.3)×1.0 1.5 - 0.3 1.2 y6 0.5×1.0 (-0.3)×3.0 0.5 - 0.9 -0.4 梯度 ∂L2/∂w1 (1/2) × [2×(1.2-3.0)×3.0 2×(-0.4-1.0)×1.0] (1/2) × [2×(-1.8)×3.0 2×(-1.4)×1.0] (1/2) × [-10.8 (-2.8)] -6.8 ∂L2/∂w2 (1/2) × [2×(-1.8)×1.0 2×(-1.4)×3.0] (1/2) × [-3.6 (-8.4)] -6.0GPU 3样本 s7, s8前向传播 y7 0.5×2.0 (-0.3)×2.0 1.0 - 0.6 0.4 y8 0.5×0.5 (-0.3)×0.5 0.25 - 0.15 0.1 梯度 ∂L3/∂w1 (1/2) × [2×(0.4-2.5)×2.0 2×(0.1-0.5)×0.5] (1/2) × [2×(-2.1)×2.0 2×(-0.4)×0.5] (1/2) × [-8.4 (-0.4)] -4.4 ∂L3/∂w2 (1/2) × [2×(-2.1)×2.0 2×(-0.4)×0.5] (1/2) × [-8.4 (-0.4)] -4.46.3 梯度同步All-Reduce取平均┌──────────────────────────────────────────────────┐ │ All-Reduce 取平均 │ │ │ │ ∂L/∂w1 (-3.7 -1.7 -6.8 -4.4) / 4 │ │ -16.6 / 4 │ │ -4.15 │ │ │ │ ∂L/∂w2 (-3.5 -1.5 -6.0 -4.4) / 4 │ │ -15.4 / 4 │ │ -3.85 │ └──────────────────────────────────────────────────┘6.4 参数更新所有GPU同步执行w1_new 0.5 - 0.1 × (-4.15) 0.5 0.415 0.915 w2_new -0.3 - 0.1 × (-3.85) -0.3 0.385 0.0856.5 验证单GPU全量计算单GPUbatch8处理全部样本 ∂L/∂w1 (1/8) × [所有样本的梯度贡献之和] (1/8) × [(-2.2-5.2) (-0.7-2.7) (-10.8-2.8) (-8.4-0.4)] (1/8) × [-7.4 (-3.4) (-13.6) (-8.8)] (1/8) × (-33.2) -4.15 ✅ ∂L/∂w2 (1/8) × [-7.0 (-3.0) (-12.0) (-8.8)] (1/8) × (-30.8) -3.85 ✅完全一致如果用求和∂L/∂w1_sum -3.7 -1.7 -6.8 -4.4 -16.6 ❌正确值 -4.15 的 4 倍 w1_错误 0.5 - 0.1 × (-16.6) 0.5 1.66 2.16 ❌严重偏离七、PyTorch 中的实现7.1 PyTorch DDP 的默认行为importtorchimporttorch.distributedasdistfromtorch.nn.parallelimportDistributedDataParallelasDDP# 初始化分布式环境dist.init_process_group(backendnccl)local_rankdist.get_local_rank()# 创建模型并包装为 DDPmodelMyModel().cuda(local_rank)modelDDP(model,device_ids[local_rank])# ☝️ DDP 默认在反向传播时执行 All-Reduce 并取平均optimizertorch.optim.Adam(model.parameters(),lr0.001)fordata,targetindataloader:optimizer.zero_grad()outputmodel(data)losscriterion(output,target)# 本地 lossloss.backward()# 反向传播 自动 All-Reduce 取平均optimizer.step()# 用平均梯度更新参数7.2 验证 DDP 确实在取平均# 手动验证importtorchimporttorch.distributedasdistdefcheck_gradient_sync():验证 DDP 使用的是梯度平均# 假设在 rank 0 上forname,paraminmodel.named_parameters():ifparam.gradisnotNone:local_gradparam.grad.clone()# 手动 all-reduce 求和summed_gradparam.grad.clone()dist.all_reduce(summed_grad,opdist.ReduceOp.SUM)# 手动取平均avg_gradsummed_grad/dist.get_world_size()# DDP 自动计算的梯度应该等于手动平均的结果# DDP 在 backward 结束后param.grad 已经是平均值了asserttorch.allclose(param.grad,avg_grad),fMismatch in{name}!print(f[Rank{dist.get_rank()}]{name}: flocal_grad_norm{local_grad.norm():.4f}, fsynced_grad_norm{param.grad.norm():.4f})7.3 完整可运行示例 完整的数据并行训练示例 运行命令torchrun --nproc_per_node4 train_ddp.py importosimporttorchimporttorch.nnasnnimporttorch.distributedasdistfromtorch.nn.parallelimportDistributedDataParallelasDDPfromtorch.utils.dataimportDataLoader,TensorDatasetfromtorch.utils.data.distributedimportDistributedSamplerdefsetup():dist.init_process_group(backendnccl)local_rankint(os.environ[LOCAL_RANK])torch.cuda.set_device(local_rank)returnlocal_rankdefcleanup():dist.destroy_process_group()defmain():local_ranksetup()world_sizedist.get_world_size()rankdist.get_rank()# 模型 modelnn.Linear(10,1,biasFalse).cuda(local_rank)# 确保所有 GPU 从相同参数开始# DDP 构造函数会自动广播 rank 0 的参数到其他 rankddp_modelDDP(model,device_ids[local_rank])optimizertorch.optim.SGD(ddp_model.parameters(),lr0.01)criterionnn.MSELoss()# MSE 自带 mean reduction# 数据 # 全局共 1000 个样本ifrank0:Xtorch.randn(1000,10)Ytorch.randn(1000,1)# 保存数据以便其他 rank 加载实际中用共享文件系统torch.save((X,Y),/tmp/data.pt)dist.barrier()X,Ytorch.load(/tmp/data.pt)datasetTensorDataset(X,Y)# DistributedSampler 确保每个 GPU 获得不重叠的数据子集samplerDistributedSampler(dataset,num_replicasworld_size,rankrank)dataloaderDataLoader(dataset,batch_size32,samplersampler)# 每个GPU的 local batch size 32# 全局等效 batch size 32 × 4 128# 训练循环 forepochinrange(5):sampler.set_epoch(epoch)# 确保每个 epoch 的 shuffle 不同forbatch_idx,(data,target)inenumerate(dataloader):datadata.cuda(local_rank)targettarget.cuda(local_rank)optimizer.zero_grad()outputddp_model(data)losscriterion(output,target)loss.backward()# 梯度自动 All-Reduce 取平均optimizer.step()ifbatch_idx%50andrank0:print(fEpoch{epoch}, Batch{batch_idx}, Loss:{loss.item():.4f})# 验证所有 GPU 参数一致 forname,paraminddp_model.named_parameters():param_tensorparam.data.clone()gathered[torch.zeros_like(param_tensor)for_inrange(world_size)]dist.all_gather(gathered,param_tensor)ifrank0:all_sameall(torch.allclose(gathered[0],g)forgingathered[1:])print(f参数{name}在所有GPU上一致:{all_same})cleanup()if__name____main__:main()八、All-Reduce 通信机制梯度取平均的操作在底层是通过All-Reduce实现的8.1 All-Reduce 操作初始状态每个 GPU 持有本地梯度 GPU 0: [g0] GPU 1: [g1] GPU 2: [g2] GPU 3: [g3] All-Reduce 之后每个 GPU 持有全局平均梯度 GPU 0: [ḡ] GPU 1: [ḡ] GPU 2: [ḡ] GPU 3: [ḡ] 其中 ḡ (g0 g1 g2 g3) / 48.2 Ring All-Reduce 算法PyTorch NCCL 底层通常使用Ring All-Reduce分为两个阶段阶段1: Reduce-Scatter每个GPU获得部分聚合结果 假设梯度被切成 4 块[a, b, c, d] GPU 0: [a0, b0, c0, d0] GPU 1: [a1, b1, c1, d1] GPU 2: [a2, b2, c2, d2] GPU 3: [a3, b3, c3, d3] 经过 3 轮环形传递后 GPU 0: [Σa, ___, ___, ___] ← 持有第1块的聚合 GPU 1: [___, Σb, ___, ___] ← 持有第2块的聚合 GPU 2: [___, ___, Σc, ___] ← 持有第3块的聚合 GPU 3: [___, ___, ___, Σd] ← 持有第4块的聚合 阶段2: All-Gather将聚合结果广播到所有GPU 经过 3 轮环形传递后 GPU 0: [Σa, Σb, Σc, Σd] GPU 1: [Σa, Σb, Σc, Σd] GPU 2: [Σa, Σb, Σc, Σd] GPU 3: [Σa, Σb, Σc, Σd] 最后除以 N4得到平均值。8.3 通信量分析模型参数量P GPU数量N Ring All-Reduce 总通信量每个GPU 2 × (N-1)/N × P × sizeof(dtype) ≈ 2P × sizeof(dtype) 当 N 较大时 关键特性通信量与 GPU 数量几乎无关九、常见问题与陷阱9.1 学习率缩放Linear Scaling Rule虽然取平均保证了梯度的等价性但全局 batch size 增大了这本身会影响训练动态单GPUbatch_size 256, lr 0.1 4 GPU 数据并行 local_batch 256, global_batch 1024 线性缩放lr 0.1 × 4 0.4 这是因为 global batch 大了4倍不是因为梯度取平均注意区分梯度取平均 vs 求和→ 保证与单GPU在同一个global batch下等价学习率线性缩放→ 补偿global batch size增大带来的更新步数减少单GPU (batch256) 4GPU 数据并行 (local256) 全局 batch size 256 1024 每epoch迭代次数 ~390 (100K/256) ~97 (100K/1024) 梯度取平均 N/A ✅ 保证每步方向正确 学习率缩放 lr0.1 lr0.4可选补偿步数减少9.2 Batch Normalization 的特殊处理# BN 统计量默认只用本地 batch 计算# 如果需要全局统计量使用 SyncBatchNormmodeltorch.nn.SyncBatchNorm.convert_sync_batchnorm(model)Local BN默认 GPU 0: mean_0, var_0 ← 只基于本地 batch GPU 1: mean_1, var_1 ← 只基于本地 batch → 每个 GPU 的 BN 统计量不同 Sync BN global_mean (mean_0 mean_1 ...) / N global_var 需要额外计算不是简单平均 → 所有 GPU 使用相同的统计量9.3 随机性控制# 确保每个 GPU 的 dropout、数据增强等随机操作不同# 但模型初始化要相同# 模型初始化种子所有GPU相同torch.manual_seed(42)modelMyModel()# 数据加载种子每个GPU不同由 DistributedSampler 自动处理samplerDistributedSampler(dataset)sampler.set_epoch(epoch)# 每个epoch重新 shuffle十、总结核心结论┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 数据并行的梯度同步必须取平均原因 │ │ │ │ 1. 数学等价性平均后的梯度 单GPU在全局batch上计算的梯度 │ │ 2. 超参数一致性学习率、梯度裁剪阈值等无需随GPU数量变化 │ │ 3. 优化器兼容性Adam 等自适应优化器需要正确量级的梯度 │ │ 4. 数值稳定性避免大规模训练时的梯度溢出 │ │ │ │ 本质每个GPU计算损失时除以的是 local_batch_size │ │ 取平均相当于再除以 N总效果等于除以 global_batch_size │ │ │ │ global_grad (1/N) × Σ local_grad_k │ │ (1/N) × Σ [(1/B) × Σ sample_grad] │ │ (1/(N×B)) × Σ all_sample_grad │ │ 单GPU全量梯度 ✅ │ │ │ └─────────────────────────────────────────────────────────────────────┘一句话记忆梯度取平均 让多GPU数据并行在数学上严格等价于单GPU大batch训练一切超参数语义保持不变。后记2026年4月18日于上海在opus 4.6辅助下完成。

更多文章