租GPU服务器跑模型,我隔三差五就会收到几乎一模一样的求助:“日志里报CUDA OOM,可nvidia-smi看显存明明还剩二十多G,这机器是不是被坑了?”
这句话本身不算错,但“显存有余量”和“这段显存能用”完全是两码事。nvidia-smi显示的是物理显存的当前占用情况,而CUDA分配器在申请内存时,看的是能不能拿到一段连续、可用、且符合当前上下文的显存块。这篇文章不写空理论,就按我在租用GPU服务器、低显存跑模型时踩过的真实场景来讲:那些“余量”到底被谁偷走了,为什么2MB都分配不出来,以及遇到OOM时应该按什么顺序排查。
1. nvidia-smi 显示“显存够用”,和 CUDA 能分到显存,是两码事
1.1 你在nvidia-smi里看到的“空闲”,不一定是你能用的空闲
先做一个最基础的区分。nvidia-smi里每一张卡的Memory-Usage,表示当前这张卡上所有CUDA上下文一共占了多少显存。这个数字是物理视角的全局数字。但如果这台GPU服务器本身是多人共享的,或者你跑在容器里,那么“能看到的显存”和“你能申请到的显存”会存在偏差。
最常见的情况是容器里看到宿主的整张卡:
- 你启动了一个Docker容器,容器里执行nvidia-smi,它依然会报告这张物理GPU的总显存,比如80GB。
- 但运行时真正允许你这个进程使用多少显存,可能被容器runtime、调度平台或者MIG实例限制在某个配额以内。
- 当你的进程申请超过配额时,CUDA立刻返回out of memory,而nvidia-smi从容器内部看过去,可能还显示“空闲60GB”。
类似的还有MIG模式。一张物理GPU被切成分区,nvidia-smi会给你展示计算实例的名称和内存上限。此时“总显存”已经是切片后的数字,但如果你没注意上下文,依然会觉得“剩余量还很多,怎么会OOM”。
所以在服务器上排查这种问题,第一步永远是问自己一个问题:这个OOM是物理显存不足,还是配额不足?如果是配额不足,那么即使物理显存空出一半,你也一样拿不到。
1.2 一个“2MB都分配不出来”的真实案例
我在调试大模型推理时,见过一条特别有代表性的报错:
CUDA out of memory. Tried to allocate 2.00 MiB当时的nvidia-smi显示GPU还有接近30GB空闲。很多人的第一反应是驱动坏了、显存虚标。但真正的原因不是“缺2MB”,而是CUDA分配器在虚拟地址空间里找不到一段连续的2MB区域。
这里有个容易混淆的概念:显存不只是物理容量,还有一个虚拟地址空间的维度。当程序反复分配、释放不同大小的显存块之后,剩下的物理显存总量可能很大,但这些物理页被映射到地址空间时,被大量活动块切碎了。申请2MB听着很小,可如果那30GB空闲都被打散成几百KB到1MB的小碎片,那2MB的连续请求一样会失败。
你可以把GPU显存想象成一个停车场。余量很大不等于有一个能停大巴车的空车位。如果整个停车场到处都是几米长的死角,一辆12米长的客车就是进不去,倒车也不行。显存OOM很多时候不是“地方不够”,而是“连续的地方不够”。
1.3 系统内存和GPU显存的OOM也不要混在一起
还有一坑。很多人说“OOM”,但日志里写的是“cannot allocate memory”,这其实可能是宿主机系统内存不足,而不是GPU显存不足。尤其是在低显存部署场景里,你需要把一部分层offload到CPU,或者使用pinned memory做异步拷贝。如果CPU内存不够,CUDA在准备H2D拷贝缓冲区时同样会报类似“out of memory”的错误。看到的词都一样,但解决方式完全不同。
所以先别急着骂显卡。看到OOM,第一件事是确认报错来自GPU驱动/CUDA层,还是来自操作系统内存分配层。两者都在字面上叫“OOM”,但一个是显存,一个是内存。
2. PyTorch 缓存分配器:显存被“扣住”的常见原因
2.1 PyTorch 不是按需向驱动申请显存,而是提前“囤地”
在PyTorch里用GPU,绝大多数情况下你会用到它的缓存分配器。这个分配器的核心逻辑很务实:cudaMalloc是昂贵操作,不能每次创建张量都向CUDA申请一次。所以分配器会在第一次需要显存时,一次性向驱动申请一大块,然后在进程内部把这块内存切成小块给张量用。
这样做的好处是速度快,坏处是:当一个张量被释放时,它的显存并不是真正还给GPU,而是留在PyTorch的缓存池里备用。所以nvidia-smi看到的使用量,往往大于你当前“存活张量”的总大小。
比如推理时,前向传播过程里有一个巨大的临时张量,占用了10GB。计算完成后这个临时张量被销毁,但PyTorch没有把这10GB还给CUDA。此时nvidia-smi里依然显示这张卡用了10GB。如果之后你又要申请一个3GB的缓存块,而当前空闲只有2GB,CUDA就报OOM,尽管这10GB里明明有8GB是可以被PyTorch“还回去”的。
很多人在这里就懵了:明明才刚释放过显存,怎么还是OOM?
2.2 allocated和reserved是两个完全不同的数字
排查PyTorch显存问题时,我最先看的不是nvidia-smi,而是PyTorch自带的显存统计。在Python里执行:
import torch print(torch.cuda.memory_summary(device="cuda:0")) print(torch.cuda.mem_get_info(device="cuda:0"))memory_summary里会明确分开两个关键数字:
- Allocated memory:当前真正被张量占用的显存大小。
- Reserved memory:PyTorch分配器从CUDA手里申请到的显存大小,其中一部分正躺缓存池里待复用。
如果Allocated只有2GB,Reserved却有10GB,那你的显存明显被缓存分配器“扣住”了8GB。nvidia-smi看到的占用大概率接近Reserved,而不是Allocated。此时调用一次:
torch.cuda.empty_cache()能释放掉缓存池里所有“当前没在使用”的块,让Reserved回落到接近Allocated。要注意,这只对PyTorch内部缓存有效,不能解决碎片问题。如果那8GB被切成几百块散落在地址空间里,释放后能不能合成一个完整的连续区域,还要看驱动和分配器的处理。
2.3 峰值分配决定了PyTorch“敢不敢”预留显存
还有一种更隐蔽的情况:PyTorch分配器会根据历史峰值,保留一个“够用”的缓存池。它不会傻到把所有释放的块全部还给驱动,因为那样下次遇到同样峰值时会再次触发昂贵的cudaMalloc。所以哪怕你在两次推理之间手动做了empty_cache,只要某个峰值分配出现过,Reserved就不会一下归零。
这也是为什么我不建议用“nvidia-smi当前空闲”来判断模型能不能跑。要看的是负载周期内的峰值。你的模型可能在某个瞬间需要15GB,绝大部分时间只占5GB。如果只看平时快照,会以为5GB就够;但一到那个瞬间,15GB申请失败,OOM来了。
2.4 两个环境变量能救回不少“假OOM”
PyTorch从1.9左右开始提供了显存分配策略环境变量,对碎片化问题特别有用的有两个:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True这个配置会让分配器使用可扩展段,而不是一次性申请一个大块。它更倾向于动态扩展已有段,显著降低虚拟地址碎片。深度学习训练、长序列推理场景里,这个参数是我第一个尝试的,效果好过硬调batch size。
另一个参数是:
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128它的作用是控制大块缓存被二次切分的阈值。主要场景是:一个大块被反复切分成各种尺寸的小块,导致很多块既不能完整满足新请求,又因为太小无法归还给驱动。把它调大,能减少这种“碎块踩踏”,但不同模型表现不一样,建议作为第二个试验项。
如果你用的是TensorFlow,也有对等选项:
import tensorflow as tf gpus = tf.config.experimental.list_physical_devices("GPU") if gpus: tf.config.experimental.set_memory_growth(gpus[0], True)开启后TF不再一把梭哈把整张卡预占,而是按需增长。副作用是运行中可能遇到更多碎片,对常规推理没有大问题。
3. 模型显存占用:权重只是冰山一角,KV Cache和激活值才是凶手
3.1 从参数到权重的第一层显存计算
很多人会拿“模型参数量 × 精度字节数”来估算显存,这个思路没错,但不完整。先看最简单的推理场景:
| 精度 | 每参数字节数 | 7B模型权重大致占显存 |
|---|---|---|
| FP32 | 4 | 28GB |
| FP16/BF16 | 2 | 14GB |
| INT8 | 1 | 7GB |
| INT4 / NVFP4 | 0.5 | 3.5GB |
这张表只能告诉你“权重裸大小”。真正的显存需求还得加上KV Cache、激活值、框架预分配的计算缓冲区和CUDA上下文。把这四类一起算进去,才是一个能落地的数字。
训练场景更夸张。除了权重,还要存梯度和优化器状态。Adam优化器在混合精度训练下,每个参数至少要额外吃掉8到12字节,最终总占用往往是“权重占显存”的三到四倍。如果你拿着推理的估算去开训练任务,不OOM才怪。
3.2 KV Cache为什么可以瞬间吃光“剩余显存”
大语言模型推理时,随着生成token数量的增长,KV Cache是线性增长的。它的计算公式大致是:
KV Cache大小 ≈ 2 × 层数 × KV头数 × 头维度 × 精度字节数 × 当前序列长度 × batch这里面的“2”对应K和V两份缓存。举个例子:一个32层、每个KV头128维、8个KV头的模型,用FP16存KV Cache,每个token大约需要:
2 × 32 × 8 × 128 × 2 / 1024 / 1024 ≈ 0.125MB看着不大,但换算到不同上下文长度:
| 上下文长度 | KV Cache占用 |
|---|---|
| 4K | 512MB |
| 32K | 4GB |
| 128K | 16GB |
很多低显存运行模型的任务,权重通过INT4量化能塞进8G卡,但一旦你想把上下文从8K拉到32K,KV Cache直接多出几GB。这个多出来的部分就是运行中申请失败的常见火药桶。显存快照在生成前看还有4GB空闲,跑到第40轮对话忽然OOM,大多数时候就是KV Cache在作祟。
3.3 临时计算缓冲区和“预占”的坑
除了KV Cache,推理框架一般还会为算子分配临时计算缓冲区。比如某些注意力算子和归一化步骤,需要在运行时申请一块大小和batch、序列长度、head维度挂钩的scratch memory。这些缓冲区不一定全程存在,但会在计算的那一瞬间申请最大块。
更常见的是框架“预占”。像llama.cpp这类推理引擎,启动时就会根据--ctx和--batch提前把KV Cache和compute buffer一次性分配好。你以为模型很小所以不会OOM,但框架为了最大化吞吐,直接把未来可能用到的显存全锁定了。此时nvidia-smi显示“还剩一点”,可一旦有新的连续大块申请需求,就立刻失败。
所以模型显存规划不能只算权重。至少要按下面这个公式来预估:
总显存需求 = 权重占用 + KV Cache上限 + 激活峰值 + 框架固定开销算完还要再留10%到20%余量,因为CUDA上下文、驱动状态、多进程共享都会额外吃掉一点。
4. MoE、量化与低显存部署:为什么省了计算却没省显存
4.1 MoE的“稀疏”省的是计算,不是显存
最近关于MoE架构的低显存讨论特别多,有一个误区反复出现:既然模型每次只激活Top-K个专家,那是不是可以把其他专家不加载进显存?
答案是否定的。MoE模型的“稀疏激活”指的是推理计算量只作用于少数专家,但权重加载量依然是全部参数。模型的总显存占用不会因为路由只挑了2个专家而变小。你可以把MoE想象成一家大食堂:100个厨师都站在备菜区,一桌客人最多同时让3位厨师做菜,但你不能确定下一个客人会点哪个厨师的拿手菜,所以这100个人都必须在岗位上。除非你做专家级offload,把一部分专家放在CPU内存里,需要时再流式换入,否则显存占用和同参数量的Dense模型在一个量级。
这也是为什么有些宣传“8G显存就能跑”的MoE模型,实际用起来还是会OOM。它们可能压低了权重精度、缩短了上下文、控制了batch,但只要你把其中一项放宽,显存就会立刻炸掉。
4.2 量化降的是权重,不是所有中间产物
量化模型能大幅降低权重占用,这是好事。比如一个70B模型,FP16权重要140GB,INT4后大约35GB,这差距非常可观。但要注意,量化只压缩静态参数。KV Cache、中间激活值、算子临时缓冲区,绝大多数情况下还是以FP16或BF16的精度计算,不会因为权重变成INT4就自动减半。
“NVFP4量化显存要求”这类说法,如果只标了权重占用,那是不完整的。你看到的“需要6G/8G显存”,很可能只算了量化权重,没算KV Cache和推理框架的compute buffer。实际部署时,8G卡跑一个权重只占4G的模型,依然可能因为上下文太长、batch太大而OOM。
低显存部署第一步不是“调节多少层量化”,而是先明确自己的上下文长度和batch。把KV Cache上限确定下来,再反推能放得下多少权重。否则很容易陷入“量化好了,但一加长上下文就崩”的循环。
4.3 低显存跑模型的正确姿势:算峰值,然后给峰值留15%以上
我自己在6G和8G卡上跑模型,习惯按下面顺序估算:
- 确定目标上下文长度,算出KV Cache上限。
- 估算推理框架的临时缓冲区,通常按1GB到2GB预留。
- 用量化后权重大小往显存里套。
- 三者相加后,再除以0.85,相当于至少留15%缓冲。
如果算出来超过显卡容量,优先砍上下文、砍batch,而不是继续强行把权重压得更狠。权重压到4bit之后,再往2bit压,精度和稳定性都会明显受影响,反而可能触发更多推理bug。在低显存卡上,“够用但紧张”从来不是很好的状态。Model只要能跑,步调还稳,比硬塞进去天天OOM强得多。
5. 一份可以直接抄的 OOM 排查链路
5.1 先确认报错来自哪一层
遇到OOM时,立刻区分三种情况:
- CUDA层报错:
CUDA out of memory,但进程还在,只是无法继续申请。 - 系统内存层报错:
RuntimeError: Cannot allocate memory,可能是宿主机CPU内存不够。 - 进程被直接杀死:
Killed,这种情况多半是系统OOM Killer动手了,不一定是显存。
如果是第三种,去看看系统内存和swap。特别是在offload场景下,几十层模型被塞进CPU内存,CPU内存本身不够时,GPU任务会直接连同进程一起被干掉。表现形式很像是“显存莫名其妙OOM”,实际根因完全不在GPU上。
5.2 查进程,别放过“看不见的占用者”
在共享租用的GPU服务器上,OOM可能是别人的进程造成的。先看全局:
nvidia-smi如果有权限,再查具体计算进程:
nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv这里有个细节:容器内PID命名空间隔离,你在容器里看到的PID未必是宿主机的真实PID。但至少能判断出“这张卡上有多少个计算进程”。如果发现一个不属于你的进程占着一半显存,那OOM的原因就找着了。这时候不是优化代码的问题,而是需要跟服务商确认这张卡是不是真的独占。
5.3 用PyTorch内存报告,区分“真不够”和“假不够”
跑代码前,先执行:
import torch print(torch.cuda.memory_summary(device="cuda:0"))把输出里的Reserved和Allocated对比一下。如果Reserved明显大于Allocated,优先试试empty_cache,再配合PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True跑一次。
如果是“真不够”,memory_summary里通常会显示当前申请的最大块大小,然后告诉你分配失败。这个失败信息特别有用——注意它给出的数字,比如“Tried to allocate 4.00 GiB”。如果你看到申请的是4GB而显存只满到80%,那大概率不是总容量问题,而是碎片或Reserved占用问题;如果你申请的是一个接近剩余总量的数字,那才是显存不足。
5.4 用“减半法”锁定谁是凶手
当模型各种参数混在一起时,最快定位峰值来源的办法是逐个减半:
- 把batch size减半,如果OOM消失,说明激活值或batch相关缓冲区是主因。
- 把上下文长度减半,如果OOM消失,KV Cache是主因。
- 把并行度或者显存里的层数减半,如果OOM消失,权重加载是主因。
- 把推理框架的n_batch调小,如果OOM消失,compute buffer是主因。
每一步变化对应不同的显存消耗项。这个方法看起来笨,但比盯着nvidia-smi猜要快得多。一次只改一个变量,问题边界很快就清楚了。
5.5 给PyTorch设一个显存上限“护栏”
如果你在租用的共享卡上跑服务,不想因为自己的进程吃满显存而影响别人,也不想被别人影响,可以用PyTorch的进程级显存比例:
import torch torch.cuda.set_per_process_memory_fraction(0.9, device=0)意思是当前进程最多使用该GPU显存的90%。好处是让分配器更谨慎,不会过度预留;坏处是如果你原本正好需要95%,那会有更多OOM。这个值更适合服务型任务,而不是训练调试。
6. 租用GPU服务器,怎么提前把显存余量算明白
6.1 租卡之前先按峰值算一次“最少显存”
我不建议只看“这模型要20G显存,所以我租24G卡”。正确的做法是把所有运行时峰值都算出来。下面这个简化估算可以当作模板:
params_b = 7 # 参数量,单位:十亿 weight_gb = params_b * 2 # FP16权重 kv_gb = 4 # 目标上下文对应的KV Cache,按你的场景估 act_gb = 1.5 # 激活和临时Buffer,推理一般留1~2GB peak = weight_gb + kv_gb + act_gb safe = peak / 0.8 # 留20%余量 print(f"峰值约 {peak:.1f} GB,建议租用显存 >= {safe:.1f} GB")如果是训练,把优化器和梯度也加进peak。训练时的显存峰值往往比推理高好几倍,千万不要拿推理的估算去卡训练。
6.2 共享型租卡的三个提前问句
租GPU服务器时,我建议在下单前问服务商三个问题:
- 这张卡是独享还是共享?nvidia-smi里能不能看到其他人的进程?
- 容器内能申请到的显存上限是多少?有没有MIG/vGPU切片限制?
- CPU内存给多少?系统盘和swap是否够用于模型offload?
这三个问题能避免绝大多数“明明有显存却OOM”的诡异场景。很多人把锅甩给服务商,最后发现是容器默认限制了显存,或者卡本身被别人占了。
6.3 多卡服务器上,用CUDA_VISIBLE_DEVICES隔离你自己的进程
如果你租到的是8卡机,优先用环境变量把进程绑定到指定卡,避免多个任务因为默认分配逻辑互相挤占:
export CUDA_VISIBLE_DEVICES=0,1如果服务商支持计算模式设置,也可以把卡设为独占模式,防止其他租户强行分食:
nvidia-smi -c EXCLUSIVE_PROCESS需要root权限,所以只有租用独立服务器时才用得着。容器的场景里,容器runtime一般已经做了隔离,不需要也不希望你直接改宿主机的GPU设置。
6.4 监控显存时,别信“瞬时空闲”,要看时间曲线
运行时可以用:
watch -n 1 nvidia-smi重点关注显存使用量的趋势。如果它在一个推理循环里反复上升到同一个峰值后回落,那是正常波动;如果它一步步爬升但不回落,那就是显存泄漏。泄漏的典型来源是循环里随手创建的张量没释放、缓存列表越攒越多、或者某个框架的静态缓存没有及时清理。
曾经排查过一个服务,用户抱怨“跑久了必OOM”,问题是每次推理逻辑里,中途生成的中间结果被追加到了一个全局list里。看起来每个item很小,但几千轮之后,所有激活值全留在显存里。nvidia-smi每次空闲时看着都正常,只有把时间线拉长才看出问题。这个教训总结成一句话:显存余量是快照,不是承诺。
6.5 让我留在最后的一条个人经验
我现在判断一台租用GPU服务器能不能跑某个任务,早就不看“当前剩余多少显存”了。我会把任务分成三个数字:权重需要多少、峰值需要多少、运行环境需要多少。前两个是模型决定的,第三个是框架和容器决定的。只有三者都满足,我才敢说“这台机器稳”。如果只是勉强塞进去,那说明你离下一次OOM只差一个更长的上下文或者更大的batch。
这也是为什么我强烈建议在低显存卡上跑模型时,把余量当成“保险”,而不是可压榨的空间。真等到只剩一两G才想起来优化上下文,往往已经来不及了。