显存不够用的日子,相信每个在本地折腾大模型的人都经历过。跑推理跑到一半,啪一下OOM,模型直接崩了,前功尽弃。前阵子我在社群里又看到有人问“我的卡是24G显存,跑7B模型怎么还会爆显存”,这问题一看就知道不是显存真不够,而是没做显存规划。这篇文章不聊训练,单聊推理场景下怎么把显存抠着用,把我自己踩过坑、试过有效的方法全部摊开来讲。
先给不熟悉的朋友补个背景:本地跑大模型推理,显存占用主要分成三块——模型权重、KV Cache(键值缓存)、以及激活值(推理中间过程产生的临时张量)。绝大多数人遇到的OOM,其实就是KV Cache在作怪,因为权重是固定的,而KV Cache会随着生成的长度线性增长。你输入越长、生成越长,显存占用就越高。明白了这一点,优化思路就清晰了:要么减小权重,要么控制KV Cache,要么把部分计算挪到别处去。
这篇文章适合那些用消费级显卡跑大模型、经常被OOM困扰、想在不换卡的前提下榨干现有硬件性能的朋友。我会从显存分配的原理讲起,逐步给出量化、KV Cache优化、批处理控制、SWAP等一系列实操手段,最后附上问题排查实录。
1. 先搞懂显存是怎么被吃掉的
1.1 推理阶段的显存分配逻辑
模型加载进显存的那一刻,占用的空间主要就是权重参数。以7B模型为例,FP16精度下每个参数占2字节,光权重就需要7 × 10^9 × 2 ≈ 14GB显存。你手里如果是一张24G显存的卡,看似还能剩下10G,但注意,这个时候模型还没开始真正干活。
一旦开始推理,Transformers库里的past_key_values会缓存每个Transformer层在计算Attention时的K和V矩阵。这个缓存的显存消耗公式是:2(K和V两组) × 层数 × 注意力头数 × 头维度 × 序列长度 × 批次大小 × 2字节。具体数值大家一般记不住,我直接说结论:对于7B模型,序列长度512、批大小1的情况下,KV Cache大约占用1-2G;当序列长度推到4096时,这个数字会膨胀到8-10G。这就是为什么很多人发现“短文本没问题,长文本必OOM”的根本原因。
激活值则是另一个动态指标。它跟具体的模型结构、隐藏层维度、批次大小都有关系,通常在短序列时占比不高,但随着batch size增大,激活值的增长速度非常夸张。这也是为什么我建议本地推理尽量把batch size设为1的原因之一。
1.2 用工具看一眼真实占用
与其猜,不如直接看。在终端里用nvidia-smi只能看到进程占用的总显存,没法看到里面细分。更好的办法是用torch.cuda.memory_summary(),或者直接跑一段小代码把权重、KV Cache、激活值分别打出来。
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained("your_model_path", torch_dtype=torch.float16).cuda() tokenizer = AutoTokenizer.from_pretrained("your_model_path") inputs = tokenizer("显存优化测试", return_tensors="pt").to("cuda") model.eval() with torch.inference_mode(): outputs = model.generate(**inputs, max_new_tokens=256, use_cache=True) print(torch.cuda.memory_summary())torch.cuda.memory_summary()会输出非常详细的显存分配情况,包括PyTorch缓存分配器的内部状态。第一次跑这个命令,你会发现自己预留给PyTorch的缓存可能远大于实际用到的,这块可以通过环境变量调小。
1.3 显存碎片的隐藏杀手
还有一个很多人忽略的点:显存碎片化。PyTorch的缓存分配器为了避免重复分配的开销,会默认缓存一部分显存块,结果就是你看到nvidia-smi显示显存占用很高,但其实很多是缓存块而不是真实使用。这种情况通常出现在反复加载不同大小模型,或者多次执行变长序列推理之后。
如果你发现显存明明没有占满但出现了OOM,十有八九就是碎片化问题。最简单的解决办法是重启推理进程,或者用torch.cuda.empty_cache()把缓存块释放掉。但请注意,这个函数只能释放空闲的缓存块,不能解决模型本身占用的显存。更彻底的办法是设置环境变量PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,这个选项可以让PyTorch使用可扩展内存段来减少碎片。实测在连续跑多个不同长度文本时,这个设置能明显降低碎片导致的虚高占用。
2. 权重瘦身:量化和精度选择
2.1 FP16、INT8、INT4到底选哪个
现在主流的本地推理框架(llama.cpp、vLLM、Transformers配合bitsandbytes)都支持量化。量化的核心逻辑就是降低每个参数占用的比特数,把FP16降到INT8,权重占用直接减半;降到INT4,占用再减半。以7B模型为例,FP16需要14GB,INT8需要7GB,INT4只要3.5GB左右。
从我的实际体验来看:如果你的显存刚好能装下FP16模型,那是首选,因为精度最高,推理表现最稳定。如果FP16装不下或加载后剩余显存太少,优先考虑INT8;INT4虽然省显存最多,但推理质量下降比较明显,尤其是在处理中文长文本、逻辑推理强的任务时,回答的连贯性会打折扣。
这里有个反直觉的经验:很多人以为量化只影响显存占用,其实它还影响推理速度。INT8和INT4的权重体积更小,减少了PCIe传输和显存带宽压力,在某些平台上的解码速度反而比FP16更快。当然这跟具体硬件和框架有关,建议自己在自己的卡上做一次benchmark。
2.2 用bitsandbytes做低精度加载
如果你继续用Transformers库,最简单的量化方式是bitsandbytes的load_in_8bit或load_in_4bit配置。它的好处是代码改动极小,坏处是因为它做了逐层量化,模型加载时间会变长,而且首次加载时会把一部分量化计算放在CPU上进行。
from transformers import AutoModelForCausalLM, BitsAndBytesConfig import torch quantization_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( "your_model_path", quantization_config=quantization_config, device_map="auto", )这套参数里,bnb_4bit_use_double_quant=True会启用二次量化,把量化常数也压一遍,大约能再省0.5-1GB显存。bnb_4bit_compute_dtype=torch.float16表示计算时反量化到FP16,这算是在显存和精度之间的折中选择。
2.3 GGUF的量化格式值不值得用
如果你用过llama.cpp,应该对GGUF格式不陌生。GGUF内置了多种量化方案,最常用的是Q4_K_M、Q5_K_M、Q6_K这些。以7B模型为例,Q4_K_M大小约为4.08GB,Q5_K_M约为4.45GB,Q6_K约为5.53GB。
我的建议是,如果你不需要跑复杂的中文上下文依赖任务,可以用Q5_K_M作为日常使用版本;如果显存比较紧张,就用Q4_K_M。这两个量化级别在绝大多数实际对话场景下质量差距很小。但是Q2、Q3级别我不太建议日常使用,那个质量下降肉眼可见,除非你就是想拿一块4G老卡体验一下本地模型。
3. KV Cache:最容易被忽视的大户
3.1 KV Cache到底怎么控制
上一节提到KV Cache是推理过程中动态增长的显存消耗。它的大小主要取决于四个参数:模型层数、上下文窗口长度、批次大小、以及是否使用GQA(Grouped Query Attention)。前三个都好理解,第四个需要单独解释。
MHA(Multi-Head Attention)里,每一层的每个注意力头都会各自缓存一份K和V,显存开销最大。GQA(分组查询注意力)是让多个查询头共享一组K和V,可以显著减少KV Cache的显存占用。比如从MHA切换到GQA,KV Cache的显存可以减半甚至更多。这个能力不是模型现有的就得靠框架支持,而是跟模型架构一起决定的。你在引入模型时需要确认它是否用了GQA,很多新版模型(如Llama 3、Mistral)已经用了GQA,这也是它们能在消费级显卡上跑长文本的原因之一。
如果推理框架支持,你可以手动限制KV Cache的最大长度或参与计算的上下文长度。比如Transformers里设置max_new_tokens并不能直接控制KV Cache,真正相关的是max_length或 generation config里的max_position_embeddings。把最大输入长度限制在4096而不是直接拉满到32K,KV Cache占用会差好几倍。
3.2 开启use_cache与关闭use_cache的博弈
很多教程会告诉你关闭use_cache可以省显存。这确实没错,但代价是推理速度大幅下降。因为关闭KV Cache后,模型每生成一个token,都需要重新计算前面所有token的注意力值,复杂度从线性退化到平方级。你会在长文本生成时明显感觉到速度变慢,慢到让你怀疑是不是卡了。
我的看法是:除非你只是在做极短的对话测试,否则不要关闭use_cache。更好的思路是配合vLLM这类高性能推理框架,它们对KV Cache的管理更精细,引入了PagedAttention,可以按页分配,显存利用率高得多且吞吐量更大。在vLLM里你可以通过--max-model-len和--gpu-memory-utilization来显式控制显存占用比例,实操中我会把gpu-memory-utilization设为0.85到0.9,剩下一点空间给CUDA context和其他进程,避免直接顶满后出幺蛾子。
3.3 Sliding Window和位置编码的隐性影响
有些模型通过滑动窗口注意力(Sliding Window)让KV Cache只保留窗口范围内的token,这能大幅限制Cache增长。Mistral 7B就用了这个机制,所以它在长文本场景下的显存表现优于同样大小的旧模型。如果你选模型时特别在意长文本下的显存,优先考虑带窗口注意力或GQA的架构。
位置编码类型也会间接影响显存占用。比如使用RoPE(旋转位置编码)的模型在推理时需要在Q和K上追加位置信息,这部分虽然本身不大,但在长上下文时会增加一部分计算和临时显存开销。你要是把上下文长度拉到几万,这部分同样不可忽视。但这些算优化细节,普通场景下先关注前三点就够了。
4. 实践调优:从加载到生成的完整链路
4.1 加载阶段的显存省钱法
除了量化之外,加载阶段还有几个容易踩的坑。第一个是使用device_map="auto",让框架自动分配模型层到不同的设备(GPU/CPU/MPS),对于显存不宽裕的机器,让一部分层驻留在CPU内存里,GPU只处理计算密集的层。这样做的好处是显存不爆,坏处是速度变慢,因为CPU和GPU之间要频繁搬运权重。
第二个是torch_dtype的选择。设定torch_dtype=torch.float16而不是不设,因为加载FP32的模型权重会把显存占用翻倍。很多人OOM其实就是FP32导致的。第三个是关闭不需要的梯度计算。推理模式下记得用torch.inference_mode()包裹,它会禁用梯度跟踪,减少中间张量保存带来的显存开销。
4.2 生成阶段的参数控制
生成阶段我最常调的几个参数是max_new_tokens、temperature、top_p和num_beams。其中num_beams是显存杀手,贪心解码只维护一个候选序列,而beam search会同时维护beam_width个候选序列,KV Cache也跟着翻倍。如果你本地推理显存紧张,beam search最好设置为1,也就是直接用贪心采样。很多朋友一上来就习惯性用beam search调个4或5,稍微长一点的文本就直接OOM,这不是模型问题,是参数问题。
temperature和top_p虽然不直接占显存,但它们决定了采样过程的随机性。对追求稳定性的本地使用来说,temperature=0.7、top_p=0.9是比较通用的区间。另外max_new_tokens千万不要设得太大,很多人设置成512看着不够就设2048,结果生成长文必爆。先设小一点,分次生成并拼接结果,也是绕开单次KV Cache峰值的好办法。
4.3 一个7B模型在8G显存上的配置实战
这里给一个可复现的配置,是我在GTX 3060 8G上跑7B模型的常用方案:
- 模型:Q4_K_M格式的GGUF模型,通过llama.cpp加载
- 上下文:
--ctx-size 2048,不直接拉满 - 批处理:
--batch-size 256(短输入够用) - 线程数:
--threads 8,GPU部分用--n-gpu-layers 999尽可能多地把层放到GPU上 - KV Cache:llama.cpp按上下文长度自动分配,2048时大约2G左右
实测这套配置下,7B模型大概占6-7G显存,剩余一点空间给系统和其他应用,生成速度在CPU和GPU搭配下能保持每秒10-15 token,日常问答完全够用。如果你的显存比8G更小,那就适当减小ctx-size到1024,同时只加载部分层到GPU,让CPU帮跑一部分。别小看这种“轻重混跑”,它比直接OOM强太多了。
4.4 显存不足时的兜底方案:CPU Offload和Swap
显存实在不够时,还有一个兜底方案是使用CPU Offload。Transformers库的device_map="auto"已经做了这件事,但你可以更细粒度地控制哪些层放在GPU、哪些层放在CPU。常用模式是:Embedding层和最后几层放在GPU,中间的Transformer层部分放CPU。因为Embedding和LM Head的计算量大但参数量不一定最大,放GPU能保住速度。中间的层虽然多,但CPU也能算,就是慢一点。
llama.cpp也支持--n-gpu-layers参数来指定把前N层放到GPU,剩下的在CPU跑。我建议从N=模型总层数的一半开始试,然后逐渐增大,找到一个显存刚好不满的临界值。这个临界值会随上下文长度变化,所以改完上下文后最好重新试。用这种方式跑大模型,速度会比我前面说的全GPU方案慢不少,但总比跑不起来好,对不对?等你后面换了显卡,直接把这个层级拉满就行。
5. 常见OOM问题与排查技巧实录
5.1 问题一:加载就爆,还没推理就OOM
这通常是权重精度问题。检查一下是不是默认用了FP32加载,把模型转成FP16或INT4就能解决。还有一个可能:CUDA context本身会占用几百MB到1G不等的显存,如果机器显存真的很小(比如6G以下),这部分占比很高。可以在加载模型前先torch.zeros(1).cuda()预热CUDA,看看空载占用,然后根据剩余空间调整模型精度。
5.2 问题二:短文本没问题,长文本必爆
这是最典型的KV Cache增长导致的。解决方案优先考虑缩短上下文窗口,然后考虑量化或换用带GQA的模型。如果你的业务确实需要长上下文,可以考虑给模型升级到更大显存的机器,或者使用支持PagedAttention的框架,这样KV Cache按页管理,长上下文的显存利用效率会好很多。
5.3 问题三:同样配置,过一会儿就OOM
这个通常有两个原因。一个是进程里残留了多个推理上下文,比如在notebook里反复跑cell,旧的推理结果没释放,解决方法是重启kernel或显式del后调用gc.collect()和torch.cuda.empty_cache()。另一个原因是累积生成了过长的对话历史,每次生成都在对话历史的KV Cache基础上继续增长。如果对话不能中断,可以定期把历史消息截断,只保留最近几轮。这个经验很实用,很多把模型当聊天机器人用的朋友,聊久了必爆,就是因为历史越积越长。
5.4 问题四:多进程跑测试显存互相挤占
如果你一边跑本地模型,一边开着浏览器或视频剪辑软件,显存被吃掉了不少。nvidia-smi能帮你看到谁在占显存,但这种场景解决方式很简单:先把无关应用关了。如果你是在做自动化测试,需要并行的多个推理进程,建议给每个进程设置不一样的PYTORCH_CUDA_ALLOC_CONF和CUDA_VISIBLE_DEVICES,把负载分散到多张卡上。单卡的话就别并行跑多个模型了,老老实实做排队。
5.5 快速排查清单
我给自己做过一张排查表,遇到OOM就按这个查一遍:
| 排查项 | 操作 | 效果 |
|---|---|---|
| 权重精度 | 检查是否FP32,考虑改为FP16/INT8/INT4 | 立减50%-75%权重显存 |
| 上下文长度 | 降低ctx-size或max_length | 控制KV Cache上限 |
| 解码策略 | 确认beam size为1 | 避免多个候选序列的KV翻倍 |
| 模型加载方式 | 使用device_map="auto"做CPU Offload | 分散显存压力 |
| 历史对话 | 截断并只保留最后几轮 | 避免KV累积到爆 |
| 显存碎片 | 调用empty_cache或重启进程 | 释放缓存碎块 |
这套表从我个人的使用体验来看,能解决90%以上的本地推理OOM。剩下10%属于显卡本身确实装不下这个规模的模型,那就得考虑进一步量化或者换模型,而不是硬撑着。
6. 进阶优化:框架选型和硬件调优
6.1 vLLM和llama.cpp怎么选
如果你只跑单轮或少次对话,llama.cpp最省事,下载即用,CPU和GPU混合推理也做得很成熟。它的GGUF量化格式选择多,而且社区活跃,遇到问题很容易找到解决方案。
如果要做并发比较高的推理服务,比如搭一个给团队用的API接口,vLLM更合适。它的PagedAttention能让你在有限的显存里塞进更大的吞吐量,而且连续批处理能显著提高GPU利用率。但vLLM对显存的最小要求比较高,小显存卡上跑不太开。我自己在8G卡上优先选llama.cpp,在有20G以上显存的机器上跑服务时就用vLLM。
6.2 GPU驱动与CUDA版本的坑
有一部分OOM是驱动层的问题。比如某些老版本驱动对特定PyTorch版本的显存管理不太好,导致显存释放不彻底。建议在可控范围内把CUDA toolkit和PyTorch的版本对齐,至少在解决OOM问题时不要排除这一项。nvidia-smi显示驱动支持的最高CUDA版本,torch.version.cuda显示当前PyTorch内置的CUDA版本,这两个可以快速对照一下。
6.3 模型选择才是最大的显存优化
说句实在话,如果你总是在压榨显存,最省心的“优化”其实是选一个参数规模更合适的模型。7B模型跑不动就看看1.5B-4B的小模型,很多场景下表现并不差。比如一些简洁问答、格式整理类的任务,4B模型和7B模型差距很小,但显存占用差了一半。把一个模型用到极致值得敬佩,但没必要死磕,量力而行选模型也是内容质量控制的一部分——毕竟模型输出质量取决于你能跑得起哪种模型。
选模型还有一个可以直接参考的点:同一系列模型的量化版本大小已知,用显存总容量去匹配模型文件大小+KV Cache余量即可。7B的Q5_K_M约4.45GB,KV Cache留2G,那8G卡基本就是稳的;如果显存只有6G,那就选Q4_K_M并把上下文压到1024。
写在最后
OOM这件事,本地跑大模型的朋友十有八九都会遇到,每次遇到都觉得很上头的点在于它往往不是显存真的满了,而是某一项配置没做好。我自己从最开始只会满世界找“更小模型”的版本,到现在能根据手头机器参数快速给出量化等级、上下文窗口、KV Cache策略的搭配组合,中间也就是把这些细节一个一个调明白的过程。
如果你手头也有个“老显存焦虑症”,建议从今天就开始做三件事:第一,装一个带显存检测的工具跑一下自己的真实占用;第二,试试把量化等级降一级,看看输出质量你能不能接受;第三,给当前常跑的模型配一个固定的显存配置模板。三件事做完,你再回头看你遇到的OOM,多半已经能自己诊断了。