最近在微调一个7B模型,LoRA训练到第三个epoch,控制台突然飘出一行红色的CUDA out of memory。这种“差一步就成功”的崩溃,经历过的人都懂。我后来把整套训练流程重写了一遍,从ZeRO-3参数分区到CPU Offload,才真正把显存账单压到一张10G显卡能接受的范围。这篇文章就是这次显存瘦身全过程记录,也是给那些想用普通显卡跑大模型微调的人一份可以照着改的作业。
大模型训练和本地推理完全是两种玩法。推理时7B模型用4bit量化能塞进8G显卡,跑得还挺欢;一进入训练,同样的模型瞬间就敢要你上百G显存。很多人第一反应是换显卡、加显存,但实际搞清楚显存到底被谁吃了之后,你会发现还有大量可以优化的空间。
1. 训练显存为什么总是不够用:先把账算明白
1.1 训练时显卡里到底存了什么
很多人以为显存只是用来“放模型”的,训练和推理的区别不过就是多算几步。这是最大的误区。
训练时一张显卡里至少躺着四样东西:模型参数、梯度、优化器状态、激活值。模型参数好理解,就是网络权重本身;梯度是反向传播算出来的更新方向,计算过程中必须保存在显存里;优化器状态则完全是被大多数人忽略的隐形大户。
以最常用的AdamW为例,它会为每一个参数保存两个动量变量,并且都以fp32精度存储,另外还要保留一份fp32的主权重备份。算下来,每个参数在优化器这一项上就要吃掉至少12字节。很多新手刚接触训练,看到模型参数是14GB(bf16),觉得16G显卡够了,结果一跑起来瞬间爆显存,就是因为没算这一层。
打个比方:推理是借一本书来看,看完就还;训练是边看书边做笔记,还要带一堆草稿纸,甚至要把整本书重新抄一遍再批注。纸张消耗自然不是一个量级。
1.2 手动算一笔7B模型的开销
我习惯在动手训练前,先用一张纸把显存账单算出来。以7B模型为例,估算如下:
| 项目 | 7B模型估算占用 | 说明 |
|---|---|---|
| 模型参数 | 14GB | bf16精度,每参数2字节 |
| 梯度 | 14GB | 每参数约2字节,看实现细节 |
| AdamW优化器状态 | 84GB | fp32主权重+两个动量,每参数12字节 |
| 激活值 | 几GB到几十GB | 取决于序列长度和batch size |
这四项加起来,即便把激活值压到很小,理论显存需求也在110GB以上。如果不开任何优化,单张24GB的4090是完全放不下的,想都别想。
很多人看到这个数字会觉得“那我是不是永远没法在自己电脑上训练大模型了”。不是,这只是说明我们必须在“省”字上下功夫:哪些部分可以省,能省到多少,省了之后要付出什么代价。这一整篇文章就是在回答这些问题。
1.3 为什么推理看着省,训练却这么吃
推理不需要反向传播,自然不需要保留梯度和优化器状态,激活值也是用完就释放。真正长期占住显存的主要是模型权重和KV Cache,所以7B模型量化后能在低端卡上跑。
训练则是另一套逻辑。每一个step都要完成前向、反向、优化器更新三步,每一步都要用到前一步的中间结果,显存里必须同时备好一整套“生产要素”。
所以你会看到一种奇特现象:同一个模型,用同一张显卡,推理流畅,训练却直接OOM。这不是显卡坏了,而是训练任务本身的显存需求就是这么高。理解这个区别之后,再看ZeRO-3和CPU Offload,就会觉得这些技术简直是救命稻草。
2. ZeRO-3参数分区:把一份完整模型拆到多张卡上
2.1 DDP的重复存储,是一种巨大的浪费
传统分布式训练使用DDP(Distributed Data Parallel)时,每一张卡都会保存一份完整的模型参数、梯度和优化器状态。8张24G显卡,显存总量一共192G,看起来很宽裕,但由于每份数据都重复了8次,实际能训练的模型规模只能以“单张卡能装下”为上限。
换句话说,多卡并行在这个模式下只是提高了训练速度,没有扩大可训练模型的尺寸。集群的显存越大,浪费越严重。
ZeRO的核心思想正是解决这个重复问题。它的全称是Zero Redundancy Optimizer,直译过来是“零冗余优化器”,意思是把那些在不同卡上重复存储的内容全部去掉,让整个集群的显存真正变成一个统一的存储池。
2.2 从ZeRO-1到ZeRO-3:拆的东西越来越多
ZeRO三个阶段可以理解成逐级拆解:
- ZeRO-1:只对优化器状态做分区,每张卡只维护自己负责的那部分优化器状态,参数和梯度仍然完整复制。
- ZeRO-2:在ZeRO-1基础上,把梯度也做分区,每张卡只需要在自己负责的参数上做归约。
- ZeRO-3:把模型参数也一起分区,每张卡只持有总体参数的1/N。
我用一个7B全参训练的例子做了个简单表格,不含激活值的情况下会非常直观:
| 方案 | 参数 | 梯度 | Adam状态 | 每卡显存估算 |
|---|---|---|---|---|
| 朴素DDP | 14GB(每卡完整) | 14GB(每卡完整) | 84GB(每卡完整) | 112GB |
| ZeRO-1 | 14GB(每卡完整) | 14GB(每卡完整) | 84GB/N | 28GB |
| ZeRO-2 | 14GB(每卡完整) | 14GB/N | 84GB/N | 14GB |
| ZeRO-3 | 14GB/N | 14GB/N | 84GB/N | 112GB/N |
8张卡跑ZeRO-3,每张卡理论上只需要14GB左右的显存来放参数、梯度和Adam状态。这就是参数分区最直接的收益:多卡显存才能真正叠加起来训练大模型。
2.3 参数分区后的All-Gather:通信开销是真正代价
ZeRO-3不是把参数分好区就完事了。训练过程中,每一层前向计算时都需要完整的模型参数,所以每张卡必须通过All-Gather操作,把其他卡上属于这层的参数临时收集过来。前向算完,这个完整副本又会被丢弃。
反向传播时也是一样,要重新聚合参数算梯度,然后再用Reduce-Scatter把该层梯度分片归并到负责它的那张卡上。
也就是说,参数分区省下了显存,但把压力转嫁到了通信网络上。通信走的是NVLink、InfiniBand还是千兆以太网,训练速度会差出好几倍。如果显存省下来了,结果每天卡在多机网络传输上,整体时间未必划算。
DeepSpeed里有个参数叫stage3_param_persistence_threshold,它就是用来控制哪些小参数可以常驻显存不参与All-Gather。像LayerNorm的权重、bias这类非常小的参数,反复通信完全是浪费,设置一个合理阈值让它们留在显存里,是日常调优的重要手段。
2.4 什么场景才适合ZeRO-3
ZeRO-3最香的场景有两个。一是你要训的模型超过单卡显存,但手上有好几张卡可以利用;二是多卡之间的通信带宽足够高,比如同机NVLink互联,或者有高性能集群网络。
反过来,如果你只有一张显卡,ZeRO-3单独使用基本救不了场,因为没有别的卡可以分担参数,分区反而只会增加无谓的通信开销。这种情况下的主力技术应该是CPU Offload。
另外,哪怕你手里显存很充裕,比如有人问我有800G显存能跑什么模型,我的答案依然是:大模型的单卡存储和计算都有物理上限,哪怕显存很多,遇到千亿参数模型同样要做分区和调度。显存优化的目标不是“堆料”,而是让每一份资源都被有效利用。
3. CPU Offload:显存放不下,内存来凑
3.1 ZeRO-Offload到底把什么搬到了内存
CPU Offload的思路一句话就能讲清楚:显存放不下的东西,挪到主机内存去放。
普通PC的内存动辄32G、64G,比显存便宜得多,容量也大得多。ZeRO-Offload在实现上,通常会把Adam优化器状态、梯度放在CPU内存中,GPU只保留正在计算的参数和激活值。每步训练时,GPU负责前向和反向,然后把梯度拷贝到CPU端,CPU执行Adam更新,再把更新后的参数传回GPU。
这个过程走PCIe总线。很多人担心CPU算力不够,实际上CPU做优化器更新绰绰有余,真正的瓶颈在于PCIe带宽和内存带宽。
3.2 什么时候开启Offload,什么时候别开
我自己的判断标准很简单:
- 显存缺口在30%以内,优先调整batch size、打开梯度累积、使用gradient checkpointing解决,不值得动用Offload。
- 显存缺口很大,甚至模型本身都加载不进去,Offload就是必选项。
以单张RTX 3080 10G为例,想微调7B模型,不开Offload想都不用想。开启ZeRO-3加CPU Offload之后,GPU上只保留正在计算的一小块参数和激活值,显存占用能压到5到6GB,训练速度虽然慢了不少,但至少能跑。
我自己在这组配置下实测过:每秒训练样本数从无Offload方案的5个左右掉到1到2个,但换来的是把“完全跑不动”变成“挂着机慢慢跑完”。对于个人开发者来说,时间换空间,很多时候值。
3.3 NVMe Offload:最后的保险绳
如果CPU内存也紧张,DeepSpeed还支持把一部分参数Offload到NVMe SSD上。这个做法的本质是:显存放不下放内存,内存放不下放硬盘。
听起来很美好,但NVMe的读写延迟和带宽远不如内存,训练速度会非常难看。我的态度是:NVMe Offload只适合应急,或者跑那种对时间完全不敏感的离线任务,不适合作为常态训练方案。
如果你已经要从NVMe里搬参数来训练,大概率是模型规模严重超出了当前硬件能力,这时候与其硬扛,不如换个更小的基座模型,或者把数据量缩减。
3.4 影响训练速度的硬件协作细节
开Offload之后速度慢,很多时候不是显卡不够,而是数据搬运路径太“绕”。有几个细节是必须注意的:
- pinned memory(页锁定内存)能加快CPU和GPU之间的拷贝速度,配置里能开就开。
- contiguous_gradients建议打开,把梯度整理成连续内存块,减少小包传输次数,提升PCIe利用率。
- 梯度累积别省。如果单step数据量太小,通信开销占比会高到让人怀疑人生。
另外,Windows下的驱动和内存管理方式与Linux差异很大,同样的3090跑ZeRO-3加Offload,我实测下来要比Linux慢不少。如果你想真正压榨这套方案,优先用Linux环境,能少踩很多坑。
4. 实操配置:从DeepSpeed一路用到Llama Factory
4.1 DeepSpeed配置里那些关键参数怎么填
如果你用的是DeepSpeed,最核心的就是ds_config.json。我常用的配置骨架长这样:
{ "train_batch_size": 8, "gradient_accumulation_steps": 4, "optimizer": { "type": "AdamW", "params": { "lr": 2e-5 } }, "zero_optimization": { "stage": 3, "offload_optimizer": { "device": "cpu", "pin_memory": true }, "offload_param": { "device": "cpu", "pin_memory": true }, "overlap_comm": true, "contiguous_gradients": true, "reduce_bucket_size": 5e8, "stage3_prefetch_bucket_size": 5e8, "stage3_param_persistence_threshold": 1e5, "stage3_max_live_parameters": 1e9, "stage3_max_reuse_distance": 1e9 } }几个参数简单说明一下:
- stage3_param_persistence_threshold:设置小一点,可以让更多小参数持久留在显存,减少反复All-Gather的次数,但会增加显存占用,需要反复试。
- stage3_prefetch_bucket_size:控制预取参数包的尺寸,适当调大能让“下一层计算时预取下一层参数”的流水线效果更明显。
- overlap_comm:开启后通信和计算尽量重叠,ZeRO-3场景下收益非常明显。
这套配置我实测下来,显存占用能压得很低,代价是训练速度下降。所以如果你显存还算宽裕,可以先把offload_optimizer和offload_param关掉,只保留stage 3,观察一下速度变化。
4.2 用Llama Factory快速落地LoRA微调
手动改模型训练脚本很容易踩坑,我自己更常用Llama Factory这类一站式微调工具。它把LoRA、QLoRA、全参微调和DeepSpeed配置都封装好了,基本不需要自己手写DataLoader。
以4bit量化加载7B模型、LoRA微调、再把DeepSpeed的ZeRO-3配置传进去为例:
CUDA_VISIBLE_DEVICES=0 llamafactory-cli train \ --model_name_or_path Qwen/Qwen2.5-7B \ --stage sft \ --finetuning_type lora \ --quantization_bit 4 \ --deepspeed ds_z3_offload.json \ --output_dir ./output这个组合对8G显存非常友好。基座模型4bit量化后大概只占4GB左右,LoRA适配器参数很小,再配合Offload把优化器状态放到CPU,跑起来没问题。
需要注意的是,量化后的LoRA训练仍然需要保留一份bf16权重用于反量化计算,这也是为什么显存再怎么省也有底线,不可能真的无限压缩。
4.3 低显存实战数据:我跑出来的参考值
下面是我在RTX 3080 10G、配32G内存的环境下,微调7B模型的一组对照数据,给大家做个参考:
| 配置 | 显存占用 | 训练速度(样本/秒) | 结论 |
|---|---|---|---|
| 7B全参+无优化 | OOM | - | 16G以下直接放弃 |
| 7B LoRA+4bit+gradient checkpointing | 5-6GB | 约5 | 8G卡可以跑 |
| 同配置+ZeRO-3 CPU Offload | 3-4GB | 约1-2 | 换显存但掉速明显 |
对于9B模型,LoRA微调的估算也差不多。基座权重4bit后大约4.5GB,适配器本身只有几MB到几十MB,激活值用gradient checkpointing控制住,8G显存依然有跑通的可能,只是序列长度要狠心压到512甚至256。
我当时的处理方式是:先以序列长度256、batch size 1把流程跑通,确认loss在下降,再逐步拉长序列、尝试更大batch。一上来就把所有配置拉满,只会得到一个OOM报错。
4.4 训练工具的选择逻辑
不同工具的取舍我总结过几次:
- DeepSpeed ZeRO:最成熟,支持CPU和NVMe Offload,适合要精细控制训练流程的人。
- PyTorch FSDP:PyTorch原生实现,理念和ZeRO-3很像,跟Hugging Face生态整合得很好,二选一即可。
- Unsloth:专门优化LoRA和QLoRA,内存占用低、速度快,适合个人快速实验。但它评估阶段经常把显存吃满,后面会专门讲。
我现在的做法是:快速验证用Unsloth,正式跑训练用DeepSpeed。每个工具都有自己的脾气,没必要硬在一个工具里死磕。
5. 常见问题与排查技巧
5.1 Offload之后速度慢得离谱怎么办
开启了Offload之后,如果训练速度慢到完全不能用,先别急着关,按下面顺序排查:
- 确认pin_memory和contiguous_gradients有没有打开,这两个开关影响数据搬运效率。
- 尝试增大微批大小。如果单step数据量太小,通信开销会占主导,速度会慢得离谱。
- 检查CPU占用率。如果CPU已经被优化器更新打满,可以换用Lion这类更便宜的优化器,或者增大梯度累积步数,减少CPU更新次数。
我遇到过一种情况:为了省显存把batch size压到1,结果Offload后的训练速度比正常配置慢了10倍以上。后来把batch调到8,配合梯度累积,速度立刻回升。小batch不是省显存的最好办法,梯度累积才是。
5.2 序列长度超了模型训练范围会不会乱说话
这个问题和显存话题高度相关。很多低显存用户会把序列长度压得很短,反过来也有人硬把序列长度拉满去“榨干”显存。
但模型的位置编码是有有效范围的。尤其是RoPE这类编码,超出训练长度太多时,注意力分布会变得很奇怪,输出质量明显下降,甚至出现重复和乱说现象。这不是显存不够的问题,而是模型本身的训练范围限制。
如果确实需要训练长文本,应该选择原生支持长上下文的基座模型,或者在训练时使用位置编码扩展技术。把超长文档硬塞给一个只支持4K上下文的模型,结果大概率是垃圾进垃圾出。
5.3 评估阶段显存瞬间拉满怎么破
我自己用Unsloth训练LoRA时,碰到过最头疼的问题就是评估阶段显存直接拉满。训练时显存明明很稳,一开评估就爆。
原因是评估阶段经常切到生成模式,输入batch、输出tokens的KV Cache、还有多个序列的激活值会同时涌进来,显存需求瞬间拉升。
我的解决方案是:
- 训练时把evaluation_strategy设成no或者很大的间隔,等训练结束再单独跑评估脚本。
- 如果必须边训边评估,把评估时的max_new_tokens压到32或64,batch设为1,关闭采样。
- 更彻底的方式是评估阶段用另一个进程加载checkpoint,完全隔离显存。
5.4 那些容易被忽略的显存杀手
显存调试像做侦探,很多问题不是配置不对,而是从一开始就搞错了状态。我踩过的坑包括:
- from_pretrained时不小心加载了fp32权重,显存直接翻倍,还没开始训练就爆了。
- 把梯度累积理解成“等效于更大的batch size”,实际上它只是降低更新频率,单个step的显存占用并不会下降。
- 开启梯度检查点后训练时间会多出约30%,但它省显存是真的有效,不要觉得是bug。
- 换卡之后忘记清掉旧的环境变量,导致CUDA_VISIBLE_DEVICES指错显卡,显存占用数据全乱。
- 推理侧的显存管理同样有讲究,像ComfyUI社区里的Dynamic VRAM、显存清理节点,本质也是按需分配和手动释放,这个思路在训练里同样值得学习。
- 如果一天到晚莫名其妙爆显存,先用nvidia-smi看占用,再用显存检测工具比如mats确认是不是硬件本身有问题。
5.5 显存很大是不是就一劳永逸了
有人问我“我有800G显存,可以部署哪些大模型”。800G确实很宽裕,但问题不全是“多少显存能跑”,而是“你的通信带宽和训练流程能不能撑起这个规模”。
哪怕是800G显存,要训练千亿参数模型,一样需要做参数分区和显存调度。这类模型在bf16下光权重就是几百GB,单卡存储和计算都有物理上限,不是容量够了就万事大吉。
反过来,小显存也不等于不能训练。ZeRO-3加CPU Offload这套组合,就是为了让普通玩家也能在大模型的门口挤进去。推理部署层面的工具比如Ollama,也有自己的显存控制参数,但那是运行时的缓存调度逻辑,和训练时的优化器状态管理是两码事,别混为一谈。
最后分享一点个人体会。我一开始看到ZeRO-3和CPU Offload这些名词,以为只是“省显存的开关”,实际用下来才发现,真正难的是理解数据搬运的路径。显卡负责计算,内存负责存储,PCIe是两者之间的桥梁。ZeRO-3做的是把存储从“每卡都有”改成“全局共享”,CPU Offload做的是把存储从“显存”延伸到“内存”。训练大模型本质上就是一场硬件协奏曲,算、存、传三件事必须协调好。如果你也在为显存发愁,我建议第一步先算清楚自己缺多少,再决定是调batch、开梯度检查点,还是上ZeRO-3和Offload。别一上来就把所有开关都打开,那样往往会更慢。希望这篇记录能让你少走点弯路。