1. 显存溢出的直接原因:模型的显存账本远比你想的复杂
做模型部署的人大概都遇到过这种情况:本地单测好好的,模型加载完nvidia-smi一看显存占用几个G,心里还挺踏实。结果接口一上线,压测工具并发打个二三十个请求,服务直接报CUDA out of memory,进程挂掉,容器直接被 OOM Killer 干掉。更头疼的是,重启之后单独请求又正常了,看起来就像“玄学”。
先把这个账算清楚。很多人以为模型显存占用量等于模型权重大小,这是一个特别危险的误解。实际上,一次 GPU 推理的显存开销由三部分组成:模型权重与优化器/缓存结构、KV cache(如果做生成式推理)、前向传播过程的中间激活值(activation)。举个实际例子,你用 PyTorch 加载一个 7B 参数的模型,FP16 权重大约占 14GB,看起来一张 24GB 的卡完全够用。但单条 prompt 推理时,中间激活值可能额外吃掉 2–4GB,如果输出序列很长,KV cache 又是随 token 数线性增长的。你以为的“24GB 够用了”,其实安全余量没剩下多少。
而一旦并发请求同时进来,问题就从“线性”变成“乘法”。每个并发请求都会在显存里创建独立的中间激活张量,夸张点说,10 个并发请求可能瞬间多出 20–30GB 的临时显存需求。PyTorch 的 CUDA caching allocator 会提前向驱动申请大块显存并缓存释放块,当多个请求交替分配和释放不同形状的张量时,还会加剧显存碎片化,明明看着总量够,却分配不出一个连续大块。这就是为什么单测永远没问题、一上并发就爆炸的根本原因——你事先没有给“并发×峰值显存”的乘积留预算。
再补一个底层知识点:CUDA context(上下文)是进程级的,第一次调用 CUDA API 时就会初始化,每个进程大概固定占用几百 MB 到 1GB 左右的显存(取决于驱动和 CUDA 版本),这部分是没法省的。所以如果你用多进程方式部署,每个 worker 都会有一份独立的 CUDA context 叠加在显存上。这点在后面的 worker 数量设计上很关键,先记住。
另外,显存还有一种“假性占用”。模型跑完之后,nvidia-smi里看到的显存占用往往不会立刻降下来,因为 PyTorch 的缓存分配器会把释放的显存留在自己的缓存池里,方便后续复用。这不代表你的服务还在泄漏,只代表可用显存余量没有你以为的那么多。后面我会提到用torch.cuda.memory_stats()看真实活跃分配,而不是只看nvidia-smi。
2. 控制并发的三种常用方案与最终选型
既然根因是“并发请求带来的显存需求叠加”,最直接的解法就是限制同一时刻进入 GPU 推理的请求数量。实现思路基本有三条路,我挨个说下实际用下来的感受。
2.1 不加限制,依赖单卡显存硬扛
很多框架自带的批处理(如 vLLM 的 continuous batching)会在推理引擎内部做动态批处理,你只管往里塞请求,引擎自己调度。但如果你只是用原生 PyTorch 或 HuggingFace Transformers 包一层 FastAPI,模型本身不具备排队和动态合批能力,请求并发进来就是各算各的,显存开销直接线性叠加。这一条只适合显存极富余、单次推理显存占用很小的场景,比如一个小分类模型跑在 A100 80G 上,那确实不用怎么操心。但对于大模型推理,这不是方案,是赌运气。
2.2 用队列(Queue)把请求串行化
思路是把所有推理请求丢进一个队列,后端单线程 worker 依次消费,天然实现“同一时间只有一个请求在跑”。好处是显存占用可控到最低,坏处是吞吐上限被死死压在单请求延迟上。比如一次生成式推理耗时 2 秒,那系统吞吐最高就是 0.5 QPS,再多的并发也只能排队。对离线任务这没问题,但如果你做的是在线服务,用户拖个滑块用一次模型就要等前面几十人排完,体验基本是灾难。队列串行的本质是拿吞吐换显存安全,适合离线批量任务,不适合在线交互式服务。
2.3 用信号量(Semaphore)限制并发数
最终我采用的是信号量方案。思路是在 FastAPI 的推理接口入口设置一个全局并发闸门,最多允许 N 个请求同时进入 GPU 推理阶段,多余的请求在await处挂起等待。这和队列不一样的地方在于,它不是把请求串成一个队,而是允许少量请求并行(比如 4 个),让它们共享 GPU 的计算能力。如果单 batch 的 batch size 是 1,那 4 个并发其实就是 4 个独立 batch 一直在 GPU 上跑,吞吐接近串行的 4 倍,同时显存预算可控。
这里有个非常关键的工程细节:FastAPI 的异步事件循环里不能直接跑同步的 GPU 推理代码,否则一个推理请求的 CPU 部分会把整个事件循环卡住,其他所有请求全部阻塞。正确做法是把推理函数放进线程池执行,同时信号量要选对类型:
- 如果推理函数是同步的、跑在线程池里,用
threading.Semaphore(线程级信号量),因为多个线程同时竞争这个闸门。 - 如果用
asyncio.to_thread或run_in_executor包裹推理,还是threading.Semaphore。 - 千万不能用
asyncio.Semaphore直接包同步推理代码,它只会限制异步任务的调度,阻止不了线程池里多个线程真的抢到 GPU 执行权。
我见过有人把asyncio.Semaphore写在同步推理函数外面,结果该爆显存还是爆。原因就是同步代码跑在线程池中,asyncio 的信号量管不到线程池里的并发。这个坑不踩一次真的容易想当然。
考虑到我自己的生产环境里还有可能把推理丢给外部推理引擎(比如 FastAPI 作为网关去请求 Ollama 或 LocalAI 推理引擎),这种场景下并发控制其实是由引擎自己负责的,FastAPI 侧只需要做连接池和超时控制,不需要动信号量。但如果推理引擎是外部进程,你同样会遇到上游并发打满、显存被撑爆的问题,这时候要么信任引擎内部的调度器,要么在你的网关上限制并发放量,逻辑和进程内是一样的。
最终我的选择是:普通模块里定义一个全局的BoundedSemaphore,推理接口用asyncio.to_thread包装同步推理函数,信号量在函数内部获取。下面一节具体说实现。
3. 信号量控制的推理接口:完整实现与并发上限计算
3.1 并发上限怎么算:先算预算,再写代码
很多人上来就拍脑袋定并发数等于 4 或者 8,结果要么浪费显存,要么跑两天就 OOM。正确做法是先算一笔账。
假设你的 GPU 总显存是M_total,PyTorch 加载模型并做完预热后的稳态显存占用(包括权重、CUDA context、缓存池)是M_base,单次请求推理时显存峰值是M_peak(这个值要通过实际测试拿到,测的方法是并发 1 个请求时盯着torch.cuda.max_memory_allocated()看)。那么理论上最大并发数:
N_max = int((M_total - M_base - M_reserve) / M_peak)其中M_reserve是你手动留出的安全余量,至少预留总显存的 10%–15%。为什么一定要留?因为生成式推理的M_peak和输入输出长度强相关,你测试时用的是 128 token 的输出,线上用户可能一次生成 1024 token,激活值和 KV cache 直接翻好几倍。用“最大可能输出长度”的峰值来算并发数,不要用平均值。
举个例子:一块 24GB 的 4090,加载 7B 模型 FP16 后M_base约 15GB(数值因实现而异),预留 3GB,剩下 6GB。如果单请求峰值约 2GB,那N_max就是 3。听起来有点少,但这意味着任意时刻最多有 3 个请求在 GPU 上跑,每个请求的峰值即使涨到 6GB,累计 18GB,加上基础占用 15GB 正好 33GB,超过 24GB 还是会炸。所以更稳妥的做法是取N_max = 2甚至动态降级。算完之后你才会理解,同时并发数往往比你想的小得多,这不是耻辱,这是硬件边界。
3.2 代码骨架:BoundedSemaphore + asyncio.to_thread
核心代码逻辑如下,注意信号量定义的位置和获取方式:
import asyncio import threading import torch from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() # 全局信号量:限制同一时刻进入GPU推理的线程数 GPU_SEMAPHORE = threading.BoundedSemaphore(2) # 根据上面的预算计算得出 model = None tokenizer = None def load_model(): global model, tokenizer # 读入模型并切换到 eval 模式 model = ... # 你的模型加载逻辑 model.eval() model.to("cuda") # 预热:跑一次极小输入,触发CUDA初始化并建立缓存池 with torch.no_grad(): dummy_input = ... model(dummy_input) torch.cuda.synchronize() print("model loaded and warmed up") @app.on_event("startup") async def startup_event(): # 注意:模型加载放线程池,避免阻塞事件循环 await asyncio.to_thread(load_model) class PredictRequest(BaseModel): prompt: str max_tokens: int = 256 def _infer(prompt: str, max_tokens: int) -> str: """同步推理函数,由线程池执行,入口用信号量控制""" with GPU_SEMAPHORE: # 这里做真正的推理 input_ids = tokenizer(prompt, return_tensors="pt").to("cuda") with torch.no_grad(): output = model.generate(**input_ids, max_new_tokens=max_tokens) return tokenizer.decode(output[0], skip_special_tokens=True) @app.post("/predict") async def predict(req: PredictRequest): try: # to_thread 把同步推理放到线程池,线程池内由 BoundedSemaphore 限流 result = await asyncio.to_thread(_infer, req.prompt, req.max_tokens) return {"result": result} except RuntimeError as e: if "out of memory" in str(e): # 触发OOM时清理缓存并返回错误码 torch.cuda.empty_cache() raise HTTPException(status_code=503, detail="GPU busy, please retry") raise几个细节我强调下:
- 信号量用
BoundedSemaphore而不是Semaphore。BoundedSemaphore在释放次数超过获取次数时会抛异常,能帮你及时发现代码里的acquire/release配对错误,别问我是怎么知道的。 - 模型加载放到
asyncio.to_thread里。否则启动时模型加载那几十秒会直接卡住健康检查,K8s 探针一失败就疯狂重启。 - OOM 异常捕获不能只放在接口层。如果你的推理函数内部循环里也分配中间张量,建议在引擎层包一层装饰器统一处理 OOM,这样不仅限于当前请求,也能记录当时的显存状态方便排查。
3.3 流式推理怎么和信号量共存
很多生成式服务需要流式输出,客户端要拿到 token 一个一个蹦出来,这跟“信号量统一进口”天然有冲突——因为在一个流式响应过程中,GPU 推理任务始终没结束,信号量必须一直被占着。
实现上我建议这样拆:_infer_stream仍然是一个同步生成器函数,在with GPU_SEMAPHORE:内部一个 token 一个 token 地yield结果。FastAPI 端用StreamingResponse包住它,但注意要把生成器放到asyncio.to_thread执行,然后通过asyncio.Queue把线程池里的数据传到StreamingResponse的异步生成器里。否则生成器在事件循环里跑同步操作,又会把事件循环堵死。
简化一下,流式接口的伪代码长这样:
@app.post("/stream") async def stream_predict(req: PredictRequest): queue: asyncio.Queue = asyncio.Queue() async def producer(): # 在线程池中执行同步生成器 def sync_gen(): return _infer_stream(req.prompt, req.max_tokens) gen = await asyncio.to_thread(sync_gen) for chunk in gen: await queue.put(chunk) await queue.put(None) task = asyncio.create_task(producer()) async def consumer(): while True: chunk = await queue.get() if chunk is None: break yield chunk return StreamingResponse(consumer(), media_type="text/plain")这段代码的关键点是:_infer_stream内部带着with GPU_SEMAPHORE:,并且在整个生成器结束之后才释放信号量,这样才是“一个流式请求等于一个并发名额”的语义,不会出现一个流式请求还没结束,下一个请求又挤进来一起抢显存。
4. 显存之外的救火措施:半精度、动态批处理与失败重试
信号量只解决“同一时刻最多多少个请求进 GPU”的问题,但它不负责“单次请求最高峰不要太高”。所以除了闸门,还要把单请求的显存峰值压下去,以及尽量摊薄固定开销,这样才能真正提高少量并发下的吞吐。这一节讲几个我实测有效的组合拳。
4.1 半精度是底线,不是可选项
能开 FP16/BF16 就开,这能直接把权重和大部分激活值的显存砍半。注意两点:一是model.half()之后一定要重新做预热,因为 FP16 下的激活值分布和 FP32 不一样,缓存分配器行为也会变化,预热后会重新建立适合半精度推理的缓存池;二是用 BF16 的时候要确认你的卡支持(Ampere 架构及以上基本都支持),否则会回退到 FP32 导致速度反降。
如果你用的是量化版本——比如 GPTQ、AWQ、bitsandbytes 的 int4——显存占用就更友好,但要注意量化模型的首次推理有反量化开销,并发时会明显拉高 CPU 负载。CPU 负载一高,线程池排队疯狂,延迟就会变差。所以量化部署要同时评估 CPU 核数和线程池大小,别只盯着显存。
4.2 动态批处理是吞吐跃升的关键
严格说这不是标准信号量方案的替代品,而是补强。既然我们已经允许最多 N 个请求并发,那能不能把这 N 个请求合并成一个 batch 喂给模型,利用 GPU 的并行计算能力?答案是能,但这需要改造推理函数。
实现上我会在服务里维护一个“攒批队列”:请求进来先不立即推理,而是放进一个带超时窗口的缓冲池(比如等 50ms),在这 50ms 内到达的请求合并成一个 batch 送入模型,50ms 一到即使只有 1 个请求也直接推理。合并后,单次推理的算力利用率更高,每个请求拿到的显存增量也更小——因为合并 batch 时权重还是同一份,只是激活值按 batch 维度增加。
代价是延迟多等一个窗口的排队时间。50ms 对大多数在线服务是可以接受的,但对单请求延迟敏感的接口(比如交互式一键出图)就不合适。我自己的经验是:生成式模型一次输出几十上百个 token,合批收益很大;判别式模型单次推理只有几十毫秒,合批反而因等待窗口拖慢整体延迟,不如直接让它们并发跑。
4.3 KV cache 释放策略和显存碎片
Transformers 库做generate时会在编码器和解码器内部维护 KV cache,不同长度的输出会导致 cache 形状差异很大,大量交替分配释放后显存碎片化很严重。我的建议是:
- 每个请求结束后不定时执行
torch.cuda.empty_cache(),而是只在显存占用超过安全水位时触发。空缓存会把所有缓存块释放回 CUDA,代价是下一次请求分配时重新向驱动申请,会有一次额外延迟。 - 更细的做法是通过
torch.cuda.memory_stats()监控active_bytes(真实活跃分配的显存),一旦活跃显存超过阈值,触发一次empty_cache()。
另外,生成式推理用torch.inference_mode()代替torch.no_grad(),前者会跳过一部分历史梯度和张量记录的额外开销,显存占用和速度都更好。
4.4 失败重试要带指数退避
OOM 捕获到之后,直接返回 503 是一种选择,但用户体验很差。更稳的做法是客户端拿到 503 后做指数退避重试(第一次 200ms,第二次 400ms,第三次 800ms,最多 5 次)。这个策略可以在服务端 FastAPI 接口里做,也可以让上游网关做。我不建议在服务端内部同步做重试,因为如果在请求高峰期触发重试,请求量不降反增,信号量闸门外堆积的任务更多,反而加剧延迟。重试应该放在被动的客户端侧或网关侧,别放在服务内部。
5. 压测结果、uvicorn 多 worker 配置与日志排查
5.1 压测要测什么:并发数、延迟、吞吐、显存四维一起看
写完接口后,我一般用locust或wrk做压测,但不是只盯着“平均响应时间”。真正要记录的是四个指标在一张表里:
| 并发请求数 | 平均延迟(ms) | 吞吐(QPS) | 峰值显存占用(GB) | 失败率 |
|---|---|---|---|---|
| 1 | 320 | 3.1 | 18.2 | 0% |
| 2 | 335 | 5.9 | 20.1 | 0% |
| 4 | 380 | 10.2 | 23.6 | 0% |
| 8 | 510 | 9.8 | 超过24,OOM | 15% |
这是我模拟出来的一组比较典型的数据,实际会根据模型和卡型变动。注意几个规律:
- 并发从 1 加到 2,吞吐几乎翻倍,延迟只涨一点点。
- 并发 4 时吞吐达到峰值,延迟开始明显上升。
- 并发 8 时显存顶到硬件极限,吞吐不升反降,因为部分请求 OOM 后疯狂重试,资源被浪费在失败和重试上。
压测结束后用nvidia-smi --query-gpu=memory.used --format=csv -l 1蹲几分钟,看看显存在请求高峰过去后会不会回落。如果它一直不回落(PyTorch 缓存池特性),别慌;如果它持续上升并最终触发 OOM,那才是真正的泄漏。区分二者最可靠的方法是看torch.cuda.memory_stats()里active_bytes是否随着请求结束而降回基线,而不是看nvidia-smi的 raw 值。
5.2 uvicorn worker 数别乱开,每个 worker 都有一份模型
这是多进程部署时最坑的一点。很多人习惯性给 uvicorn 开 4 个 worker,然后发现显存直接翻了 4 倍——因为每个 worker 进程独立加载了一份 CUDA context 和一份模型权重。你要么开单 worker + 线程池,要么开多 worker 但每个 worker 的并发数按“总显存 / worker 数”重新均分。
以 24GB 卡、15GB 模型基础占用为例:
- 开 1 个 worker,信号量并发数可以到 3;
- 开 2 个 worker,每份模型基础占用变成 15GB,两张共享一块卡时总基础占用 30GB,已经超了,根本跑不动;
- 开 2 个 worker,只能用两张卡分别部署(一个进程一张卡),每张卡上信号量并发数 3,总并发 6。
所以我的结论是:在单机单卡场景,优先单 worker + 多线程并发 + 信号量;多卡场景,优先多 worker 且每个 worker 绑定一张卡(CUDA_VISIBLE_DEVICES=0和=1),再在每个 worker 内做信号量限制。千万不要多 worker 共享一块卡,那是自找 OOM。
5.3 uvicorn 日志丢失问题与推理统计
热词里提到的“uvicorn/fastapi 日志丢失问题”,我在多 worker 部署时确实遇到过。原因是默认日志配置里,多个 worker 进程同时往同一个文件句柄写日志时,会出现被截断或被互相覆盖的情况,而且异步日志队列在进程退出时丢失缓冲,直接表现就是“有一批请求的记录消失了”。
我的解决方案是:
- 给 uvicorn 传
--log-config指定一个dictConfig,核心是把disable_existing_loggers设为False,并给不同 worker 加不同的日志文件(通过环境变量注入 worker id)。 - 额外用 Sentry 或 Prometheus 记录请求数和推理耗时,不要把业务统计完全押在日志文件上。你后面排功耗、做容量规划时,可靠的指标比日志文本有用得多。
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1 --log-config log_config.yamlworker 数设置为 1 时,业务日志最省心;如果实在要多 worker,就靠上面的方法分流日志文件。
6. 长期运行时的显存监控与异常自动恢复
部署完光测一轮压测是远远不够的,线上跑几天后显存、缓存池、碎片率都会发生漂移。这一节讲我在长期运维时盯的几个指标和处理手段。
6.1 用 pynvml 监控真实显存水位
手动跑nvidia-smi只在排查时用,生产环境我会写一个小后台任务,用pynvml库每秒采样一次显存使用量和利用率,推到 Prometheus。监控指标除了总量,更推荐加一个torch.cuda.memory_reserved()和torch.cuda.memory_allocated()的自定义 metric,前者看 PyTorch 从驱动手里占了多少,后者看真实活跃张量占了多少。两者差值越大,说明缓存池越大,碎片风险越高。
当memory_reserved()长时间超过你设定的安全阈值(比如可用显存的 85%)时,触发一次torch.cuda.empty_cache()。如果memory_allocated()也超阈值,说明真实活跃显存异常,需要检查是否有请求长时间占着信号量没释放、或者生成任务真的把输出长度拉到了上限。
6.2 CUDA 上下文异常与自动恢复
长时间运行后,偶尔会遇到CUDA error: an illegal memory access was encountered或CUDA context lost。这类错误比 OOM 严重,因为整个进程的 CUDA context 已经坏了,empty_cache()救不回来,必须重启进程。
我的做法是在 FastAPI 里挂一个全局异常处理器,单独捕获这类 CUDA 错误,记录现场后调用os._exit(1)让进程退出,依赖容器的自动重启策略拉起新进程。别纠结怎么恢复现场,CUDA context 损坏后没有任何安全恢复路径,尽早自杀重启才是对用户最负责的做�法。唯一要做的是给重启策略加一点退避,避免启动即崩溃时无限循环重启。
6.3 健康检查接口建议做成“真实推理检查”
服务存活探针如果只返回 200,那只能证明进程还活着,证明不了模型可服务。我在/health接口里除了检查进程状态,还会执行一次极小输入的推理(比如只生成 1 个 token),并检测是否有 OOM 异常标记。这样做的代价是每次健康检查都消耗一次 GPU 计算,但对在线服务来说,宁可少接一点流量也不要带着一个已经接近 OOM 的服务继续收请求——否则一次请求高峰就是一次雪崩。
这个检查的频率我设置为每 30 秒一次,由负载均衡器触发,不影响正常推理。实际线上跑下来,救回过好几次因为显存碎片问题导致“能加载但推理极慢”的僵尸状态。
关于显存碎片的真正解法,我再提一句:如果业务形态确实是大量短请求、输出 token 长度波动很大,长期跑下来碎片化几乎是必然的。除了定期empty_cache(),也可以考虑每运行 24 小时主动重启一次 worker,虽然粗暴,但能把很多隐性问题一起带走。这个频率要根据你的请求量来调,请求量大的服务可以缩短到 12 小时,请求量小的服务一周一次就够了。毕竟稳定运行比“零重启记录”这种虚荣指标重要得多。
我自己在实际运维这套体系时最深的一个体会是:GPU 推理服务和普通 Web 服务最大的区别在于,普通 Web 服务加机器就能加并发,GPU 服务却会被单卡显存这个物理上限钳死。信号量、动态批处理、半精度这些说到底都是为了“在显存这个硬约束下尽量提高有效并发”,方向对了,剩下的就是不断根据线上数据调整那几个参数。希望这篇实战记录能让你少走几步弯路。