去年帮人调一个 7B 模型的 LoRA 训练,机器只有 24GB 显存,基础模型加载完还剩不到 10GB,数据一进来直接 OOM,当场卡了一下午。后来把显存占用逐项打出来才发现:哪怕只训 LoRA,基础模型权重、激活值、临时缓冲这几项已经吃得差不多,优化器状态反而是小头。这件事让我养成了一个习惯——训练大语言模型之前,永远先按字节把显存的“账单”算清楚,否则你根本分不清自己是在调参,还是在跟随机 OOM 搏斗。
这篇就把大语言模型训练中的显存去向、常见优化手段、以及我实测过的组合取舍一次讲透。适合刚开始跑大模型训练、被 OOM 反复折磨的读者,也适合已经会用 LoRA 但想搞明白“为什么这么配”的人。内容不涉及具体硬件厂商和框架绑定,思路在所有主流训练方案里基本通用。
1. 显存账本:训练时那几十个 GB 到底被谁吃掉了
很多人的第一反应是“显存都被模型参数吃了”,这句话只说对了一小部分。大模型训练时的显存占用可以分成四大块:模型权重、梯度、优化器状态、前向过程的激活值。前三个相对固定,激活值则随输入大小动态变化,也是最容易被忽视的变量。
1.1 四个主要开销:权重、梯度、优化器状态、激活值
先说模型权重。一个 13B 模型用 FP16/BF16 格式存放,权重本身就占约 26GB(13 × 10^9 × 2 字节)。模型越大、精度越高,这部分越扎实,躲不掉。
再说是梯度。反向传播算出的梯度,尺寸和模型权重一模一样。半精度训练下,13B 模型也要再吃掉约 26GB。有些框架会默认用 FP32 存梯度,那就要更高。这部分的优化空间不大,能改的主要是混合精度和分片策略。
然后是优化器状态,这是全量微调里最夸张的一块。以最常用的 Adam 优化器为例,它需要每个参数保存两份动量(一阶和二阶),为了数值稳定还要保留一份 FP32 的“主权重副本”。三个 FP32 副本算下来,每个参数要 12 字节。13B 模型光优化器状态就是 156GB,比你想象中整个模型的权重还大好几倍。很多人第一次看到这个数字都会愣住,但这就是真实情况:全量微调时,优化器状态才是显存第一大用户。
最后是激活值。所谓激活值,就是前向计算时每一层产生的中间张量,反向求梯度时需要用到,所以不能全部丢弃。它的大小取决于序列长度、batch size、隐藏层维度、层数,和模型参数量没有直接关系。同样一个模型,你把序列长度从 2048 提到 8192,激活值可能翻几倍,而模型参数一点没变。
1.2 拿 7B 模型算一笔账,心里就有数了
我们按社区常用的经验值来算:全量微调 + Adam 优化器 + 半精度混合训练,每个参数大约需要 16 字节(权重 2 字节 + 梯度 2 字节 + 优化器状态 12 字节)。那么一个 7B 模型:
| 项目 | 计算公式 | 显存占用 |
|---|---|---|
| 模型权重(BF16/FP16) | 7B × 2 字节 | 14 GB |
| 梯度(BF16/FP16) | 7B × 2 字节 | 14 GB |
| Adam 优化器状态(FP32 主权重 + 两个动量) | 7B × 12 字节 | 84 GB |
| 静态部分小计 | 7B × 16 字节 | 112 GB |
| 激活值(动态,取决于序列和 batch) | 前向中间结果 | 通常在 10~30 GB 甚至更高 |
激活值这块很多人会漏算。我之前用普通 32 层、4096 维度的模型,把 batch 开到 4、序列长度拉到 2048,朴素公式算出来激活值只有 2GB 左右,但实际因为注意力矩阵、层归一化临时缓冲、算子中间结果等,经常是朴素估算的好几倍。所以稳妥起见,给自己的激活值预留 10~20GB,遇到超长序列还得更多。
这组数字说明什么?一个 7B 模型如果做全量微调,光静态部分就需要 112GB 显存。一张 80GB 的卡根本装不下,就算勉强塞进去,激活值一进来还是爆。所以全量微调小模型也需要多卡分片,或者干脆放弃全量微调,走参数高效微调路线。
2. 减小显存占用的几条主路
知道了钱花在哪,接下来就是对症下药。我按“从便宜到贵”的顺序讲:混合精度几乎零成本,梯度检查点和 ZeRO 要权衡速度,LoRA 则改变训练范式。
2.1 混合精度:先把这个开关打开再说
混合精度训练不是新概念,本质是让模型权重和梯度使用 FP16 或 BF16,减少一半字节数,同时保留 FP32 的优化器状态保证更新精度。FP16 和 BF16 的区别值得记一下:FP16 动态范围小,容易出现梯度溢出;BF16 和 FP32 的动态范围一致,更稳,所以现在大模型训练基本默认 BF16。
开启混合精度后,静态显存里“权重 + 梯度”这两块从每参数 4 字节降到了 8 字节?不对,准确说权重从 4 字节降到 2 字节、梯度从 4 字节降到 2 字节,光这两项就能省一部分。但要注意,混合精度对总账的影响没有想象中那么夸张,因为优化器状态仍是 FP32 的 12 字节。它的真正价值在于降低计算量、减少临时缓冲和激活值开销,同时让单卡能塞进更大的 batch。
实操层面,不同训练框架的开关位置不同,但配置逻辑是一致的:训练脚本里开启 fp16 或 bf16,优化器保持 FP32,模型权重以半精度参与前向反向。如果用的是 FP16,一般还需要开损失缩放,防止梯度下溢出;BF16 不需要。有个容易踩的坑:LayerNorm 这类对精度敏感的层,部分实现会强制保留高精度,这是正常的,别为了省显存去手动把所有层都转成半精度。
2.2 梯度检查点:用算力换显存,性价比极高
梯度检查点的原理一句话就能讲明白:前向传播时不再保存每一层的激活值,只存少量“检查点”;反向传播需要用到某层激活值时,再临时从检查点重新算一遍。相当于拿额外的计算量换显存,省下的激活值通常非常可观。
很多框架里开启梯度检查点就是一个开关的事,同时要把推理缓存关掉(训练时不需要缓存历史 token 的 key/value)。代价是训练时间变长,实测常见范围在 15%~30% 左右,具体取决于模型结构和卡型。我的经验是:如果你的 batch size 已经小到影响收敛速度了,优先开梯度检查点,比强行降 batch 要划算得多。尤其是在长序列任务里,激活值会飙得很高,这个开关几乎是必开的。
有读者担心“重算一遍不会导致梯度错吗”。放心,重算的是前向值,反向路径和正常训练完全一致,只是多了重复计算,精度不会变。这一点和激活值精度压缩不是一回事。
2.3 状态分片:ZeRO 系列到底切了什么
多卡数据并行训练时,一个很容易被忽略的浪费是:每张卡都存了一份完整的模型参数、梯度和优化器状态。明明四张卡一起训练,每张卡的开销却和单卡差不多,只是 batch 变大了。ZeRO 的核心思路就是“别重复存,把状态切碎分到各卡”。
ZeRO 有三个阶段。第一阶段只切优化器状态,第二阶段把梯度也切了,第三阶段连模型参数都切了。切的越多,单卡显存越低,但卡与卡之间的通信量也越大。
拿上面的 7B 模型举例,静态部分 112GB,四卡训练:
| 方案 | 每卡静态显存估算(不含激活) | 说明 |
|---|---|---|
| 普通数据并行 | 112 GB | 每卡存全部状态,没有任何分片 |
| ZeRO 阶段一 | 14 + 14 + 21 = 49 GB | 只切优化器状态 |
| ZeRO 阶段二 | 14 + 3.5 + 21 = 38.5 GB | 优化器和梯度各分四份 |
| ZeRO 阶段三 | 28 GB | 权重、梯度、优化器全部分片 |
所以四张 80GB 卡全量微调 7B,用 ZeRO 阶段三完全够,阶段二配梯度检查点也有戏。但阶段三有个副作用:每步训练都要把当前层的权重从各卡 gather 回来,通信开销明显增加,小模型、小 batch 时甚至可能比阶段二慢。不是越高级越好,够用就行。
ZeRO 还有一个延伸叫卸载(offload),把优化器状态放到 CPU 内存,甚至放到 NVMe 硬盘上。显存确实能降到很低,但速度会进一步下降,适合“机器配置一般但必须跑起来”的场景。我自己的取舍是:能上多卡就优先多卡,卸载是最后手段。
2.4 LoRA 与 QLoRA:不动那座大山的显存
LoRA 的思路是冻结原模型权重,只训练插入的少量低秩矩阵。低秩矩阵参数量极少,所以优化器状态和梯度只跟这几个小矩阵有关,而不是跟几十亿的参数有关。模型权重本身仍然占显存,但不需要为它计算梯度或维护 Adam 状态。
QLoRA 更进一步,把基础模型量化到 4bit,再配合分页优化器,显存占用进一步压缩。一个 7B 模型 4bit 量化后只有约 3.5GB,LoRA 训练参数又很小,24GB 的消费级卡也能跑起来。这也是目前个人开发者玩大模型微调最常用的路线。
选 LoRA 还是全量微调,取决于两个问题:一是你的显存够不够全量,二是任务是否需要。如果只是做指令微调、风格迁移、领域适配,LoRA 通常够用,而且可以同时冻结多个副本、快速切换任务。如果想从预训练权重继续大语料预训练,或者做非常深度的领域改造,全量微调效果往往更好,但这也意味着你大概率需要多卡分片。
3. 实操:先算账,再选组合,最后调参数
这一节给一套可直接上手的流程。每次开训前花五分钟算清楚静态显存,再决定用哪个方案,能省掉后面大量的 OOM 排查时间。
3.1 看手里的显存,决定训练路线
假设目标模型是 7B,手里有不同的卡,我的选择通常是这样的:
| 硬件条件 | 推荐方案 | 理由 |
|---|---|---|
| 单卡 24GB | QLoRA + BF16 + 梯度检查点 | 全量根本装不下,只能走参数高效微调 |
| 单卡 48GB | LoRA 或小 batch 全量(配合梯度检查点) | 7B 全量静态 112GB 不够,但 LoRA 很宽裕 |
| 四卡 80GB | ZeRO 阶段二或三 + 混合精度 | 全量微调可行,注意通信开销 |
| 八卡 80GB | ZeRO 阶段三,必要时加并行 | 空间充足,主要优化训练速度 |
这里有个提醒:显存只是门槛,不是越高越好。如果单卡 24GB 非要上全量微调,就算通过 offload 把模型塞进去了,训练速度也会慢到怀疑人生。相反,用 QLoRA 可能几小时就能出一个效果不错的结果。项目初期先跑通,再谈全量。
3.2 一份可以留作底稿的配置参考
下面是一个典型的分布式训练优化器配置,我按伪代码的意思写出来,字段名到具体框架里会有些差异,但含义通用:
{ "train_micro_batch_size_per_gpu": 2, "gradient_accumulation_steps": 8, "bf16": { "enabled": true }, "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu" } }, "gradient_clipping": 1.0 }解释一下每个字段的意义。
单卡 batch 设成 2,是因为很多模型在 batch 大于 4 时激活值会突然暴涨。梯度累积步数设 8,目的是让“等效 batch size”变成 16,保证收敛稳定性。但要注意,梯度累积不省显存,它只是让显卡算完一个 micro batch 后先攒着梯度,等攒够若干步再更新一次权重。所以它解决不了 OOM,真正解决 OOM 的是调小 batch,或者开梯度检查点。
bf16 开启后,模型参与计算时用半精度,优化器更新时用 FP32。开启 ZeRO 阶段二,优化器状态和梯度分片到多卡,每张卡只存一部分。offload_optimizer 这个选项是把优化器状态进一步放到 CPU 内存,显存压力更小,但每一步更新时 CPU 和 GPU 之间要搬数据,训练速度会受影响。如果卡间通信资源紧张,可以考虑关掉 offload 换完整 ZeRO 状态。
gradient_clipping 设 1.0,是为了防止梯度爆炸。这个和大模型训练的数值稳定性息息相关,尤其在混合精度下,梯度突然变大很容易让 loss 变成 NaN。别为了省事关掉。
3.3 显存监控的正确姿势
很多人开了训练后习惯盯着任务管理器或 nvidia-smi 看,看到显存利用率很高就以为马上要爆。实际上,很多框架的显存分配器会预先把一大块显存缓存起来,后续张量直接在这块缓存里分配。所以 nvidia-smi 显示的高占用不代表真实峰值,可能只是预分配。
正确的做法是:在训练脚本里打印框架报告的预留显存(reserved)和峰值显存(max reserved),用这两个数值做对比。比如下面的伪代码思路:
# 伪代码,具体 API 以你用的框架为准 before = memory_api.reserved() # 跑一个完整训练 step after = memory_api.max_reserved() print("峰值显存:", after, "当前预留显存:", after - before)另外,用小 batch 先跑几步,记录每一步的耗时和峰值显存,再逐步调大 batch,这种方法比一次开到最大再被 OOM 教训一顿要高效得多。我每次都会做这样的“显存扫描”:用不同 batch size 各跑两三个 step,画出显存和速度的曲线,然后选一个刚好不爆、速度也不慢的点。
4. 常见显存问题与排查技巧实录
4.1 OOM 的几种典型场景
先整理一个排查表,遇到问题可以先对照一下:
| 症状 | 常见原因 | 优先动作 |
|---|---|---|
| 加载模型时就 OOM | 模型权重 + 量化/半精度副本超过了显存 | 换量化权重、换小模型、加大虚拟内存 |
| 一到反向传播就爆 | 激活值或梯度占用过高 | 开梯度检查点、减小单卡 batch、开混合精度 |
| 训练中途突然 OOM | 数据加载器缓存、评估阶段 or 临时张量堆积 | 清空数据加载缓存、减少 eval 频率、检查是否有张量未释放 |
| 看着还有显存却报 OOM | 显存碎片化,或者剩余空间不是连续块 | 重启训练进程、调整缓存分配策略、降低 batch |
加载模型时报 OOM,其实是最容易处理的。模型权重是固定值,7B 半精度 14GB 装不下,那就下个量化版本,8bit 变 7GB,4bit 变 3.5GB,问题立刻缓解。怕的是反向传播阶段的 OOM,因为激活值和梯度是动态的,不太好一眼定位。
我见过一个很典型的错误:有人训练时不开梯度检查点,序列长度又拉到 4096,模型权重只占 14GB,激活值却冲到 25GB,总显存刚好卡在爆与不爆的边缘。开着梯度检查点后,激活值能降到原来的零头,问题瞬间解决。所以激活值大导致 OOM 时,第一反应应该是开梯度检查点,而不是立刻降 batch。
4.2 显存碎片化:显存明明还剩,但就是分配不出来
这是很多中高显存显卡用户的噩梦。现象是:nvidia-smi 显示还剩好几 GB,但训练一报错就说要申请一块 12GB 的连续空间失败。原因在于显存里被各种大小不一的张量切成了碎片,空闲空间合计够大,但没有一块连续区域能满足申请。
这个问题的根源通常是频繁创建和销毁大张量,或者模型初始化时临时缓冲大量占用。处理方式有几个:最有效的是杀掉进程重启,重新开始后内存布局会干净很多;其次是给框架的缓存分配策略配一个更大的预留比例,减少分配器频繁执行块整理的次数;再就是尽量固定 batch size,避免在训练过程中反复改变张量形状。
一个容易被忽略的点:把验证(eval)和训练放在同一个循环里,每轮 eval 都会创建新的临时张量,跑几十轮之后碎片化会越来越严重。建议 eval 单独开循环,或者降低 eval 频率。
4.3 速度和显存怎么平衡,我踩过的坑
显存压下来了,但训练时间翻倍,这种“假优化”很多人应该都经历过。我自己最典型的案例:为了把 batch 压到 1,用了 offload,结果每一步都要在 CPU 和 GPU 之间搬数据,训练速度从每秒 3 步掉到 0.5 步,本来 5 小时能跑完的任务拖到了 15 小时。后来换回 ZeRO 阶段三 + 梯度累积,显存差不多,速度快了不止一倍。
| 配置 | 峰值显存(估算) | 训练速度(相对) | 适用场景 |
|---|---|---|---|
| 全量 + BF16 + 梯度检查点 | 高 | 最快 | 显存充裕、追求速度 |
| 全量 + ZeRO 阶段二 + 梯度检查点 | 中 | 较快 | 4~8 卡、显存适中 |
| 全量 + ZeRO 阶段三 | 低 | 中等 | 多卡、显存紧张 |
| 全量 + offload | 很低 | 慢 | 配置差但必须全量 |
| QLoRA + 梯度检查点 | 很低 | 中等 | 单卡、对速度要求不高 |
我的经验排序是:优先保证 batch 不低于某个阈值(比如单卡不小于 1、全局不小于 16),再用梯度检查点压激活值,然后用 ZeRO 分片压静态状态,最后才考虑 offload。每一步都记录速度和显存,别想当然。调参这件事,真正有用的不是感觉,而是你每次改动前后留下的数字。
结束:一点个人体会
大模型训练的显存优化,说白了就是三件事:不该存的不存,该切开的切开,实在不行用算力换空间。混合精度、梯度检查点、ZeRO、LoRA,所有经典手段本质上都在围绕这三点做文章。我自己在踩过无数坑之后的体会是:不要迷信某一个方案“一定好”,显存优化的正确答案永远取决于你的模型、显卡、数据分布和可接受的训练时间。最后再分享一个小技巧:把每次实验的模型规模、batch size、峰值显存、每步耗时、loss 曲线记到一张表里,坚持记一个月,你对显存问题的判断力会比看十个教程都涨得快。毕竟能跑通的办法,从来不是想象出来的,是一个个数字堆出来的。