
最近有同事来找我说机器上明明有32G显存跑Llama-3-8B的LoRA微调还是一直CUDA OOM。他把模型加载上去看nvidia-smi显存才用了不到6GB可训练器一启动就报错。这个场景我见过太多次了很多人对LoRA有一个误解觉得“7B模型量化一下才4GB32G怎么着都够了吧”。但训练时的显存账单远不止模型权重这一项梯度、优化器状态、激活值、临时缓冲区、CUDA上下文个个都是吞显存的大户。这篇文章就围绕32GB这块短板把LoRA和QLoRA微调时的显存构成、优化手段、实战参数和排查路径一次讲清楚。文章不会讲太多理论重点是“算得清账、控得住峰值、看得懂报错、调得动参数”适合那些手里正好有30系/40系或V100/A6000这类单卡想本地微调7B左右模型的朋友。看完之后你可以自己评估手头模型该跑什么配置也能在OOM时快速判断是权重问题、激活值问题还是碎片问题不再瞎调batch size。1. 显存都去哪了LoRA/QLoRA的显存账本1.1 LoRA 省掉的不是权重而是梯度与优化器状态先纠正一个常见误解LoRA并不会减少模型权重本身占用的显存。原始权重仍然要完整地加载进显存用于前向和反向计算除非你同时用了量化QLoRA。LoRA真正省的是梯度和优化器状态。如果要训练全量模型7B模型的每个参数都需要保存梯度还需要为AdamW维护一阶动量exp_avg和二阶动量exp_avg_sq。按fp32算每个参数至少占4字节梯度 8字节优化器状态折算下来仅这两项就是84GB。加上fp16权重14GB32GB连个零头都不够。而LoRA把可训练参数限制在新增的低秩矩阵上假设7B模型只训练约20M参数那梯度和优化器状态从GB级别直接掉到几十MB级别这才是LoRA最大的价值。所以LoRA解决的是“训练参数太多导致梯度/优化器爆显存”的问题不是“模型权重太大放不下”的问题。想真正把权重显存压下来必须上量化这就轮到QLoRA了。1.2 一张32GB显存的预算表我习惯在训练前先估算一遍显存把任务拆成四块权重、梯度/优化器状态、激活值、临时开销。以7B模型QLoRA微调为例项目计算方式7B模型估算占用4bit量化权重7e9 × 0.5字节 少量scale约3.5~4GBLoRA参数rank8挂4个模块约20M参数量约0.1GBLoRA梯度优化器状态20M × (4 8) 字节约0.3GB激活值batch1, seq2048与hidden size和层数强相关约4~8GBCUDA上下文、临时buffer框架固定开销约1~2GB这样初步估算在10~15GB左右32GB确实没问题。但如果序列长度拉到4096batch设为2激活值马上会翻到15GB以上如果用了fp32优化器或全量的AdamW预算表又会变脸。这就是为什么很多人上来OOM不是模型太大而是几个开关没有打开。1.3 为什么省了这么多还 OOM最隐蔽的大头是激活值。模型前向计算时每一层都要保存中间输出用于反向传播层数越深、序列越长、batch越大激活值的占用越夸张。在7B模型上单条batch且seq1024时激活值可能只要2GB但seq2048时可能会到5GB再叠加attention矩阵的中间项峰值增长非常快。另一个常见问题是padding浪费。数据加载器在batch内按最长样本补零如果样本长度差异大显存里塞了大量无效token而且这些token同样会参与矩阵运算、同样产生激活值。很多新手把max_seq_length设成模型支持的4096实际数据平均才800个token结果激活值白白多算好几倍。OOM不是凭空出现几乎每一条都有对应的账可以查。2. 动手前先做的显存体检2.1 别只盯着 nvidia-smi训练时大家习惯开个watch -n 1 nvidia-smi盯着看但这里有个坑nvidia-smi显示的是当前进程占用的显存总量包括PyTorch的caching allocator提前缓存下来的块。PyTorch为了减少cudaFree的调用会把释放的显存留在自己的缓存池里所以显存使用率高不一定是真不够使用率低也不代表平安因为可能只是碎片化导致分配不到连续空间。更准确的做法是看PyTorch自己的统计它知道哪些已经分配给了张量哪些只是缓存。举例来说torch.cuda.memory_reserved()是缓存池大小torch.cuda.memory_allocated()是实际张量占用。两者的差就是缓存但没在用的部分。如果差值很大可以通过empty_cache()释放缓存但这只影响后续分配不影响当前峰值。2.2 用 PyTorch 拿到真实峰值在训练脚本里我最常用的一个组合是torch.cuda.reset_peak_memory_stats() # 训练代码 # ... peak_memory torch.cuda.max_memory_allocated() print(fPeak memory allocated: {peak_memory / 1024**3:.2f} GiB) print(torch.cuda.memory_summary())max_memory_allocated()返回的是整个进程至今出现的最高实际张量占用这比nvidia-smi能更真实地反映训练峰值。memory_summary()会输出一段很详细的报告包括每个分配段的地址、大小、状态适合看到底有多少碎片、哪些块被缓存占用。如果用Hugging Face Trainer可以在回调里做同样的事情from transformers import TrainerCallback class MemCallback(TrainerCallback): def on_step_end(self, args, state, control, **kwargs): if state.global_step % 10 0: print(torch.cuda.max_memory_allocated() / 1024**3)这样每训练10步打一次峰值能清楚看到显存是稳定、上升还是突然跳变。2.3 从 OOM 报错反推占用源头OOM报错其实透露了很多信息。比如CUDA out of memory. Tried to allocate 4.00 GiB (GPU 0; 31.7 GiB total capacity; 24.1 GiB already allocated; 5.2 GiB free; 26.3 GiB reserved in total by PyTorch)说明当前需要一次分配4GB但只剩5.2GB。如果剩余空间小于请求量但有连续碎片问题往往调用expandable_segments或减小batch即可。RuntimeError: CUDA error: device-side assert triggered这种不是显存不足而是越界访问和OOM没关系别混为一谈。尖峰位置也可能从堆栈里看出来如果OOM发生在self-attention的torch.bmm或flash_attn_varlen_kernel说明激活值或attention矩阵超预算。如果发生在optimizer.step()说明优化器状态分配失败优先检查优化器类型和可训练参数量。把OOM当成一个日志来读比乱调batch有效得多。3. 32GB 微调的关键优化开关3.1 bf16/fp16 与 8bit 优化器混合精度不只是“省一半显存”那么简单它还会影响激活值精度和训练稳定性。建议优先开bf16因为它和fp32一样有8位指数对小学习率场景更稳不会像fp16那样容易出现梯度溢出。在transformers的TrainingArguments里直接写TrainingArguments( bf16True, fp16False, )如果显卡比较老不支持bf16再用fp16同时必要时加上fp16_opt_levelO1之类O1是apex的用法transformers直接fp16True即可。优化器方面强烈建议用paged_adamw_8bit。它来自bitsandbytes核心是把优化器状态保存成8bit整型同时用分页机制把部分状态换到CPU内存。对LoRA这种可训练参数很少的场景8bit优化器状态省不了几GB但它能把优化器状态从fp32的8字节/参数降到2字节/参数也减少了OOM风险。实际上对于LoRA如果你坚持用标准AdamW优化器状态是几GB级别可能也不会爆但8bit是更稳的选择。3.2 梯度检查点用时间换空间的王牌激活值是32GB训练中的一个主要变量梯度检查点gradient checkpointing是压激活值最直接的手段。它的原理是正常训练时每一层激活值都保存用于反向计算梯度开启后只保留少数中间层的输出其他层的前向激活不保存反向时再临时重新算一遍。这样把存储开销从“每个batch峰值”摊到“计算时间”上训练变慢30%~50%但显存可能掉接近一半。在transformers中一行开启TrainingArguments( gradient_checkpointingTrue, )如果直接用PEFT可以model prepare_model_for_kbit_training(model, use_gradient_checkpointingTrue)还有一个细节开启gradient_checkpointing后model.config.use_cache必须设为False否则会报兼容性问题而且训练阶段本就不需要KV cache。Trainer一般会自动处理但手写训练循环时容易漏。3.3 QLoRA 量化参数的正确姿势用QLoRA时BitsAndBytesConfig里的参数决定你真省了多少钱。下面是我常用的配置import torch from transformers import BitsAndBytesConfig bnb_config BitsAndBytesConfig( load_in_4bitTrue, bnb_4bit_quant_typenf4, bnb_4bit_use_double_quantTrue, bnb_4bit_compute_dtypetorch.bfloat16, )bnb_4bit_quant_typenf4表示用NF4类型相比普通4bit整数类型它对近似正态分布的权重保留精度更好。QLoRA论文在大多数任务上用的就是NF4。bnb_4bit_use_double_quantTrue表示对量化常数再做一次8bit量化能再省一部分显存。7B模型大概能省0.5GB左右。bnb_4bit_compute_dtypetorch.bfloat16表示计算时权重从4bit反量化为bf16做矩阵乘这个值和训练精度建议一致。注意load_in_4bitTrue后模型结构里会有许多torch.nn.Linear被替换为bnb.nn.Linear4bit但PEFT在创建LoRA层时只会绑定在有目标的Linear上不会给量化层带来额外显存压力。3.4 LoRA 超参如何再抠一点显存LoRA的显存开销和可训练参数量成正比所以降低r是有效的。很多人一上来就设r16甚至r32在7B模型上可训练参数会从20M涨到80M状态显存也会翻倍。实际效果不一定比r8好尤其数据量不大时r8已经能提供足够的表达能力。另一个容易被忽略的是target_modules。如果只挂[q_proj, v_proj]参数量会明显减少但表达能力可能受限如果挂[q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]效果通常更好但参数量和显存都上去了。我的建议是先从4个基础投影开始验证效果后再决定要不要扩展到全连接层。from peft import LoraConfig lora_config LoraConfig( r8, lora_alpha16, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeCAUSAL_LM, )biasnone也是减少可训练参数的一种方式因为模型bias数量不多但全设成all也会增加一点显存和过拟合风险。4. 实战配置7B 模型 QLoRA 微调全过程4.1 可直接修改的脚本骨架下面这个配置我已经在多个7B模型上验证过单卡32GB、batch1、序列长度2048时显存峰值大约在13GB左右离OOM还很远甚至还能往上涨一点batch。代码基于transformers和peft注释里写了每个参数为什么这么设。import os os.environ[PYTORCH_CUDA_ALLOC_CONF] expandable_segments:True import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments, Trainer, ) from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training model_path Qwen/Qwen2.5-7B-Instruct bnb_config BitsAndBytesConfig( load_in_4bitTrue, bnb_4bit_quant_typenf4, bnb_4bit_use_double_quantTrue, bnb_4bit_compute_dtypetorch.bfloat16, ) model AutoModelForCausalLM.from_pretrained( model_path, quantization_configbnb_config, device_mapauto, torch_dtypetorch.bfloat16, attn_implementationflash_attention_2, # 支持的话优先开 ) model.config.use_cache False model prepare_model_for_kbit_training(model, use_gradient_checkpointingTrue) lora_config LoraConfig( r8, lora_alpha16, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeCAUSAL_LM, ) model get_peft_model(model, lora_config) model.print_trainable_parameters() training_args TrainingArguments( output_dir./lora_qwen7b, per_device_train_batch_size1, gradient_accumulation_steps8, gradient_checkpointingTrue, optimpaged_adamw_8bit, learning_rate2e-4, bf16True, max_length2048, logging_steps10, save_steps200, num_train_epochs1, ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset, # 需要提前构造好 ) trainer.train()per_device_train_batch_size1配合gradient_accumulation_steps8可以得到相当于batch size8的更新效果同时不会突然把激活值翻8倍。attn_implementationflash_attention_2也很关键FlashAttention能显著降低attention部分的内存和计算但在部分老卡上可能不支持需要装对应的库。4.2 训练过程中的显存变化我在一台309024GB和一台V10032GB上分别测过一组数据。V100不支持bf16只测fp163090支持bf16但不支持FlashAttention的某些版本。整体规律很稳定阶段显存占用24G卡bf16batch1, seq2048说明加载4bit权重4.2GB包含量化参数创建LoRA层后4.6GBLoRA参数极少单步前向9.8GB激活值开始增长反向和梯度更新13.5GB峰值出现在optimizer step前后若开启gradient checkpointing7.8GB左右激活值下降明显32GB卡上我甚至可以把batch提到2、seq长度保持在2048显存大约到19GB仍然安全。所以32G没有想象中紧张前提是开关都开对。4.3 遇到 OOM 时的降级顺序如果按上面的脚本仍然OOM我一般按这个顺序逐项放宽/收紧先降序列长度max_length2048改成1536或1024。显存对sequence length通常是线性起步降一步省一大块。再降batchper_device_train_batch_size1本来就是底确保没有设成2。关闭flash_attention_2因为某些环境下它反而因为反量化产生额外峰值但一般不建议关。检查数据集的padding策略打开group_by_lengthTrue减少padding浪费。如果还不行考虑把r降到4或者从target_modules里去掉某些projection。最后再考虑换更小的基座模型比如3B或1.5B。很多人的OOM其实是前四步就能解决的根本轮不到换模型。5. OOM 排查实录与避坑清单5.1 请求4GB但可用6GB的假 OOM有类报错特别迷惑错误信息说“Tried to allocate 4.00 GiB ... 5.2 GiB free”理论上执行者的可用显存还剩5.2GB怎么连4GB都分配不了原因多半是CUDA缓存分配器只能在预留的空闲segment里分配连续块而这时显存已经被切成一堆碎片找不到单个4GB的连续空间。PyTorch 2.x之后提供了一个缓解方案export PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True这个设置会让CUDA缓存使用可扩展段减少碎片。实际效果取决于显卡驱动和PyTorch版本但值得作为第一手环境变量加上。另外max_split_size_mb参数也可以调节缓存块最大切割大小不过在expandable_segments面前这个参数的历史权重已经没那么大了。5.2 padding 和序列长度带来的隐性浪费训练数据长度不一如果每个batch都按最长样本padding短样本的显存全被浪费。例如一个batch里有两条数据长度分别是512和4000那所有样本都会补到4000的长度短样本的3488个token全是无效填充但一样参与矩阵计算、一样产生激活值。这可能是OOM的一个巨大来源。解决办法在DataCollator里设置paddinglongest确实会有这个问题但这是必要的关键是用group_by_lengthTrue让Trainer把长度相近的样本凑到一个batch减少最长和最短的差距。如果数据集长度分布太散可以自定义一个分段排序的dataset或者直接用packing将短样本拼接成长样本。Packing能最大化利用序列长度但实现稍复杂需要处理attention mask和token type。truncation要设不能为了保留信息让长文本全部进入序列长度一高激活值直接爆。5.3 优化器状态为什么还是会占满显存理论上LoRA只更新少量参数优化器状态应该很小。但有些情况下你会看到实际占用还是很大。几个常见原因忘了调用prepare_model_for_kbit_training导致原权重没有被冻结优化器中包含了全部参数状态。optim参数还是adamw_torch它最少保留fp32的exp_avg和exp_avg_sq虽然只针对LoRA参数但如果你把r设得很大、target_modules很多可训练参数也会到百M级优化器状态跟着到GB级。有部分参数被设置为requires_gradTrue比如一些embedding或者layer norm。LoRA默认不更新这些但如果你手动改了状态就会增加。建议打印model.print_trainable_parameters()如果trainable params不是“几M”级别而是几百M这就解释了为什么显存不够。5.4 显存一直上涨不释放的排查有时训练刚开始显存正常几万步后逐步爬升直到OOM。这种长期增长通常是代码里把某些张量缓存在了列表或dict里比如为了写日志而保存了每个step的loss、针对可视化保存了中间的hidden state、或者data collator重复生成大数组没有释放。排查方法是定期打印max_memory_allocated()和memory_reserved()如果reserved在涨allocated也在涨但不回落注意清掉不再用的引用。torch.cuda.empty_cache()只能释放空闲的缓存块如果确实有张量还引用着内存不会真正还回去。另外eval阶段如果也开predict_with_generateTrue模型会在验证时做生成max_new_tokens设置过大会瞬间吃掉大量显存并在每个epoch累积。建议eval时单独用小batch或者把generation_max_length调低。6. 极限场景还能怎么救6.1 CPU Offload 与虚拟内存的误区有些朋友一看OOM就想把部分层放到CPU用device_mapauto时模型确实会自动把放不下的层offload到内存但训练过程会频繁进行CPU-GPU数据搬运速度能慢到让人怀疑人生。如果只在推理场景CPU offload是可选方案训练场景里除非你只微调LoRA并且offload的是底座的量化权重否则我建议谨慎使用。还有一个很常见的误区是把Linux swap当“虚拟显存”。swap本身是内存的扩展不是显存的扩展CUDA驱动不会自动把显存换到swap里。强行调大swap的唯一结果就是整个系统卡死。Windows的“共享GPU内存”同理它把系统内存的一部分当成显存扩展可用但性能极差训练任务用它只会让OOM变成死机。6.2 多卡或加速库的出路如果真的一块32G扛不住多卡是最直接的出路。但单机多卡也有配置复杂度尤其是之前体验过掉卡、NCCL超时问题的话学习成本并不低。比较轻量的方案是DeepSpeed ZeRO Stage-2它只把优化器状态、梯度分片到多卡配合LoRA也不会太夸张。但这里要提醒LoRA本来参数就少多卡的收益主要体现在batch并行上而不是显存翻倍。另一个值得试的是Unsloth这个库。它把注意力、LoRA算子做了底层融合声称可以提升训练速度并降低显存占用。我在7B模型上实测同样的QLoRA配置Unsloth在相同的batch和seq下确实比原生HF实现更省显存且速度更快。如果你是刚开始接触微调直接用它提供的notebook模板会省很多力气。6.3 工具链选择建议现在微调LoRA/QLoRA的工具有很多列举几个常见的供参考工具显存效率上手难度适用场景Hugging Face Transformers PEFT中等需要自己调开关中等自定义训练逻辑时最灵活LLaMA-Factory较高内置大量优化参数低快速跑通数据实验Axolotl高配置项极多偏高有经验者做实验复现Unsloth高底层kernel优化低追求速度和显存支持模型有限我的观点是如果你只是想验证“这个模型适不适合我的任务”先用LLaMA-Factory最快如果你要写论文或产品化需要精确控制训练流程那就用PEFT自己写训练脚本。别一开始就上Axolotl参数复杂度会把你拖进config文件的泥潭。在32GB显存这个量级上我认为真正的瓶颈不是显存大小而是你能否精确掌握每个环节的消耗。多跑几次实验把memory_summary()和print_trainable_parameters()当成固定动作时间久了自然知道哪个模型该配什么参数。我们拿7B模型做一次完整的QLoRA微调正常优化后峰值也就13GB左右32GB还有很大余力去调大batch或seq len。但如果不去控制激活值不量化权重不选8bit优化器32G就算翻倍也一样会被OOM追着跑。我个人踩坑最多的地方是序列长度和padding每次觉得已经调得很小时总会发现某个data collator又把所有样本pad到了4096。后来我把打印每个batch的input_ids shape写进了日志才彻底改掉这个毛病。如果你也正在被OOM折磨建议先从打印shape开始很多问题比你想的要简单得多。