算力到位了,服务还是卡,这是过去一年我在好几个推理项目里反复撞见的怪圈。GPU 明明没有跑满,显存也还有富余,可线上服务的延迟就是不稳定,吞吐上不去。大多数人习惯把 GPU 调度当成训练集群的专属课题,但推理场景下的资源调度完全是另一套逻辑——它要同时应付延迟敏感、显存碎片、并发抖动、多模型共存这些破事,一个处理不好,几十万买的卡跟摆设差不多。
这篇文章我就把几个推理项目里沉淀下来的 GPU 资源调度经验全部摊开讲,从显存管理、利用率排查、多模型共享到具体落地参数,属于抄了就能用的那种。无论你是刚把模型部署上线的新手,还是正在为多模型共卡发愁的运维老手,这篇文章应该都有你能带走的东西。
1. 推理场景和训练场景的 GPU 调度逻辑,完全是两码事
很多人上来就照着训练集群那套思路做推理资源调度,这是第一个大坑。训练场景下,我们追求的是算力打满、吞吐最大化,任务跑个几小时甚至几天,延迟波动几十毫秒根本无所谓。推理场景刚好相反,它追求的是稳定延迟和可控吞吐,单个请求可能只有几毫秒到几十毫秒的 GPU 计算时间,但对响应时间极度敏感。
1.1 训练调度看利用率,推理调度看的是延迟分布
训练任务里的 GPU 利用率,基准越低越值得优化,因为算力就是钱。但推理服务不一样,一个推理服务 GPU 利用率 30% 可能已经是健康状态,因为你要给流量洪峰留出缓冲,要给动态批处理腾出拼批窗口。如果推理服务长期跑在 90% 利用率,那延迟分位数大概率已经失控了。
我习惯用 TP99 和 TP999 这两个延迟指标来判断推理服务的健康度,而不是盯着利用率看。真正需要关注的是:在某个并发压力下,延迟分布是否稳定,有没有长尾。长尾请求通常不是 GPU 算力不够,而是资源调度出了问题——显存分配卡顿、上下文切换开销、批处理窗口太小、共卡任务互相干扰,这些都可能是元凶。
1.2 推理调度的核心矛盾:显存占用小,但访存带宽和延迟要求极高
模型推理和训练还有一个根本性差异:显存占用量级不同,但带宽需求一点不低。训练大模型动辄占满 80GB 显存,推理一个 7B 模型量化后可能只要 6GB 显存。显存看起来富余了,但推理过程中的访存模式是高度密集的,尤其自回归解码阶段,权重矩阵一遍遍从 HBM 里读,内存带宽直接决定 decode 的速度。这也是为什么 7B 模型在 A100 上推理,和 4090 上推理速度差距没那么悬殊——算力是一方面,带宽才是瓶颈。
这个底层差异导致推理资源调度不能只看显存余量,还要考虑带宽争抢。同一个 GPU 上塞多个推理任务,如果都是带宽密集型,互相干扰会比显存不足更早出现,表现为延迟分布劣化、逐 token 生成速度明显下降。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 显存分配与复用:推理调度最容易忽略的隐性成本
显存管理在推理场景里被低估得离谱。很多人以为显存够放模型就够了,实际上运行时显存开销包括模型权重、KV Cache、CUDA Context、CUDA 算子临时缓冲区、通信缓冲等一堆杂项。模型只占其中一部分,KV Cache 增长是动态的,CUDA Context 动不动就占几百 MB 到 1GB,这些都必须在调度时提前算进去。
2.1 CUDA Context 的吃显存机制,以及多进程隔离的代价
每个进程初始化 CUDA 环境,驱动都要给它分配一块统一内存空间来放 context,包括模块加载、kernel 编译缓存、设备参数等。这块空间在进程整个生命周期内不会释放。不同框架、不同 CUDA 版本的 context 开销不一样,PyTorch 相对节省,TensorRT 则比较克制,但一个上下文吃 300MB 到 800MB 只是起步价,多卡环境还要再乘卡数。
所以调度策略里,我坚决反对为每个请求拉起一个进程这种方式。进程级隔离安全是安全,但 context 反复初始化对服务延迟是灾难性的,而且 GPU 显存碎片化会因为频繁申请释放而恶化。正确做法是用常驻 worker 进程,配合 IPC / gRPC 或共享内存做数据交换,进程池保持固定,避免反复创建销毁。
2.2 KV Cache 动态增长,是最难预先评估的显存变量
自回归模型推理过程中,KV Cache 随序列长度动态增长,显存占用是浮动的。项目上线前估算显存需求,如果只按模型权重估算,上线第一天就会被长上下文请求打爆。调度时给 KV Cache 预留的显存必须考虑最大并发数和最大上下文长度,一般建议按下述公式估算:
KV Cache 占用 ≈ 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × 并发数 × 每个元素字节数
只看公式容易懵,直接算一个实际例子:一个 7B 模型,层数 32、注意力头数 32、头维度 128,用 FP16 推理,对 2048 长度的上下文并发处理 8 个请求。
单请求 KV Cache = 2 × 32 × 32 × 128 × 2048 × 2 字节 = 约 1GB
8 个并发 = 8GB
也就是说,光 KV Cache 可能就要吃掉 8GB 显存,这还没算 CUDA context 和算子缓冲。很多线上事故就是这么来的——GPU 显存总量看着够,但实际跑起来直接 OOM。
2.3 显存碎片化,一个被低估到离谱的问题
碎片化的核心成因是:不同长度的请求分配不同大小的显存块,请求结束后释放,留下大量不连续空洞。当新的请求需要更大连续块时,CUDA 分配失败,即便显存总剩余空间足够,也没法用。这种情况通常在已有多个不同上下文长度的请求交错跑了一段时间后出现,危害比显存不足隐蔽得多——因为 nvidia-smi 看剩余显存还有几个 G,可服务已经疯狂 OOM 了。
缓解碎片化我有三个实测有效的策略:
- 对推理服务启用 CUDA 的显存池 / caching allocator(PyTorch 默认开启),避免反复向驱动申请释放显存。
- 按固定区块预先分配显存,例如把 KV Cache 做成预分配池,请求进入后从池子里按固定步长分配,不按需实时分配。
- 限制单个 GPU 上的模型数量,宁可隔几个卡,也别往一张卡里硬塞太多模型,碎片化会呈指数级恶化。
3. 利用率上不去?先按这条链路排查,避免瞎调参数
我在多个项目里遇到“GPU 利用率看着不高,但 CPU 和内存也不高,服务却卡得不行”的情况。这个现象特别迷惑人,因为它不符合直觉:所有资源看似都有余量,性能却上不去。经过好几轮排查,我把根因排查链路固化成了一套步骤,照着跑一遍通常能定位到问题。
3.1 第一步:排除“伪空闲”——利用率的时间粒度与采样陷阱
nvidia-smi 默认的采样间隔是 1 秒,这个粒度对推理服务的瞬时峰值非常不敏感。一个请求可能只有 50ms 的 GPU 计算时间,在 1 秒的采样窗口里平均下来,利用率看起来就低得可怜。真正的评估方式是用 nvidia-smi dmon -s puc -d 1 这种细粒度监视命令看实时波动,或者直接看推理框架自带的 metrics。像 vLLM 这类框架暴露了 token 级延迟、batch 内请求数等 metric,能精确反映 GPU 是否真正吃满。
我踩过的坑是:因为 nvidia-smi 显示利用率才 20%,就去加大并发,结果一压测,这些并发全部堆到 CPU 侧排队,GPU 倒是吃满了,但端到端延迟直接飙了几倍。性能瓶颈根本不在 GPU,却被“利用率低”这个假象误导,把问题搞得更复杂了。
3.2 第二步:CPU 侧预处理和调度逻辑是不是假瓶颈
很多团队在数据预处理阶段用的是 Python,tokenization、图像解码、prompt 拼装这些操作都吃 CPU。GPU 计算本身非常快,但 CPU 来不及把请求喂给 GPU,GPU 只能空转等待,利用率自然上不去。这类问题通常表现为:GPU 利用率曲线忽高忽低,高点和低点差距极大,中间夹杂大量空闲间隙。
排查方法不复杂,在请求入口和数据管线关键路径打点,统计 CPU 侧处理耗时、队列等待耗时、GPU kernel 耗时三个指标。如果队列等待加 CPU 预处理时间占比超过端到端延迟的 30%,就得考虑上异步流水线。把数据预处理和模型推理放到不同的线程/进程里,中间用有界队列解耦,GPU 侧只要准备好就立刻拉数据,不要让 CPU 拖后腿。
3.3 第三步:小 batch 导致 kernel 启动开销比例失衡
GPU kernel 启动是有固定开销的,量级在几微秒到几十微秒之间。如果一个 kernel 本身执行时间只有 200 微秒,那启动开销占了近 10%,白白浪费算力。推理模型如果没有做算子融合和 batch 批处理,会频繁启动大量小 kernel,GPU 算力利用率根本起不来。
动态批处理(continuous batching)是目前最通行的解法。它的核心思路不再是等待固定数量的请求凑成一个 batch,而是只要 GPU 有空闲计算槽位,就把新请求插入到正在执行的 batch 中,通常在 self-attention 阶段之后、FFN 阶段之前完成拼接。这样不需要等满一个 batch,也能最大化 GPU 吞吐。vLLM、TensorRT-LLM 都内建了这套逻辑,直接启用就好。如果要手写实现,核心参数就两个:最大 batch 大小(建议从 16 起步,按显存余量调整)和最大等待时间(通常设 20-50ms,超过这个时间窗口即使 batch 没满也提交执行)。
3.4 第四步:数据加载与张量搬运是不是被忽略了
如果前面三步都排查完了还没定位到问题,就要看数据从存储到 GPU 这整条链路的搬运耗时。推理场景常见的数据搬运瓶颈包括:从远端对象存储拉取模型或 embedding 向量、CPU 到 GPU 的 PCIe 传输、GPU 之间的 P2P 通信。PCIe 带宽对于单卡或多卡推理的输入数据推送还是有影响的,尤其是图像类模型,输入张量动辄几 MB 到几十 MB。
这里有个实测经验:图像模型推理如果输入是 1080p 原图,原始 JPEG 从磁盘读到 CPU 内存、解码成 RGB 张量、再拷贝到显存,这段耗时在某些服务里能占到端到端延迟的 40% 以上。解法通常是:缩小输入尺寸、用 GPU 解码或 JPEG 解码硬件单元、显存资源充足时做输入张量缓存。GPU 并不是不够快,而是数据根本来不及送到。
4. 多模型共卡与共享调度:省钱的背后全是细节
把多个模型塞到同一张 GPU 上跑,是推理成本优化最直接的手段。尤其在 GPU 价格高企的背景下,多模型共享一张卡是刚需。但怎么共享、共享到什么粒度,决定你是省了钱还是埋了雷。
4.1 三种常见共享方案,以及各自最适用的场景
市面上常见的多模型共卡方案大体分三类:
| 方案 | 显存隔离 | 算力隔离 | 适用场景 | 主要短板 |
|---|---|---|---|---|
| 进程级共卡 | 显存空间独立 | 算力争抢,无隔离 | 模型口径不统一,需要隔离部署 | 每个进程的 CUDA context 和显存开销都要考虑,并发争抢时延迟不可控 |
| MPS(多进程服务) | 显存空间独立 | 可配置算力分配比例 | 同一用户的多任务,算力可动态调整 | MPS 本身增加一层调度,CPU 端开销有上升,配置稍复杂 |
| MIG(多实例 GPU) | 显存物理隔离 | 算力硬隔离 | 安全要求高、强隔离场景 | 只在 A100/H100 等部分显卡支持,实例规格固定,不够灵活 |
我实际使用下来:如果只是想让几个模型安静地跑在一起,MPS 性价比最高;如果客户有强隔离要求,MIG 更稳;进程级共卡适合模型版本差异大、共卡容易互相污染的场景,但要做好算力争抢的延迟兜底。
4.2 MPS 的实际配置参数,以及不同显存分配策略的优劣
MPS 的启用过程网上教程很多,这里只强调最容易踩坑的几个参数和选择逻辑。
CUDA_MPS_PIPE_DIRECTORY 是活跃连接的服务端目录,所有客户端进程需要能访问这个目录。集群里经常出现 MPS 无法正常连接的问题,大多数是权限和路径不一致导致的。我习惯把 MPS 目录放到某个全局可写路径下,然后在所有推理容器里显式声明这个环境变量,避免因为临时目录不一致造成连接失败。
MPS 的显存分配策略 CUDA_MPS_ACTIVE_THREAD_PERCENTAGE 控制的是 MPS 客户端占用 GPU 计算资源的百分比上限。默认值是 100,也就是不限制。我的建议是:如果一张卡上跑两个模型,直接给 A 模型设 60,B 模型设 40,先跑几天看延迟分布再微调。这个参数的调整是热生效的,不需要重启 MPS 服务,适合做动态调配。
4.3 按时间片切分 vs 按优先级抢占,谁能兼顾成本和稳定性
多模型共卡场景还有一个躲不开的问题:多个任务同时想用 GPU,怎么排队?常见思路是时间片轮转,每个模型固定分到一个时间片,比如 50ms。这种做法的好处是实现简单,坏处是如果某个模型在一个时间片内没完成计算,上下文切换会把进程打回 CPU 等待下一轮,导致这个模型的延迟出现固定周期的抖动。
我更喜欢用优先级抢占,不是让所有模型平等轮换,而是按业务重要性分配优先级。比如在线 API 的优先级大于离线批处理任务,批处理任务可以在在线服务空闲的时间片里插空执行。这个策略实现起来比时间片切分复杂一点,但对延迟敏感型业务的保护效果非常明显。落地时主要依托 NVIDIA 的 MPS 或 MIG 能力,配合任务调度框架做二次编排。
5. 一套可落地的多模型多卡推理调度配置(可以直接抄)
前面几节讲了一堆原理,这一节给一套可以直接照做的配置骨架。假设有 4 张 A100 80G,需要部署 3 个模型:一个在线 Chat 模型 A(7B)、一个离线 Batch 模型 B(13B)、一个实时 Embedding 模型 C(Base 大小)。三个模型的总显存需求大约 60G,但直接共卡会很挤,所以我分配的方案是交错布局。
5.1 显存布局与进程拓扑设计
| 显卡 | 部署内容 | 显存预留 | 备注 |
|---|---|---|---|
| GPU0 | 模型 A(在线) | 40G(含 KV Cache 池) | 为主服务预留膨胀空间 |
| GPU1 | 模型 B(离线 Batch) | 32G | 可接受延迟波动 |
| GPU2 | 模型 C + 模型 A 备用副本 | 20G + 10G | 做热备,平时 C 为主 |
| GPU3 | 模型 B 备用副本 + 模型 C 备用副本 | 32G + 10G | 离线任务独占,不做混部 |
这个布局的考量逻辑是:在线服务 A 放在 GPU0,显存要留足动态余量,且不做隔离,把 MPS 关掉,因为在线服务要独占算力保证延迟;离线 B 放到 GPU1,MPS 上限设 80,留 20 给系统闲时任务;GPU2 同时放 C 和 A 的备用副本,两张卡在 MPS 层设定比例。
5.2 vLLM 推理框架的关键调度参数配置
vLLM 是目前我用得最顺手的推理框架,它的 continuous batching 是内建支持,省去很多手写调度的痛苦。实际使用中,值得优先配置的参数如下:
--tensor-parallel-size:张量并行度,如果模型权重在单卡能放下,就设 1,不用张量并行。跨卡张量并行的通信开销在推理场景里经常是负优化,实测 7B 模型单卡推理比双卡张量并行还快。--max-model-len:模型支持的最大上下文长度,设太大,KV Cache 预留过多,并发就上不去;设太小,长上下文用户直接报错。一般按业务用户 95% 的上下文长度再加 20% 余量来设。--gpu-memory-utilization:vLLM 允许使用的显存比例上限。这个参数直接决定 KV Cache 能分配多少,一般按 0.85 到 0.9 设置,剩下的留给 CUDA context 和框架开销。--max-num-seqs:限制 batch 里的最大序列数。根据我刚才讲的 KV Cache 估算公式反推,显存余量除以单请求 KV Cache 就是合理上限。
5.3 压测验证与参数调整的实际路径
配置完成后,直接上压测工具跑流量。推荐用 Locust 或者 wrk 配合真实请求样本,不要用固定 token 长度的假数据,那会完全误导 KV Cache 和延迟估算。压测分为三个梯度:正常流量的 50%、峰值流量的 100%、峰值的 150%。每个梯度至少跑 30 分钟,观察延迟分布和显存波动曲线。
压测的时候注意一个容易被忽略的细节:观察 GPU 的 memory clock 而不是只看利用率,memory clock 波动大说明显存带宽压力已经上来了。这种情况下再提并发没有意义,该调整的是批量大小和超时窗口之间的平衡,或者直接加卡。
6. 上线之后才发现的几个隐藏 Bug 和稳定性的处理思路
最后这部分是纯踩坑经验。很多问题我在设计阶段完全没想到,是上线之后才暴露出来的。
6.1 同一个 GPU 上多模型并发,超过一定并发量后整体崩溃
一个真实案例:两个模型共卡,单模型跑 8 并发都正常,双模型各自 8 并发合计 16 并发,跑了大概两个小时,整个 GPU 驱动直接报错并重启。事后排查发现,两个模型的进程都用了 PyTorch 的 caching allocator,显存释放和分配的节奏互相干扰,诱发了 CUDA 溢出错误。最终解法是给两个模型分别配置了独立的 CUDA 环境变量,并且把 PyTorch 的 PYTORCH_CUDA_ALLOC_CONF 设置为 max_split_size_mb:128,限制缓存块分裂,碎片化问题得到明显缓解。
6.2 超时重试机制,居然成为打垮系统的帮凶
推理服务常见的保护机制是超时重试,但重试如果没做全局限流,会被流量洪峰直接放大成雪崩。一个请求超时后客户端立刻重试,重试请求又超时,又继续重试,GPU 资源全耗在处理重复请求上,真正的新请求反而排不上队。处理方式是在网关层做全局级别的熔断和超时重试配额,单请求最多重试两次,且重试请求和新请求共享同一个并发限额。
6.3 掉卡和热迁移的问题,比想象中更常见
GPU 在运行中掉卡,常见原因包括电源供电不稳、驱动缺陷、温度过高等。这个问题在推理集群里比训练集群更棘手,因为推理服务通常要求高可用,不允许中断。应对策略是依赖前面说的副本部署,每张卡上的模型必须有备用副本在另一张卡上。热迁移和故障切换的延迟是分钟级的,但至少比彻底不可用强得多。
6.4 碎片化评估的实际检查手段
最后分享一个用于日常检查显存碎片化程度的技巧:启动推理服务前记录一次可用显存总量,服务运行几天后再记录一次,然后用 nvidia-smi -q -d MEMORY 查看每个内存区块的大小分布。如果发现大量小于 64MB 的小区块,基本可以判定碎片化已经够严重了。此时最有效的操作不是调参,而是重启推理进程,让显存分配器重新初始化,碎片直接清零。这个操作可以写成运维脚本,在碎片率达到阈值时自动触发。
我对这几个项目的核心体会是:GPU 资源调度不是一锤子买卖,它需要跟随业务流量和模型迭代持续调整。上线前做好容量规划只是基础,上线后的监控和动态调整才是稳定性的关键。没有任何一套静态配置能永远适配动态的流量特征,把监控指标建好、把动态调整的路径打通,比追求一次到位的规划方案更重要。
