4、梯度累积与梯度压缩:如何用梯度累积解决显存不足,如何用梯度压缩(TopK、QSGD)降低通信量

多卡训练中,显存和通信带宽是两个绕不开的坎。

我刚开始做分布式训练时,最头疼的就是这两件事。明明单卡能跑的模型,放到多卡上反而炸显存了。或者训练速度上不去,一看监控,GPU计算单元在空转,全在等通信。

今天咱们就聊聊两个实战技巧:梯度累积和梯度压缩。一个帮你省显存,一个帮你省带宽。

4.1 梯度累积:用时间换空间

先说说梯度累积。

你想想看,为什么多卡训练反而显存不够?

单卡训练时,你设个batch size=32,显存刚好够。但多卡训练,每张卡也要处理32条数据。如果模型太大,单卡连32都塞不进去,怎么办?

最简单的办法:把batch size拆小。比如每张卡只处理4条数据。

但问题来了——batch size太小,梯度噪声大,模型收敛慢,甚至不收敛。

梯度累积就是来解决这个矛盾的。

核心思想:不立即更新参数,而是攒几个小batch的梯度,累加起来,再统一更新。

举个例子。你设了梯度累积步数=8,每张卡batch size=4。那么每张卡会连续算8次前向+反向,每次得到一个小梯度。把这8个梯度累加,最后用累加后的梯度更新一次参数。

效果上,相当于每张卡用了batch size=32的数据来更新。但显存占用,始终只是batch size=4的水平。

我个人习惯在PyTorch里这样实现:

# 梯度累积示例
accumulation_steps = 8
optimizer.zero_grad()

for i, (inputs, labels) in enumerate(dataloader):
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    
    # 关键:除以累积步数,保持loss尺度一致
    loss = loss / accumulation_steps
    loss.backward()
    
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

注意那个loss / accumulation_steps。我见过有人忘了这步,结果loss震荡得厉害。为什么?因为累加8次梯度,相当于学习率放大了8倍,训练直接崩了。

小技巧:梯度累积可以和混合精度训练一起用。先开AMP,再设累积步数,显存能省到极致。

4.2 梯度压缩:通信瓶颈的破局点

显存问题解决了,下一个瓶颈是通信。

多卡训练时,每张卡算完梯度,要同步给所有卡。这个同步操作,通信量等于模型参数量×4字节(float32)。

举个例子,一个7B参数的模型,单次同步就要传28GB数据。8张卡就是224GB。哪怕用NVLink,也得传好几秒。

GPU算得快,但等通信等得心累。

梯度压缩的思路很简单:梯度不需要精确传输,压缩一下再传,能省大量带宽。

我重点讲两种主流方法:TopK和QSGD。

4.2.1 TopK稀疏化:只传最重要的梯度

TopK的想法很直观:梯度里大部分值都很小,接近0。这些值对模型更新的贡献微乎其微。那干脆只传绝对值最大的K%个梯度,剩下的直接丢弃。

比如K=1%,意味着只传1%的梯度。通信量直接降到原来的1%。

实现上,每张卡先算完整梯度,然后选出TopK个值:

def topk_compress(grad, compress_ratio=0.01):
    # grad: 完整梯度张量
    # 展平后取绝对值最大的K%
    grad_flat = grad.view(-1)
    k = max(1, int(grad_flat.numel() * compress_ratio))
    
    # 选出TopK的索引和值
    values, indices = torch.topk(grad_flat.abs(), k)
    
    # 只传输索引和值
    return indices, grad_flat[indices]

但这里有个坑——丢弃的梯度去哪了?

直接丢弃,信息就丢了。模型精度会下降。

解决办法是梯度累积误差。把丢弃的梯度存下来,下次训练时加回去。这样信息不会丢失,只是延迟传输。

我曾经踩过的坑:TopK的压缩率不能设得太高。我试过0.1%,通信确实快了,但模型精度掉了2个点。后来调到1%,精度几乎无损,通信量降了100倍。建议从1%开始调,看精度能接受再往上压。

4.2.2 QSGD量化压缩:用更少的比特传梯度

TopK是减少传输的个数,QSGD是减少每个值的比特数。

正常梯度是float32,32位。QSGD把它量化成低精度,比如8位、4位甚至2位。

量化过程很简单:

  1. 找到梯度的最大值和最小值
  2. 把连续值映射到离散的整数区间
  3. 传输这些整数,接收方再反量化回来

举个例子,8位量化:

def qsgd_compress(grad, bits=8):
    # 找到梯度范围
    grad_min = grad.min()
    grad_max = grad.max()
    
    # 量化到[0, 2^bits - 1]
    scale = (2**bits - 1) / (grad_max - grad_min + 1e-8)
    grad_quant = ((grad - grad_min) * scale).round().to(torch.int)
    
    # 传输量化后的梯度和缩放参数
    return grad_quant, grad_min, grad_max

8位量化,通信量降到原来的1/4。4位量化,降到1/8。

我个人的经验是,8位量化几乎不影响精度。4位量化在大部分模型上也能用,但需要配合误差累积。

4.3 梯度累积 + 梯度压缩:组合拳

这两个技术不冲突,可以一起用。

我常用的策略是:

  • 先用梯度累积,把单卡batch size压到1~4,解决显存问题
  • 再用TopK(1%压缩率)+ 8位QSGD,把通信量降到原来的1/400

效果怎么样?

我之前在一个8卡A100集群上训练130B模型。原始方案显存爆了,通信耗时占训练总时间的60%。用了这套组合拳后,显存占用降了70%,通信耗时降到5%以下。训练速度提升了将近3倍。

避坑指南:梯度压缩会引入额外计算(选TopK、量化反量化)。如果模型很小,压缩的计算开销可能超过通信节省。建议模型参数量超过1B时再用压缩。小模型直接用AllReduce就行。

4.4 知识体系总览

下面这张图总结了梯度累积和梯度压缩的核心逻辑:

梯度优化技术知识体系 问题:显存不足 单卡batch size太大,放不下 方案:梯度累积 攒N个小batch梯度,统一更新 显存占用 = 小batch,效果 = 大batch 注意:loss要除以累积步数 问题:通信瓶颈 梯度同步耗时 > 计算耗时 方案:梯度压缩 减少传输的梯度量 TopK:只传最重要的K% QSGD:用低精度量化传输 组合使用效果更佳 梯度累积省显存 + 梯度压缩省带宽

4.5 实战建议

最后给几个实战建议:

  • 先解决显存,再优化通信。显存都爆了,谈通信优化没意义。先用梯度累积把batch size降下来。
  • 梯度压缩从8位QSGD开始。实现简单,效果稳定。TopK需要调压缩率,门槛稍高。
  • 别忘了误差累积。无论TopK还是QSGD,丢弃的信息都要存下来,下次训练补上。否则精度必掉。
  • 实测验证。每个模型对压缩的敏感度不同。建议先跑一个小实验,对比压缩前后的loss曲线和精度。

嗯,梯度累积和梯度压缩就聊到这儿。这两个技术组合起来,基本能解决90%的多卡训练瓶颈问题。下次遇到显存不够或通信太慢,你知道该怎么做了。

一句话总结:梯度累积用时间换空间,梯度压缩用精度换带宽。两者结合,大模型多卡训练不再是难题。

想要更多资料,联系可可