☰
大语言模型训练显存优化:从OOM到LoRA的实战指南
2026/10/19 4:16:02 网站建设 项目流程

去年帮人调一个 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,手里有不同的卡,我的选择通常是这样的:

硬件条件推荐方案理由
单卡 24GBQLoRA + BF16 + 梯度检查点全量根本装不下,只能走参数高效微调
单卡 48GBLoRA 或小 batch 全量(配合梯度检查点)7B 全量静态 112GB 不够,但 LoRA 很宽裕
四卡 80GBZeRO 阶段二或三 + 混合精度全量微调可行,注意通信开销
八卡 80GBZeRO 阶段三,必要时加并行空间充足,主要优化训练速度

这里有个提醒:显存只是门槛,不是越高越好。如果单卡 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 曲线记到一张表里,坚持记一个月,你对显存问题的判断力会比看十个教程都涨得快。毕竟能跑通的办法,从来不是想象出来的,是一个个数字堆出来的。

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

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

立即咨询