1. 6GB 显存跑两个决策模型,这事到底卡在哪
先把结论摆在前面:6GB 显存同时装两个决策模型,不是"能不能跑"的问题,而是"跑起来之后每一步都在跟显存抢地盘"的问题。Kev 和 Laya 这两个模型,一个偏结构化推理,一个偏对话式决策,单拎出来在 6GB 卡上都能勉强喘气,但两个一起塞进去,翻车几乎是必然的——区别只在于翻在哪一步。
我这次折腾的起点很朴素:手头只有一张 6GB 显存的卡,想同时挂两个决策模型做对比测试,一个负责逻辑推演,一个负责自然语言决策输出。听起来像是"两个小模型而已",实际动手才发现,显存这东西不是简单的加法。模型权重、KV Cache、中间激活值、框架自身的开销,四样东西叠在一起,6GB 根本不够分。
关键词里提到的 LoRA、NaN、peft,这三个词基本概括了我这一晚踩的坑:LoRA 微调后的权重合并方式直接影响显存占用,NaN 是训练和推理里最让人抓狂的报错,peft 则是整个加载流程的核心工具链。下面我把整个过程拆开讲,包括每一步为什么这么选、哪里翻了车、最后怎么绕过去的。
这篇文章适合谁看?如果你手上是 6GB 到 8GB 显存的卡,想跑多个模型或者做 LoRA 微调,又不想被各种"显存不足"的报错反复折磨,那这篇记录应该能帮你省下几个晚上。我不打算讲太多教科书式的原理,重点放在实际操作中那些文档里不会写的细节。
2. 两个决策模型同时加载的显存账本
2.1 模型权重只是显存开销的冰山一角
很多人算显存的时候只算模型权重,比如一个 3B 参数的模型,FP16 精度下大约占 6GB,于是觉得"6GB 卡刚好能装一个"。这个算法本身没错,但漏掉了后面三样东西。
模型权重加载进显存后,推理过程中还需要:
- KV Cache:每生成一个 token,都要缓存 Key 和 Value 矩阵。序列越长,这部分占用越大。一个 3B 模型在 2048 上下文长度下,KV Cache 可能吃掉 1GB 到 2GB。
- 中间激活值:前向传播过程中产生的临时张量,虽然会释放,但峰值占用不容忽视。
- 框架开销:PyTorch 自身的 CUDA 上下文、cuDNN 的 workspace、通信缓冲区,这些加起来轻松占掉 500MB 到 1GB。
所以一个标称 6GB 的模型,实际跑起来可能需要 8GB 以上。6GB 卡想装两个,必须做量化或者用 LoRA 这种轻量适配方式。
2.2 量化精度与显存占用的换算关系
我把常见的精度和显存占用关系整理成了一张表,方便你直接对照自己的卡来估算:
| 精度格式 | 每 1B 参数占用 | 3B 模型权重 | 7B 模型权重 | 备注 |
|---|---|---|---|---|
| FP32 | 4GB | 12GB | 28GB | 基本不用考虑 |
| FP16/BF16 | 2GB | 6GB | 14GB | 推理常用,但 6GB 卡装 3B 就满了 |
| INT8 | 1GB | 3GB | 7GB | 需要量化支持,精度损失小 |
| INT4 | 0.5GB | 1.5GB | 3.5GB | 精度损失明显,但显存友好 |
| NF4 | 约 0.5GB | 1.5GB | 3.5GB | peft 里常用的量化格式 |
按这张表算,6GB 卡想同时装两个 3B 模型,至少得用 INT4 或 NF4 量化,权重部分占 3GB,剩下 3GB 留给 KV Cache 和框架开销。听起来可行,但实际跑起来 KV Cache 会随着对话轮次增长,很快就撑不住。
2.3 为什么我选择 LoRA 而不是全量微调
关键词里有 LoRA 和 peft,这里解释一下我的选择逻辑。全量微调一个 3B 模型,即使是用 INT4 量化加载,优化器状态和梯度也会把显存撑爆。LoRA 的思路是不动原始权重,只在旁边挂一对低秩矩阵,训练时只更新这对小矩阵。
这样做的好处很直接:
- 可训练参数从几十亿降到几百万,显存占用大幅下降。
- 原始权重可以量化后冻结,不参与梯度计算。
- 训练完的 LoRA 权重文件很小,几十 MB 级别,方便切换。
但 LoRA 也有坑。最大的坑是合并方式:你可以选择训练时动态合并,也可以选择训练完再把 LoRA 权重合并回原模型。前者灵活但推理时多一层计算,后者推理快但合并过程可能引入精度问题。我这次两个模型用了不同的策略,后面会详细说。
3. Kev 和 Laya 的加载策略与翻车现场
3.1 Kev 模型:NF4 量化加 LoRA 的加载流程
Kev 这个模型我采用的是 NF4 量化加载加 LoRA 适配器的方案。核心代码逻辑是这样的:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from peft import PeftModel import torch bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) base_model = AutoModelForCausalLM.from_pretrained( "kev-base-model", quantization_config=bnb_config, device_map="auto", torch_dtype=torch.bfloat16, ) model = PeftModel.from_pretrained(base_model, "kev-lora-adapter")这段代码看起来干净,但实际跑的时候第一个坑就来了:device_map="auto"在 6GB 卡上会把部分层卸载到 CPU,推理速度直接掉到没法用。我后来改成手动指定device_map={"": 0},强制全部放 GPU,配合 NF4 量化才勉强装下。
注意:
bnb_4bit_use_double_quant=True这个选项会额外做一次量化,进一步压缩显存,但会增加一点计算开销。6GB 卡上建议开启,能省出几百 MB。
3.2 Laya 模型:INT8 量化下的 KV Cache 爆炸
Laya 模型我一开始用的是 INT8 量化,权重占 3GB 左右。单独跑没问题,但和 Kev 同时加载时,两个模型的 KV Cache 加起来直接把显存吃满。
这里有个细节值得说:KV Cache 的占用和 batch size、序列长度成正比。我当时的对话上下文设的是 4096,两个模型各占一份 KV Cache,峰值直接冲到 5GB 以上,加上权重和框架开销,6GB 根本兜不住。
解决办法有两个方向:
- 降低上下文长度,从 4096 降到 2048,KV Cache 直接减半。
- 用 PagedAttention 或者类似的显存管理机制,把 KV Cache 分页管理,减少碎片。
我最后选的是降上下文加动态卸载:不活跃的模型把 KV Cache 释放掉,需要时再重建。这个策略后面会详细讲。
3.3 两个模型共存时的显存分配实测数据
我把实测的显存占用整理成了表格,数据来自nvidia-smi的实时监控:
| 阶段 | Kev 占用 | Laya 占用 | 框架开销 | 总占用 | 状态 |
|---|---|---|---|---|---|
| 仅加载 Kev | 2.8GB | - | 0.6GB | 3.4GB | 正常 |
| 仅加载 Laya | - | 3.2GB | 0.6GB | 3.8GB | 正常 |
| 两个同时加载 | 2.8GB | 3.2GB | 0.8GB | 6.8GB | 溢出 |
| 量化优化后 | 2.1GB | 2.4GB | 0.7GB | 5.2GB | 勉强 |
| 加 KV Cache 后 | 2.1GB | 2.4GB | 0.7GB | 6.3GB | 再次溢出 |
这张表说明一个残酷的事实:即使权重压到 4.5GB,加上框架开销和 KV Cache,6GB 卡依然不够。最后我是靠动态释放 KV Cache 才把峰值压到 5.8GB 左右,留了 200MB 的余量。
4. NaN 报错的完整排查链路
4.1 NaN 出现的三个典型位置
NaN 这个报错,我在这一晚遇到了三次,每次位置都不一样:
- 第一次:LoRA 训练时 loss 突然变成 NaN,出现在第 30 步左右。
- 第二次:推理时输出全是 NaN,模型直接吐乱码。
- 第三次:两个模型切换时,KV Cache 里出现 NaN,导致后续生成全部崩坏。
这三个位置对应三种不同的根因,不能一概而论。
4.2 训练阶段 NaN 的根因定位过程
训练阶段的 NaN,我一开始怀疑是学习率太大。把学习率从 2e-4 降到 1e-5,问题依然存在。然后我检查了数据,发现有几条样本的标签全是 padding token,导致 loss 计算时出现除零。
具体排查步骤是这样的:
- 在 loss 计算处加断言,检查是否有 inf 或 nan。
- 打印每个 batch 的标签分布,确认是否存在全 padding 的情况。
- 检查梯度裁剪是否生效,
max_grad_norm设的是多少。
最后定位到两个问题:一是数据里确实有全 padding 样本,二是梯度裁剪的阈值设得太大,1.0 对于这个模型来说不够。改成 0.3 并过滤掉无效样本后,训练稳定了。
提示:LoRA 训练时,
max_grad_norm建议设在 0.3 到 0.5 之间。太大容易 NaN,太小收敛慢。
4.3 推理阶段 NaN 与 KV Cache 污染的关系
推理阶段的 NaN 更隐蔽。我一开始以为是模型权重有问题,重新加载了一遍还是 NaN。后来用torch.isnan逐层检查,发现是 KV Cache 里混入了 NaN。
根因是:两个模型共享同一个 CUDA 流,切换时没有正确同步,导致一个模型的中间结果污染了另一个模型的 KV Cache。解决办法是给每个模型分配独立的 CUDA 流,切换时显式调用torch.cuda.synchronize()。
stream_kev = torch.cuda.Stream() stream_laya = torch.cuda.Stream() with torch.cuda.stream(stream_kev): kev_output = kev_model.generate(**kev_inputs) torch.cuda.synchronize() with torch.cuda.stream(stream_laya): laya_output = laya_model.generate(**laya_inputs)这段代码看起来简单,但少了torch.cuda.synchronize()就会出现竞态条件,NaN 就是这么来的。
4.4 防止 NaN 的防御性编程习惯
踩了这三次坑之后,我养成了几个习惯:
- 每次前向传播后检查输出是否有 NaN,有就立即中断并打印上下文。
- 训练时用
torch.autograd.set_detect_anomaly(True)定位异常梯度。 - KV Cache 每次复用前做一次 NaN 检查,发现污染立即重建。
- 保存 LoRA 权重时同时保存优化器状态,方便断点续训。
这些习惯看起来繁琐,但比起 NaN 出现后盲目排查,效率高得多。
5. 让两个模型在 6GB 卡上共存的实操方案
5.1 动态显存释放与模型切换机制
核心思路是:不要求两个模型同时驻留显存,而是按需加载、用完释放。具体实现上,我用了一个简单的模型管理器:
class ModelManager: def __init__(self): self.current_model = None self.current_name = None def switch_to(self, name, model): if self.current_name == name: return self.current_model if self.current_model is not None: del self.current_model torch.cuda.empty_cache() self.current_model = model self.current_name = name return model这个方案的关键在于torch.cuda.empty_cache()的调用时机。必须在删除模型引用之后、加载新模型之前调用,否则显存碎片会导致新模型加载失败。
5.2 量化参数与上下文长度的联合调优
6GB 卡上,量化精度和上下文长度是一对矛盾。我的调优过程是这样的:
| 配置组合 | 权重占用 | KV Cache | 总占用 | 可用性 |
|---|---|---|---|---|
| NF4 + 4096 上下文 | 3.0GB | 2.5GB | 6.2GB | 溢出 |
| NF4 + 2048 上下文 | 3.0GB | 1.3GB | 5.0GB | 可用 |
| INT8 + 2048 上下文 | 4.5GB | 1.3GB | 6.5GB | 溢出 |
| NF4 + 1024 上下文 | 3.0GB | 0.7GB | 4.4GB | 流畅 |
最后我选的是 NF4 加 2048 上下文,留出约 1GB 的余量给框架和临时张量。这个配置下,两个模型切换的延迟在 2 秒左右,可以接受。
5.3 LoRA 权重合并与推理加速的取舍
LoRA 权重合并有两种方式,我两个模型用了不同策略:
- Kev:训练完立即合并,推理时直接用合并后的模型,速度快但失去灵活性。
- Laya:保持 LoRA 分离,推理时动态加载,灵活但每次多一层计算。
实测下来,合并后的 Kev 推理速度比分离状态快约 15%,但切换不同 LoRA 适配器时需要重新合并,耗时约 30 秒。Laya 的分离方式切换适配器只需 2 秒,但推理速度慢一些。
提示:如果你的场景需要频繁切换不同任务的 LoRA,保持分离更合适;如果固定一个任务,合并后推理更划算。
6. 这一晚踩过的坑与留下的经验
6.1 显存监控工具的正确使用方式
nvidia-smi是最常用的工具,但它有个问题:刷新频率低,容易错过峰值。我后来改用nvidia-smi dmon或者watch -n 0.1 nvidia-smi,能捕捉到更细粒度的显存变化。
另外,PyTorch 自带的torch.cuda.memory_summary()能看到更详细的信息,包括已分配、已缓存、碎片情况。排查显存问题时,这个比nvidia-smi更有用。
6.2 模型加载顺序对显存碎片的影响
加载顺序会影响显存碎片。我的经验是:先加载大的、后加载小的。因为大模型需要连续的大块显存,如果先加载小模型把显存打散,大模型可能加载失败。
具体到 Kev 和 Laya,Kev 的权重更大,所以先加载 Kev,再加载 Laya。反过来试过一次,Laya 加载后显存碎片化,Kev 的 NF4 量化层分配失败。
6.3 从这次翻车中提炼的可复用检查清单
最后整理一份检查清单,下次再遇到类似场景可以直接对照:
- 加载前用
torch.cuda.mem_get_info()确认可用显存。 - 量化配置里
bnb_4bit_use_double_quant建议开启。 device_map不要用auto,手动指定 GPU。- 训练时
max_grad_norm设在 0.3 到 0.5。 - 多模型切换时用独立 CUDA 流并同步。
- KV Cache 复用前做 NaN 检查。
- 加载顺序从大到小。
- 上下文长度根据显存余量动态调整。
这套流程跑下来,6GB 卡同时挂两个决策模型虽然紧张,但确实能跑。代价是上下文长度受限、切换有延迟、需要精细的显存管理。如果你的卡是 8GB 以上,这些坑大部分可以绕过去。但如果你跟我一样只有 6GB,那这些细节就是必须掌握的生存技能。