☰
CPU Offload优化器状态:大模型训练显存不足的实战解法
2026/10/22 13:38:35 网站建设 项目流程

最近在调一个大规模语言模型微调任务,单卡显存 80G,batch size 从 8 一路降到 4 还是 OOM。查了显存占用后发现:真正吃显存的大头根本不是模型权重,而是 AdamW 的优化器状态。一阶动量、二阶动量、FP32 的 master weight 叠在一起,几乎是模型参数量的好几倍。后来把优化器状态全部挪到 CPU 上,显存压力立刻小了一个量级,batch size 能重新提上去了。这篇文章就把 CPU Offload 的具体做法、原理和踩坑记录完整写一遍。

1. 先搞清楚:优化器状态在显存里占了多少

1.1 AdamW 要维护的“私货”有哪些

很多人以为显存占用主要来自模型参数,其实训练一个模型时,显存里同时住着四类东西:模型参数本身、梯度、优化器状态,以及前向传播时保存的中间激活值。其中优化器状态是被低估最严重的一块。以最常用的 AdamW 优化器为例,每个参数都要额外保存两个状态:一阶动量(也就是俗称的 momentum)和二阶动量(variance),它们的数据类型通常是 FP32。再加上更新参数时为了避免精度丢失而额外保留的一份 FP32 参数副本。

这里有个很容易忽略的细节:你在 PyTorch 里用torch.float32创建模型,参数本身就是 FP32,这个 FP32 master weight 不需要重复占用。但如果你用了混合精度训练,模型权重以 FP16 或 BF16 存储和计算,优化器更新时仍然需要一份 FP32 的 master weight 来做数值累加。也就是说,混合精度训练下,优化器状态的额外代价会翻倍。具体数字可以这样算:

模型参数量为 \(M\)。FP16 训练时,模型权重占 \(2M\) 字节,梯度占 \(2M\) 字节,而 AdamW 的状态是:FP32 的 master weight 占 \(4M\)、一阶动量占 \(4M\)、二阶动量占 \(4M\),合计 \(12M\) 字节。如果是纯 FP32 训练,模型参数本身就是 FP32,master weight 可以和参数合并,但动量依然要占 \(8M\) 字节。对于一个 7B 模型,纯 FP32 训练下仅优化器状态就要 56GB,加上参数和梯度就是 70GB;混合精度训练更是直接到 112GB。单卡 80G 怎么塞都塞不下,这个账必须提前算。

1.2 显存爆炸是从哪一步开始的

显存分配是动态的,很多人看nvidia-smi发现占用很高,但分辨不清是谁占的。常规训练流程里,前向传播会逐层计算并保存激活值,反向传播时这些激活值被消费后释放;反向传播产生梯度后,优化器步骤会读取梯度并更新参数,然后梯度被释放。所以峰值显存往往出现在反向传播结束、 optimizer.step() 即将执行的那一刻:此刻参数、梯度、优化器状态、还没完全释放的中间缓冲全都在场。

如果你在 optimizer.step() 之前打印一下当前显存占用,会发现从模型加载完成到反向完成,占用一路爬升。当我把优化器状态挪到 CPU 后,显存曲线在 optimizer.step() 附近出现了一个明显的“下降台阶”,就是因为 CPU 承担了那 12M 字节里的大部分。

判断显存瓶颈到底在哪,最简单的办法是只加载模型、不跑训练,看初始占用;然后跑一个前向,看激活值峰值;再跑完整训练,比较 step 前后的占用变化。如果完整训练比单步前向多出来的显存接近 12M 字节,那 CPU Offload 就是性价比最高的方案。如果瓶颈反而在激活值,offload 优化器状态的意义就不大,应该先调小 batch size 或用重计算。

2. CPU Offload 的底层逻辑与方案选型

2.1 为什么单独把优化器状态赶去 CPU

CPU Offload 的核心思路不是把整个训练搬到 CPU,而是把训练中最“重”但计算最简单的那部分拆走。模型前向反向的计算量巨大,必须留在 GPU;优化器状态虽然体积大,但更新逻辑其实只是一组逐元素的乘加操作,放到 CPU 上也完全能算。等于把最占地方、计算又不复杂的部分挪出去,让 GPU 只保留计算密集的部分。

为什么不干脆把梯度也放到 CPU?梯度是在 GPU 上反向传播得到的,如果要把梯度送到 CPU,那每个参数都要在反向结束后来一次 GPU 到 CPU 的拷贝,通信开销直线上升。优化器状态不同,它在上一轮 step 结束时就驻留在 CPU 上了,本轮训练中只有更新时需要把梯度从 GPU 拷到 CPU,拷完之后 CPU 算好新的状态,再把更新后的参数拷回 GPU 继续前向。一次训练步多两次 PCIe 传输(梯度下 CPU、参数上 GPU),但相比梯度每轮都跨设备,压力小得多。

这里还有一个隐蔽的好处:CPU 内存比显存便宜得多,扩展性也强。服务器动辄几百 GB 的内存,配合 NVMe 甚至可以把 optimizer state 再溢出到磁盘,虽然慢,但至少不会 OOM。GPU 显存加不了,但内存条可以加,这是工程上非常现实的取舍。

2.2 三种常见实现路线对比

现在做 CPU Offload 主要有三条路:用分布式训练框架自带的 offload 能力、用 PyTorch 原生手段手动搬、以及把优化器换成支持 offload 的第三方实现。三者逻辑上有重合,但落地细节差异很大。

用框架自带能力是最省心的。业界常用的训练框架里把优化器状态 offload 到 CPU 通常被称为 ZeRO-Offload,大致原理是:把优化器状态和更新计算都放到 CPU 侧,GPU 只保留参数和梯度;反向传播结束后,梯度被分片传输到 CPU,CPU 上的优化器对各个分片完成更新,再把更新后的参数数据重新拷贝回 GPU。这个过程中还包含参数分片(ZeRO Stage 1 或 Stage 2)的优化,所以显存节省比单纯 offload 更多。

PyTorch 原生手段没有“一键开启 offload”的开关,但你可以自己写:模型参数在 GPU 上,额外在 CPU 上维护一份 optimizer state,用自定义的 optimizer 或手动更新逻辑,在 step 时把梯度 detach 到 CPU,用 CPU 张量更新,再把新参数拷贝回 GPU。这个路线灵活、可控,适合做研究或调试,但要做好通信重叠和内存管理,不然性能会很难看。

第三方优化器实现是另一种选择,不过对多数场景来说没必要。如果只是微调中小模型,用 PyTorch 手动方案就够了;大模型预训练这种规模,建议直接用成熟的分布式训练框架,毕竟它连梯度分片、通信 overlap、checkpoint 保存都帮你处理好了。

2.3 什么时候根本不需要 offload

并非所有场景都适合 CPU Offload。如果你的模型小于 1B,用单卡能跑,显存也没到 90%,那 offload 的收益很小,反而把简单的训练流程复杂化了。激活值才是瓶颈时,优先做 activation checkpointing 或者减小 batch size。GPU 到 CPU 的带宽本来就紧张,offload 优化器状态引入额外传输,训练吞吐会下降,对于小模型来说这种下降可能不划算。

另外,如果你的训练负载已经用上了模型并行或者流水线并行,优化器状态已经在各设备间分片了,再叠加 CPU offload 需要仔细考虑跨节点通信的拓扑。多机场景下,CPU 内存访问要走 PCIe 甚至网络,开销更大,这时优先用 NVMe 做 swap 或者干脆不对优化器状态做 offload,而是用更大的显存集群。offload 是单机显存不够时的妥协方案,不是万能的。

3. 具体做法:从配置到跑起来

3.1 用框架自带的 ZeRO-Offload 落地

先展示最省事的方式。假设你已经在跑分布式训练框架,并且代码里用的已经是框架的分布式数据并行封装,那么开启 offload 基本就是改配置的事。以典型的 ZeRO 配置为例,核心是两步:把 stage 设为 2,把 offload optimizer 设为 true。

下面是一个可以照着改的配置模板:

{ "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu", "pin_memory": true, "ratio": 1.0 }, "contiguous_gradients": true, "overlap_comm": true, "reduce_bucket_size": 5e8, "cpu_offload_use_pin_memory": true }, "train_batch_size": 32, "gradient_accumulation_steps": 4, "optimizer": { "type": "AdamW", "params": { "lr": 3e-5, "betas": [0.9, 0.999], "eps": 1e-8, "weight_decay": 0.01 } } }

注意几点:pin_memory可以加速 CPU 和 GPU 之间的传输,但会占用不可换页的内存,需要预留足够;contiguous_gradients让梯度在传输前先变成连续内存块,减少传输次数;overlap_comm让梯度通信和 GPU 计算的一部分重叠,建议开着。如果你的显存还没到完全不够的程度,可以只把ratio设为 0.5,让一半优化器状态留在 GPU,另一半在 CPU,这个折中能缓解一部分带宽压力。

跑起来之后,怎么确认真的生效了?最直接的方式是在训练循环前后打印 GPU 显存占用,观察 step 前后的峰值是否明显降低。也可以把 PyTorch 的 CUDA memory 统计打开,看 reserved 量是否显著下降。若显存下降但不明显,八成是ratio没设对,或者还有额外的激活值在占用空间。

3.2 不依赖框架的手动 CPU Offload 写法

如果你想搞清原理,或者用的代码库没法引入分布式训练框架,那手动实现也不复杂。核心思路是:让优化器状态常驻 CPU,每一步把梯度搬到 CPU,在 CPU 上更新参数,再把更新后的参数搬回 GPU。

先创建参数副本和 CPU 状态。假设模型参数名字为name -> param,在 GPU 上。我们为每个参数维护 CPU 上的 FP32 master weight 和动量:

cpu_master = {name: param.detach().float().cpu() for name, param in model.named_parameters()} m = {name: torch.zeros_like(cpu_master[name]) for name in cpu_master} v = {name: torch.zeros_like(cpu_master[name]) for name in cpu_master}

训练循环里,在loss.backward()之后、参数更新之前,把每个参数的梯度搬到 CPU 并执行 AdamW 更新:

for name, param in model.named_parameters(): if param.grad is None: continue grad_cpu = param.grad.detach().float().cpu() p_cpu = cpu_master[name] m[name].mul_(beta1).add_(grad_cpu, alpha=1.0 - beta1) v[name].mul_(beta2).addcmul_(grad_cpu, grad_cpu, value=1.0 - beta2) bias_corr1 = 1.0 - beta1 ** step bias_corr2 = 1.0 - beta2 ** step denom = (v[name] / bias_corr2).sqrt().add_(eps) p_cpu.addcdiv_(m[name] / bias_corr1, denom, value=-lr) param.data.copy_(p_cpu.cuda())

这段代码很直观,但有几个坑。第一,不要在循环里频繁做.cpu()和.cuda()调用,每轮如此会有大量同步开销;更好的做法是提前为每个参数创建固定的 CPU 张量作为接收缓冲,用copy_完成数据传输。第二,梯度搬走之后要记得把param.grad置空或者减掉引用,否则 GPU 显存里仍保留着梯度,问题没解决。第三,更新时用的step要按实际更新次数递增,别在梯度累积的每个 micro step 都加一次,否则动量偏置校正会算错。

手动方案还有一个致命问题:更新过程的 CPU 计算和 GPU 前向反向没有重叠,每个 step 都是 GPU 在等 CPU 算完优化器,吞吐会比框架方案低不少。如果只是验证原理没问题,想调到能用的程度,就要把“搬梯度”和“搬参数”也变成异步的,用单独的 CUDA stream 配合non_blocking=True做传输,让计算和拷贝尽量重叠。

3.3 关键参数怎么定:通信粒度、内存预留、梯度裁剪位置

参数配置里最容易被忽略的是通信粒度。梯度从 GPU 到 CPU 是分批传的,如果一次把所有梯度拼成一个大张量再传,传输效率最高,但内存峰值会变大;如果每个参数单独传,内存小但启动开销惊人。工程上一般用前面配置里的reduce_bucket_size来控制分桶大小,比如 5e8 字节意味着约 500MB 一个通信桶。这个值需要根据模型大小和 PCIe 带宽实测调,不能照抄。

内存预留则要算一笔账。CPU 上需要存的包括 FP32 master weight、一阶动量、二阶动量,总共是模型参数量的 12 倍字节(混合精度下),再加上训练框架本身的数据加载、checkpoint 暂存等内存消耗。一个 7B 模型,CPU offload 后 CPU 侧至少预留 84GB 以上内存,这还没有算数据加载用的 page cache。很多服务器表面内存很大,但跑 offload 后因为内存不足开始 swap,整机卡死,就是没算这笔账。

梯度裁剪的位置也很讲究。手动实现时,如果你在 GPU 上做梯度裁剪,裁剪后再搬到 CPU,那没问题。但如果你把所有梯度都搬到 CPU 之后才做 global norm 裁剪,计算出的全局梯度范数要考虑 CPU 和 GPU 上梯度是否齐全。框架方案内部已经处理好了,但手动方案里我发现最容易犯的错误是:梯度已经搬走一部分,另一部分还在 GPU,此时计算全局范数会得到一个错误的裁剪系数。正确做法是先把所有梯度都搬到 CPU,再统一计算范数并裁剪,或者干脆在 GPU 上先裁剪再搬。

4. 性能调优与工程陷阱

4.1 PCIe 带宽是最大的拦路虎

CPU Offload 最直接的性能损耗来自 PCIe 传输。一个 7B 模型,每轮训练要把 7B 参数对应的梯度从 GPU 搬到 CPU,再把更新后的参数从 CPU 搬回 GPU,总数据量在 28GB 左右(以 BF16 计算),这还不算中间可能存在的内存拷贝开销。普通服务器 PCIe Gen4 x16 的理论带宽约 32GB/s,实际能跑到 20GB/s 就不错了,所以光数据传输就要接近一秒多。如果训练框架没有做好通信和计算重叠,这个时间会直接加到每一步的训练耗时里,训练吞吐可能下降 30% 甚至更多。

为了提高实际带宽,务必打开 pin memory。pin memory 是 CUDA 传输的“快速通道”,它让 CPU 侧的内存页锁定在物理内存中,GPU 可以直接用 DMA 访问,避免内核先拷贝到可分页内存再传输的开销。另外,梯度在传输前尽量整理成连续的 chunk,减少多次小传输。

如果条件允许,用 NVMe SSD 做 swap 层也是一种思路:把一部分动量状态放在 NVMe 上,CPU 内存只保留当前需要更新的分片。这种分层 offload 可以突破 CPU 内存容量限制,但延迟比内存高一个量级,只适合内存确实不够、又不想买新机器的场景。

4.2 和梯度累积、混合精度、冻结层搭配时的小算盘

CPU Offload 不是孤立使用的,实际训练里常常伴随梯度累积、混合精度、冻结层等优化手段。梯度累积时,你累积的是梯度,而不是更新参数,所以优化器 step 只在累积结束后执行一次。这时 offload 的通信量并没有增加,但累积过程中 GPU 显存里始终保留着累积中的梯度副本。如果累积轮数很多,gradient 本身占用的显存也要算进预算里,否则可能反而 OOM。

混合精度下,模型参数是 BF16,优化器在 CPU 上必须维护 FP32 的 master weight。为了避免精度损失,参数从 CPU 拷贝回 GPU 时要转成 BF16,这个转换是在 CPU 上做还是 GPU 上做会影响精度和速度。我的经验是不要为了省带宽在 CPU 上直接截断成 BF16 再传,那样计算图里参数更新后需要同步的精度问题会很隐蔽。更好的做法是 CPU 侧保留 FP32,拷贝到 GPU 后再统一转成 BF16,这个差异在长训练里能看到 optimizer 更新的 fp32 累积效果。

冻结层更微妙。如果你冻结了某些层,那么这些层的参数不需要梯度,也不需要优化器状态。在 offload 配置里要尽量让冻结层不进入 offload 管理范围,否则框架会傻乎乎地把它们的动量也搬到 CPU,白白占内存和带宽。手动实现时,直接跳过requires_grad=False的参数即可。

4.3 实测中容易踩的坑

第一个坑是 CPU 内存碎片。训练框架里 CPU 侧会反复分配和释放优化器状态缓冲区,跑了很久之后 CPU 内存碎片化严重,虽然看起来总量足够,但分配大块内存时失败,训练直接崩。解决方法是尽量在训练开始前把所有 CPU 张量的存储空间一次性分配好,后面只做copy_,不做clone。

第二个坑是数据加载进程和 offload 抢内存。如果用多个 DataLoader worker,它们会预取大量图片/文本数据,内存占用可能突然暴涨。CPU offload 本来就把 CPU 内存快用满了,数据加载一出问题,整机就进入 swap,训练速度断崖式下降。建议给数据加载 worker 单独限制内存,或者用共享内存缓存。

第三个坑是 checkpoint 保存。offload 后,optimizer state 在 CPU 上,如果你还在调用默认的save_checkpoint逻辑,它可能只保存 GPU 上的模型状态,而 CPU 上的动量状态根本不会被存下来。恢复训练时,优化器从头开始,虽然 loss 看起来能继续降,但实际前几百步会“白训”。一定要确认 checkpoint 里包含 CPU 上的优化器状态。

还有个很容易被忽略的问题:torch.cuda.synchronize()的位置。很多人为了测量时间会在 step 前后加同步,但这会把异步传输强制同步化,掩盖了本来能做好的通信重叠。测性能时加同步没问题,但平时训练代码别放太多同步点,否则 offload 的优势会被冲刷掉。

5. 故障排查与速查表

5.1 常见故障现象、原因与处理

现象大概率原因处理方式
训练一步卡住很久才动CPU 内存不足触发 swap,或 pin_memory 未开启检查内存用量,预留足够空间;开启 pin_memory 并缩小 num_workers
显存没怎么降offload 配置没生效,或优化器状态仍由 PyTorch 默认管理确认框架配置里的 stage 和 offload 开关、打印各参数设备位置
恢复训练后 loss 异常反弹checkpoint 没保存 CPU 上的优化器状态检查保存的逻辑,把 CPU 状态序列化进 checkpoint
更新后的参数和梯度对不上梯度搬移前被清零,或异步传输未同步就开始下一步在 copy 完成后再 clear 梯度,必要时加一次同步
只 offload 一半,吞吐反而更低ratio 太小,通信次数增加但没省多少显存调大 ratio,或先不开 offload 跑通基线再优化
数据加载进程 OOMCPU 内存预留不足,worker 预取导致峰值超限减小 num_workers,或用内存数据库缓存替代预取
用 NVMe swap 后训练极慢swap 延迟太高,瓶颈转移到磁盘 IO增大 CPU 内存,或减少 offload 到 NVMe 的数据量

5.2 我个人最后想补充的几点经验

做 CPU Offload 最容易陷入“为了 offload 而 offload”的误区。我在实际调参时发现,先用小模型把通信时间和计算时间拆开统计一遍,往往能发现真正瓶颈在哪。比如我用一个 3B 模型做实验,开 offload 后显存降了 30%,但是 step 时间涨了 20%,可整体吞吐反而因为 batch size 能翻倍而变快。这个收益不能只看 step 时间,要结合有效吞吐和显存容量一起评估。

另一个经验是:如果要长期跑大模型训练,不要手动实现 CPU Offload,直接用成熟的分布式训练框架,哪怕前期学配置多花点时间。手动方案的坑实在太隐蔽了,异步传输、内存对齐、checkpoint 兼容,随便一个都能让你排查好几天。框架方案迭代多年,这些边界情况都处理过。

最后提醒一点:CPU Offload 只是显存不足的缓解手段,它的上限受限于 PCIe 带宽和 CPU 内存。如果你的训练需求已经大到 offload 都压不住,那应该考虑模型并行或者参数高效微调方案。先把显存分布看清楚,再决定动哪一层,这比盲目堆技术手段更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询