资讯详情

显存还有余量却OOM?GPU推理显存排查与优化实战

📅 2026/11/2 12:39:58 | 华诺云谱 👁 阅读
显存还有余量却OOM?GPU推理显存排查与优化实战
去年帮朋友排查一个GPU租用平台上的OOM问题过程相当折腾。他在跑一个推理服务nvidia-smi里明明还剩6GB多显存程序却报CUDA out of memory整个服务直接崩掉。他反复跟我强调“显存有余量啊怎么还会OOM”这类疑惑在做模型部署的人里非常普遍而这背后往往不是单点问题而是好几层因素叠加在一起你看到的剩余显存不一定是能用的显存分配器碎片让大块内存申请失败推理过程中上下文还在动态增长甚至同卡上还有其他租户的进程在抢占。这篇文章我会结合GPU服务器租用场景把“显存有余量但仍OOM”这个现象拆开讲。你会看到它跟显存监控、CUDA上下文、内存分配器、KV Cache、MoE模型架构、多租户共享都有关系。最后我会给出一套我自己常用的排查流程和可复现的验证方法希望能帮你少走弯路。1. 显存有余量却OOM先分清“三个显存”1.1 nvidia-smi看到的余量不是程序能用的余量很多人一遇到OOM就打开nvidia-smi看显存占用然后得出结论“还剩8G啊没问题。”问题恰恰出在这里nvidia-smi顶部显示的是整张GPU的物理显存状态不是你的CUDA进程能直接分配到的剩余量。GPU服务器租用环境里这个差异会更明显。比如我朋友那台卡上虽然显示剩余6GB但那个数字可能是“物理总量减去当前所有进程占用”的结果而CUDA程序在申请显存时不仅要看总量够不够还要看驱动侧有没有足够的可用分配空间、当前CUDA上下文是不是已经被某个库占掉一块、PyTorch或TensorFlow的缓存分配器是否已经提前“锁”住了一大块。最容易让人误判的是PyTorch的caching allocator。默认情况下PyTorch会一次性向驱动申请一大块显存然后自己内部切成小块分给张量。程序里释放了某个张量PyTorch未必立刻把显存还给驱动而是留在自己的缓存池里nvidia-smi里看到的“占用”依然很高。反过来如果你的程序在运行中突然需要一块比缓存池剩余连续空间更大的内存PyTorch会尝试再向驱动申请但驱动侧可能因为预留、上下文、其他进程等原因拒绝即使nvidia-smi里显示还有好几个GB的空闲。还有个容易被忽略的点CUDA上下文本身就会占显存。第一次初始化CUDA时驱动会为当前进程建立上下文这个上下文的大小跟驱动版本、库版本、GPU型号有关几百MB到1GB都很常见。它不会因为“没有张量”就释放所以在某些监控面板里你可能会看到自己的服务一启动就占了1.5GB显存什么模型都还没加载。1.2 报错类型不一样排查方向完全不一样“OOM”这个词在GPU场景下其实至少对应三种不同的报错排查方向差很远。第一种是CUDA侧的CUDA error: out of memory这通常是cudaMalloc或cudaMemcpy分配失败也就是显存真的不够分配了。第二种是std::bad_alloc或Python里的MemoryError这可能是系统内存不足也可能是统一内存模式下CPU端内存耗尽。第三种是进程被操作系统直接kill掉日志里出现“Killed”字样这在很多设备监控里也会被粗略写成OOM但实际是整机内存不足不一定是显卡问题。我遇到过不少案例nvidia-smi里显存明明够但进程还是被kill最后查下来是主机内存爆了。尤其是用了CUDA managed memory统一内存或者某些框架默认开启shared memory特性时GPU内存和系统内存会联动GPU显存不够时系统内存来凑凑着凑着主机内存就崩了。所以拿到OOM报错第一件事不是去看显存而是先看报错来自哪一层、谁的日志。这里有个习惯值得养成定期记录nvidia-smi完整输出的同时也记录free -h和/proc/meminfo。很多一次性崩溃现场事后复盘时发现系统内存才是真凶。2. 第一个大坑上下文越长显存越吃越多2.1 KV Cache在读代码里最容易被忽略很多人估算显存时只会算“模型权重多大”比如7B模型FP16权重14GB那我12GB显存跑不了得用8GB的量化版。这种估算在加载模型那一瞬间是对的但在推理过程中完全不够因为你漏掉了KV Cache。Transformer模型做自回归生成时每生成一个token都要重新计算注意力。为了不把前面所有历史token从头再算一遍推理框架会把历史上每个token的Key和Value缓存下来这个缓存就是KV Cache。它的特点是随序列长度线性增长上下文越长KV Cache越大。而且只要你的程序不主动释放它会一直累积到该次生成结束。举个例子一个7B规模、32层、KV头数为8的模型如果用半精度每个元素2字节推理每新增一个token大约会增加0.15MB量级的KV Cache具体数值跟层数、头数、每个头的维度有关。单看这个数字不大但生成2048个token就是约300MB如果上下文窗口是8192甚至32768KV Cache轻松吃掉1GB到4GB这还没算上模型权重和中间激活值。所以最简单的“显存有余量却OOM”场景是这样的模型加载时显存很宽松剩了好几个GB但是你的prompt很长、max_new_tokens又设得很大程序一边生成一边累积KV Cache跑到某个时刻终于把剩下的显存吃光然后崩了。2.2 用公式算清楚自己要预留多少要避免这种“运行到一半才炸”的情况最好在加载模型之前就把峰值显存算出来。推理场景的显存需求大致可以用这个公式预估峰值显存 ≈ 模型权重大小 KV Cache 激活值/临时缓冲区 框架预留模型权重大小容易算参数量 × 每个参数的字节数。FP16或BF16是2字节INT8量化大约1字节4比特量化大约0.5字节。KV Cache可以用这个简化公式估算KV Cache字节数 ≈ 2 × 层数 × 序列长度 × KV头数 × 头维度 × 精度字节数这里乘2是因为Key和Value各缓存一份。实际操作中你大概率不知道每个参数的具体值那也没关系直接跑一个短序列任务观察在只有对话历史、还没开始生成时的显存占用再统计每个token新增多少显存然后用“每token新增显存 × 目标序列长度”去估算峰值。这个“实测每token占用”比理论公式更可靠因为它已经把注意力头分组、激活重计算这些细节都包含了进去。2.3 线上部署时怎么防止KV Cache“爆炸”这个问题在API服务里尤其致命。线上服务不可能限定用户只聊两句话同一个会话可能攒下几千甚至上万token的历史每次请求进来都要重新构建KV Cache。如果不设上限多个并发请求叠加OOM是迟早的事。我自己的做法是三层防御。第一层在服务端硬限制最大上下文长度和max_new_tokens比如最多允许4096 token的输入、生成上限512 token。宁可用户抱怨“回得短”也好过服务挂掉。第二层在框架层面开启PagedAttention或类似的显存管理机制把KV Cache分页化只保留当前需要访问的页面减少浪费。第三层做好请求排队和并发数控制不要让所有请求同时冲进一张卡。这不是调度器该管的事但部署脚本里加一个简单的信号量或流量限制能救回很多显存。3. 第二个大坑显存“看起来够”其实被碎片卡死3.1 GPU显存分配器的工作方式决定了碎片不可避免这是“显存有余量但OOM”最经典的形态。你打开nvidia-smi物理显存明明还剩6GB但程序尝试申请一个5GB的连续显存块还是失败了。原因不是总量不够而是这6GB的空闲显存被分割成了很多小块没有一段连续的5GB空间。碎片化是所有内存分配器都要面对的问题GPU侧更严重。GPU显存的分配以块为单位进行PyTorch的caching allocator会在第一次分配时向驱动申请一大块显存然后在内部按需切分成更小的块。问题是当你释放其中一部分张量后空出来的空间往往是零散的有的块只剩几百MB有的块在中间被占着。如果释放的空闲块彼此不连续分配器无法把多个小块合并成大块那么即使空闲总量够大也无法满足一次大块分配请求。这里有个很反直觉的地方显存空闲的总量是够的但“没有连续的空闲段”。你可以理解成停车场里明明有很多空位但都是单个的零散车位你要停一台加长车停不进去。我曾在一次文本向量化服务里踩过这个坑。服务每隔几秒处理一批文本每个批次生成中等尺寸的向量张量处理完就释放。跑几个小时以后某个较大的批次突然OOM但nvidia-smi显示显存还剩不少。后来用torch.cuda.memory_summary()一看total reserved和total allocated之间有巨大差距而且空闲块数量非常多典型的碎片化症状。3.2 复现碎片化OOM的实验想直观理解碎片化可以在本地或租用的GPU服务器上跑一个简单实验。下面这段PyTorch代码演示了“总量够但连续内存不够”的OOMimport torch # 假定显存总量接近 8GB可保守分配约 7.5GB chunks [] total_free 7500 # 预计可分配 MB按卡实际情况调整 # 第一步申请大量中等大小的显存块把空间切碎 size_mb 200 while total_free 0: try: chunk torch.empty(int(size_mb), 1024 * 1024 // 4, dtypetorch.float32, devicecuda) chunks.append(chunk) total_free - size_mb except RuntimeError: break # 第二步释放一部分形成零散空位 for i in range(0, len(chunks), 2): chunks[i] None torch.cuda.empty_cache() # 第三步尝试申请一个非常大的连续块预期会失败 try: huge torch.empty(int(3000), 1024 * 1024 // 4, dtypetorch.float32, devicecuda) print(成功分配 3000MB) except RuntimeError as e: print(碎片化导致 OOM, e)这段代码在显存越大的卡上越能体现问题。释放一半后的空闲总量很可能超过3000MB但因为空闲段不连续最终尝试申请3000MB大块时会失败。实际项目里大块分配的需求通常来自超大batch、长序列的KV Cache、或者一次很大的loss计算它们对连续空间的要求远比中等张量高。3.3 实际项目里怎么缓解碎片化碎片化没法完全消除但可以大幅缓解。最常见的手段是设置PyTorch的分配器参数export PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128max_split_size_mb控制分配器在缓存池中允许切分的大块尺寸上限。把它调小可以减少大块被切成碎块的情况但代价是分配器管理压力变大。调大则相反大块更容易保留但碎片化可能更严重。另一个参数是expandable_segments:True它让PyTorch优先使用虚拟内存而不是物理连续内存来扩展显存段对长序列推理场景比较友好但对部分卡和驱动版本有兼容性要求。我的习惯是先在单卡小规模上测试确定支持后再上生产。更稳妥的工程方案是从生命周期上规避碎片。如果你有一个长期运行的服务最好在启动时就预先计算好峰值显存需求一次性以大块方式分配然后在这个buffer内部自行管理。这其实就是很多推理框架的做法比如vLLM的显存池就是这样干的避免了频繁申请和释放导致碎片化加剧。4. 第三个大坑MoE模型的“稀疏”骗了你4.1 稀疏激活不等于稀疏占用这几年MoE架构的模型特别多网上也经常有人讨论“MoE模型推理时只激活部分专家是不是可以大幅降低显存需求”。正确答案是不能。常规MoE模型虽然每个token在推理时只会路由到少数几个专家但“路由”发生在模型已经加载到显存之后。也就是说加载模型的那一刻所有专家的权重都已经进入了显存没有参与计算的专家并不会被动态卸载。所谓稀疏激活省的是计算量不是存储量。这在数学上很简单模型文件有多大加载就要占多大区别只在于权重精度和量化方式。很多人在低显存环境下看到“MoE模型、稀疏激活”就以为8GB显存能轻松跑几十B的模型结果一到加载就OOM。真相是MoE模型的存储瓶颈和Dense模型一样都在“全部参数进显存”这一步唯一的出路是量化、剪枝、层间卸载或CPU offload。当然MoE也不是完全没有优化空间。在推理引擎层面一些实现会把不被路由的专家权重offload到CPU内存需要时再搬到GPU这叫“专家卸载”。它确实能显著降低GPU显存占用但代价是路由到CPU专家时会有额外的PCIe传输延迟生成速度会变慢。所以如果看到某个低显存整合包能跑MoE大模型多半是用了量化专家卸载的组合而不是“只加载少数专家”。4.2 低显存跑大模型的落地方法量化与拆分社区常说的“8G显存低参数运行”“6G显存部署”本质都是在压缩上面那个显存公式里的“模型权重大小”这一项。现在最流行的做法是GGUF格式加4比特量化。比如常见的Q4_K_M量化档每个权重大约只需要0.5字节一个7B模型压缩后大约4GB8GB显存就可以勉强跑起来。如果连4GB都放不下还可以用Q2、Q3这类更低比特量化的档位但代价是质量损失和推理速度下降。新一代模型也开始支持NVFP4这类4比特浮点格式它在LLM推理链路中比传统整数量化更稳不少推理框架已经跟进支持。如果你是租GPU服务器跑模型建议优先选择官方或社区社区维护的量化版本而不是自己强行转换因为前者的校准数据和评测通常更完善。如果量化后还是放不下下一步是拆分把模型前几层放到GPU后几层放到CPU由推理库按需搬运。这种“层间offload”方式能把GPU显存压到很小比如8GB跑十几B模型但速度会明显打折。我这里提醒一句租GPU服务器本来就是为了要性能CPU offload应该是最后的手段而不是默认方案。4.3 8G、6G、12G能否运行模型先做这道算术题很多人拿着“RTX 3060 12G能不能跑Minimax H3”“8G显存能不能跑某模型”这类问题问我其实关键不是型号而是算数。拿到一个模型先查三件事参数量是多少、推理精度是多少FP16还是4bit、推荐上下文长度是多少。然后按我之前给公式算一遍。举几个典型例子。假如一个模型是7B用FP16加载就是14GB12G卡肯定放不下必须量化到4bit约4GB这时12G卡就非常宽裕。假如模型是13B4bit约7GB再加上KV Cache和激活8G卡会非常极限大概率跑不了长序列12G卡如果限制上下文长度可以试试。假如模型是32B级别4bit约16GB12G卡就算offload也吃紧更建议租更大的卡或者32G版本。关键判定点是“模型量化后权重 目标长度KV Cache 激活值余量”是否小于你实际可用的显存而不是只看卡名。PyTorch社区常用一个经验值至少给模型权重留出10%~20%的余量给KV Cache和临时buffer。5. 第四个大坑你租的卡上不止你一个人5.1 别把别人的进程当成自己的问题GPU服务器租用和自购机最大的区别就是你可能不是这张卡唯一的用户。尤其在一些按小时计费、多租户共用的平台上同一张GPU可能同时跑了几个人的任务大家互相看不见对方的代码但共享同一份显存。这时候“显存有余量却OOM”往往很简单你看的时候有空闲但别人的任务正在申请内存抢在你前面了。或者更隐蔽有些平台的监控服务本身也会在GPU上占一点显存。排查时第一步就是用nvidia-smi看进程列表确认有没有除你之外的进程在占用。nvidia-smi -p可以按进程列出占用的显存nvidia-smi -L可以查看GPU编号和UUID防止你盯着错误的卡号调参。有些平台把多张卡挂在一个节点上物理顺序和逻辑编号经常对不上你以为自己在看第2张卡实际那张卡上跑的是别人的任务。更进一步可以用nvidia-smi topo -m查看GPU之间的拓扑连接判断你的任务是不是真的跑在了目标卡上。某些分布式框架会在初始化时自动选择“看起来最好”的卡结果所有进程都挤到一张卡上其他卡反而空着。5.2 统一内存与系统内存的OOM还有一种共享内存型的OOM最容易让人误判成显卡问题。某些平台或设备让GPU直接使用系统内存作为显存或者启用了CUDA的统一内存机制这时nvidia-smi里看到的显存占用可能很低但程序却因为系统内存耗尽被kill。举个例子有次我在一台设备上跑视频模型推理GPU的专用显存很小大部分靠共享内存。程序起来后nvidia-smi显示GPU占用只有1.2GB可跑着跑着整个进程被系统OOM kill了。原因就是视频解码buffer、图像数据缓冲、中间特征全都在系统内存里堆积操作系统连CPU内存都扛不住了。遇到这类情况不要只盯着nvidia-smi要把free -h、/proc/meminfo一起纳入监控。如果系统内存在持续上涨后突然归零而进程消失基本就可以判断为系统内存OOM。5.3 共享卡环境的自查操作在共享GPU环境里我建议每次开始刷任务前做一次“环境快照”。把下面这些信息记录下来再跑nvidia-smi完整输出记录总显存、已用、空闲和进程数。free -h记录系统内存状态。你的任务的进程PID确认它跑在哪张卡上。跑起来后再用一个简单的循环脚本每10秒记录一次显存曲线。这样做的好处是等OOM发生时你能立刻对比“前几分钟是谁在申请显存”不会两眼一抹黑。很多共享平台自带监控面板但面板刷新频率低可能看不到1分钟内的峰值自建脚本更可靠。6. OOM定位实战从看显存到找到真凶的完整流程6.1 拿到准确的内存视图遇到OOM我第一步永远是拿到准确的内存视图而不是猜。对PyTorch用户torch.cuda.memory_summary()是最直接的入口它会列出当前进程的allocated、reserved、active等各项指标还能看到空闲块总量和最大空闲块大小。很多“显存有余量却OOM”在这里都能一眼看出来allocated很小reserved很大最大空闲块太小。对非PyTorch或用CUDA原生开发的程序可以用一个小工具测一下当前还能分配多少显存import torch free, total torch.cuda.mem_get_info() print(f当前可用: {free / 1024**3:.2f} GB) print(f物理总量: {total / 1024**3:.2f} GB) # 测试能否分配一块接近空闲显存的连续块 try: test torch.empty(int(free * 0.9), dtypetorch.uint8, devicecuda) print(分配测试成功空闲显存基本可用) del test except RuntimeError as e: print(即使空闲显存看起来够也无法分配大块, e)这段代码能区分“总量不足”和“碎片化导致分配失败”。如果mem_get_info()返回的空闲量很大但分配测试失败说明问题是碎片化或驱动侧虚拟地址空间不足。6.2 二分法定位申请点拿到准确视图后接下来要定位是哪段代码在申请显存。我的习惯是用二分法注释代码先把整个任务流程分成几个大阶段数据加载、模型加载、推理、后处理在每个阶段前后打印显存占用。然后锁定增长最快的阶段再在该阶段内部用二分法缩小范围。一个特别常见的定位结果是问题不在模型而在数据预处理。比如视频任务里一帧一帧往GPU传图像时如果没及时释放中间张量显存会一点点被吃光。等到几十帧积累下来突然来一个大模型的推理请求直接OOM。这种“慢性增长后突然爆炸”的曲线结合前面说的显存监控脚本很容易识别。另一种常见结果是某个库在后台偷偷持有张量。比如损失函数里保存了中间结果没释放或者某个用于日志的tensor被意外挂到了全局变量上。二分法能帮你快速锁定这类“显存黑洞”。6.3 用分配器参数和模型配置做最后的收复定位到问题后如果代码逻辑没有明显bug就需要从分配器参数和模型配置层面做回收了。PyTorch用户的几个环境变量非常实用export PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:64 export PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True export PYTORCH_CUDA_ALLOC_CONFgarbage_collection_threshold:0.8garbage_collection_threshold设定在多少占用率时主动回收空闲块能在长时间运行的任务里减轻碎片积累。模型层面的配置也很关键。推理时优先确认这些参数max_new_tokens、max_length、batch_size、num_beams束搜索会把显存放大几倍。如果不需要高质量生成把num_beams设为1能立刻省下一大块显存。训练或微调时打开gradient_checkpointing用混合精度、减少batch size都是比升级显存更快的方案。如果你是在租GPU服务器节点本身往往支持热升级但升级前一定要先确认是“真不够”而不是“分配不合理”。我见过课题组租了A100后因为没开梯度检查点照样OOM后来多花了钱问题却没解决非常亏。6.4 常见问题速查表现象可能原因解决方案显存显示剩余很多但大块分配失败显存碎片化设置allocator参数减小max_split_size_mb启用expandable_segments程序运行一段时间后才OOMKV Cache增长或中间张量泄漏限制上下文长度和max_new_tokens监控每token显存增量启动即OOM剩余显存很少模型权重或CUDA上下文占用换量化版本减小batch_size检查CUDA上下文占用进程被kill但nvidia-smi显存正常系统内存OOM或统一内存耗尽检查free -h关闭shared memory特性减少CPU内存占用多卡任务时某张卡OOM其他卡空闲进程全挤到一张卡用CUDA_VISIBLE_DEVICES固定卡号检查nvidia-smi topo -m显存占用居高不下但任务正常PyTorch缓存未释放给驱动不用处理这是allocator缓存可设置garbage_collection_threshold回收MoE模型加载时OOM所有专家权重都需进显存量化、专家卸载、降低上下文长度7. 我踩过的一些坑最后再交代几句其实文本开头那个朋友的案例最后定位下来是三层原因叠加他那台共享GPU上有一个残留进程占了2GB显存他的推理服务没限制上下文长度导致KV Cache持续增长再加上PyTorch的缓存分配器把剩余空间切得很碎。三个问题单看都不严重叠在一起就成了“显存明明还剩6GB却OOM”的灵异事件。这大概是所有显存OOM排查的共同规律很少有单一致命原因绝大多数是多个小问题叠加的结果。所以别一看nvidia-smi有剩余就下结论也别一上来就换卡加钱。先做一次完整的显存视图检查确认碎片、KV Cache、其他进程、系统内存这四件事都没问题再考虑是不是真的需要更大的卡。最后分享一个小技巧我习惯在所有部署脚本里把GPU服务器的显存监控做成“启动即记录”的模式每5秒写一行占用日志。这样即使OOM发生在凌晨三点第二天起来也有完整的数据链可以复盘而不是对着一个崩溃报错干瞪眼。显存这种东西数据永远比直觉靠谱。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑