做模型推理服务的人,没有不被 GPU 调度折腾过的。今年我把几个推理服务的 GPU 调度机制完整梳理了一遍,起因很简单:集群里明明还有不少空闲算力,新服务却经常提示资源不足;等我把调度策略调整之后,同样的负载竟然只用了一半的卡。这种反常现象背后,真正的问题不在显卡数量,而在调度机制本身。
如果你也在维护模型推理集群,或者正准备优化推理服务的 GPU 调度机制,这篇文章大概率对得上你的胃口。我会从推理负载和调度机制的底层差异开始拆,再讲清楚调度链路里最容易忽视的几个决策环节,给出一些可以直接落到配置里的优化做法,最后把落地过程中踩过的几个坑完整还原一遍。没有玄学,只有一步步的推理、验证和调整。
1. GPU 调度为什么难:推理负载与传统资源管理的错配
1.1 训练要算得快,推理要答得稳
先想清楚一个前提:训练和推理对资源调度的要求,本质上就不是一回事。
训练任务的负载画像通常是稳态的。一个训练作业一旦起来,loss 曲线、batch 大小、GPU 算力占用基本在一个稳定区间内波动,跑十几个小时甚至几天都差不多。它的核心诉求是"单位时间处理的样本数越多越好",所以调度上倾向独占整卡、长租不拆,即使中间有短暂波动,资源利用率也相对可控。
推理任务则完全不同。一个在线推理服务面对的是真实请求流,早高峰和晚高峰差好几倍,单个请求的处理时间也有大有小。用户点击一下,你必须在几百毫秒内返回结果,这时候调度如果慢半拍、排错队,用户侧的 P99 延迟直接飙红。更麻烦的是,推理服务的算力消耗不是均匀的,一个空转的模型实例可以把显存占得满满当当,算力却只用了几个百分点。
所以,给推理服务做 GPU 调度,本质上是在管理一种"显存和算力严重不对等"的稀缺资源。如果沿用训练时代"整卡独占 + 按显存申请"的思路,就一定会出现下面的问题。
1.2 显存维度掩盖了算力维度
GPU 这种加速卡有两个关键维度:显存容量和计算能力。传统容器调度平台在登记 GPU 资源时,通常只上报了几个配额单位,说白了就是"这张卡被分成了几块、还剩几块",至于卡上有多少算力在闲置,调度器完全感知不到。
这个偏差在推理场景下非常致命。一个 7B 参数的模型加载到显存里可能要占 20GB,但一次只处理一个请求时,GPU 上的几千个计算核心大部分都在空等。显存被占满了,算力却闲着,调度器却认为"这台机器资源耗尽,新任务不能上来",于是新服务被排在队列里,而集群整体利用率只有百分之十几。
很多团队做 GPU 调度优化的第一步,不是换调度器,而是先把"资源"的定义改掉:不只是看显存还剩多少,还要看 AI 加速卡的计算单元平均利用率、近期历史、排队请求规模。否则再好的调度策略,都是在错误的地基上盖楼。
1.3 一个典型的浪费场景:按峰值申请,按均值使用
说个很有代表性的场景。某团队同时维护着四个推理模型,每个模型单独部署成一套实例,各占一张整卡。为了避免流量波动打垮服务,每个模型申请资源时都按峰值预留,实际跑下来平均算力利用率不到 15%。四个模型加起来,显存全占了,算力全闲着。
如果调度机制能感知到"这四个模型的流量天然错峰",完全可以做两件事:一是把低峰期的模型实例缩容,把高优先级的临时流量调度到空出的卡上;二是在同一张卡上用显存切分和算力隔离的方式共存多个模型实例,让算力在时间维度上填满。
但我们当时连第一步都做不到,因为调度器眼里只有"还剩几张整卡",没有"这张卡目前的算力水位如何"。这就是调度机制优化要解决的第一个核心问题:让调度对象从"卡"细化到"卡上的时间和空间"。
2. 调度决策链路的三个层次:排队、上机、上卡
把 GPU 调度拆开看,它其实不是一个独立的动作,而是三个层次的决策接在一起。每一层都在回答不同的问题,也都决定了资源最终被利用到什么程度。
2.1 第一层:队列与并发配额
请求到达推理服务之后,先要过一个队列,由服务框架决定放多少请求进来、排多久队、超出的请求是等待还是拒绝。这一层看起来跟"调度"无关,实际上决定了 GPU 后续被打满还是被打爆。
调度机制优化一个容易被忽略的杠杆,就是队列长度和排队策略。队列太短,突发流量直接被拒,用户体验差;队列太长,请求在队列里积压,P99 延迟爆炸,而且每个请求都在占用后续资源。更隐蔽的问题是,队列里的请求如果带重试逻辑,一旦上游超时,重试流量会成倍压回来。
我们当时把队列参数当成一个需要和批处理窗口协同设计的变量来处理:队列深度设为批处理最大量的两到三倍,排队时间上限控制在目标 P99 的 10% 以内。这样即使突发流量进来,也不会让 GPU 侧的资源分配被打乱节奏。
2.2 第二层:节点过滤、打分与绑定
容器调度平台里最经典的动作是三段式:过滤掉不满足条件的节点,对剩余节点打分,选最高分节点绑定。做 GPU 调度优化时,大多数功夫都花在改"打分"这一段上。
默认打分逻辑通常只考虑一个方向:谁的剩余可分配 GPU 多,谁得分高。这个逻辑在训练场景下没问题,但推理场景下必须叠加更多因子,常见的有:
- 算力水位:节点上 GPU 的平均计算利用率,避免把新任务堆到已经满载的卡上,也避免堆到完全空闲但显存已满的卡上;
- 显存剩余与碎片化程度:如果一张卡只剩 5GB 不连续显存,而模型需要 8GB 连续空间,这张卡应该被过滤掉;
- 模型缓存与数据本地性:节点上如果已经缓存了模型权重,加载时间可以省掉一半以上,这类节点应该加权重;
- 设备健康状态和故障反亲和:避免多个副本落在同一张卡或者同一个故障域里,防止单点故障引发雪崩;
- 拓扑亲缘性:涉及多卡通信的模型,调度到高速互联的卡组上,通信成本差别很大。
我当时给打分逻辑改成加权方式后,效果立竿见影:
调度得分 = 0.4 × 剩余显存归一化值 + 0.3 × 算力余量归一化值 + 0.2 × 模型缓存亲和加分 + 0.1 × 故障域分散加分
这个权重不是拍脑袋定的,是通过离线回放历史调度数据,模拟不同权重组合下的资源利用率表现选出来的。权重体系没有绝对标准,每个集群的负载特征不一样,但方向是一致的:别让调度器只看显存这一个维度。
2.3 第三层:卡上切分与流内调度
节点选定之后,还有一层容易被忽略的决策:这张卡上的算力怎么切、多个模型实例怎么共处。
这里有两个层面。第一个是空间切分,把物理卡的记忆体切出几块隔离的区域,每个区域跑一个服务;第二个是时间层面的算力排布,多个服务轮流使用计算核心,通过算力隔离手段避免互相干扰。
实际落地时,卡上切分要重点考虑两个副作用。切分太细,单块算力太弱,超过延迟阈值,用户体验变差;切分太粗,资源利用率还是上不去。我们测试下来的经验是:优先保证延迟敏感服务的算力下限,再谈共享。至于流内调度,也就是 GPU 内部多条计算流的并发安排,最好交给推理框架的批量调度器去管,调度平台不要在上层干预太多,否则两类调度策略会互相打架。
这三个层次的决策关系,可以大概排成下表:
| 调度层次 | 决策问题 | 常见病 | 优化抓手 |
|---|---|---|---|
| 队列层 | 放多少请求进服务 | 队列溢出、延迟爆炸 | 队列深度、排队等待上限、重试策略 |
| 节点层 | 选哪台机器放实例 | 只看显存、负载不均 | 打分因子加权、历史利用率画像 |
| 卡内层 | 一块卡上怎么共用 | 算力争抢、切分过碎 | 显存切分、算力隔离、批处理窗口 |
这三层不是互相独立,而是层层递进的关系。队列层控制流量进入节奏,节点层决定资源落在哪里,卡内层决定闲置资源能不能被二次利用。任何一层失守,前面的优化都会被抵消。
3. 收益最大的三个优化方向:窗口、超卖与拓扑
在动手改调度器之前,我建议先看三个方向,它们的投入产出比最高,而且很多场景下不需要改动调度器的核心代码,只需要调整推理框架和平台配置。
3.1 动态批处理窗口:以极小延迟换数倍吞吐
GPU 算力是典型的并行算力:单请求打进卡里,使用率低得可怜;如果把多个请求攒成一波再一次性计算,吞吐能翻好几倍。这就是动态批处理的核心逻辑。
大多数推理服务框架都支持设置两个关键参数:最大批量大小、最大排队等待时间。前者决定了一波最多处理多少个请求,后者决定了系统等多长时间。这两个参数此消彼长:等待时间越长,批量越大,吞吐越高,但请求在队列里多等的时间也会直接反映到延迟上。
我们当时做了一个 A/B 对照。同一组模型服务,默认批量大小 1、等待时间 0 毫秒时,吞吐基准是 220 请求每秒,P99 延迟 45 毫秒;把批量大小调到 32、等待时间上限设为 20 毫秒后,吞吐涨到 1100 请求每秒,P99 延迟只增加了约 15 毫秒。也就是说,用 15 毫秒的延迟增量,换了 5 倍的吞吐能力。
具体调参时有几个参考经验:
- 等待时间的上限不要超过目标 P99 的 10% 到 15%,否则延迟预算会失控;
- 最大批量大小从 8 开始往上试,每一步对比吞吐和 P99 的变化曲线,找到拐点;
- 流量有明显波峰波谷的场景,可以按时间段切换窗口参数,而不是全局统一。
动态批处理本质上是在"攒请求"和"尽早干活"之间找平衡点。它是一个典型的调节阀,把这个阀调好了,GPU 的算力利用率会肉眼可见地往上走。
3.2 显存超卖:把闲置显存借给非敏感负载
GPU 显存的大头被模型权重和上下文缓存占着,问题是这些空间不是时时刻刻都用满。比如一个大模型的上下文缓存区会在空闲期间大部分闲置,但这种闲置显存,默认情况下调度器是不允许其他任务碰的。
显存超卖的思路很简单:允许调度器按一定比例把同一张卡分配给多个实例,让实际分配的显存总和略微超过物理显存。比如超卖比例 120%,只要两个实例不会同时把峰值跑到顶,这张卡就能多塞一个服务。
但超卖是有代价的,最大的风险是显存溢出。一旦多个实例同时冲到峰值,物理显存放不下,就可能触发服务崩溃,造成比资源不足更严重的故障。我们在落地时给超卖设了三个条件,缺一不可:
- 只有非延迟敏感型负载允许参与超卖,比如离线批量推理、异步任务;
- 超卖比例从 110% 起步,观察显存水位曲线稳定后再逐步调高,最高不超过 150%;
- 必须开启显存水位监控和自动回收,当整卡水位超过 90% 时,优先杀掉可抢占的超卖实例。
超卖是一个典型的"用风险换利用率"的手段,适合已经有一定资源池规模、并且监控体系较完善的团队。刚开始优化调度机制时,不建议第一时间上超卖,而是先把前面的打分逻辑和窗口参数调好,再考虑这一层。
3.3 拓扑感知打分:让通信快的卡优先接活
当模型大到需要多卡并行推理时,卡与卡之间的通信速度会直接影响请求延迟。GPU 之间通信的物理拓扑是有层级的:同一块板卡上的高速互联最快,同一个节点内次之,跨节点走交换网络最慢。如果调度器对拓扑无感,很可能把需要大量通信的两个实例分别放到两个远端节点上,导致每次前向传播都要付出高昂的通信开销。
拓扑感知调度的做法,是在打分阶段加入"通信代价"因子。调度器在决策时先计算候选节点与已有实例之间的拓扑距离,给近距离高权重、给远距离低权重,这样多实例协同任务会自动聚集到通信较快的卡组上。
这个优化对大模型场景收益尤为明显。我们有一个多实例并行的推理任务,之前平均每次前向传播的通信等待时间占整体耗时的三成,改成拓扑感知调度后,这部分时间直接砍掉了约六成,整体 P99 延迟下降了 18% 左右。
不过要注意一个前提:拓扑感知只对需要频繁通信的负载有意义。如果一个模型是纯单卡推理,不需要和其他实例通信,强行按拓扑聚到一起反而会让故障域变大。所以这个因子在打分权重里要给场景化条件,不能一刀切。
4. 落地复盘:三个真实踩坑场景的完整排查链路
理论讲完,落地时总是会有一堆意外。下面三个坑是我实际踩过的,每一个都花了不止一个晚上去排查。把这些还原出来,是希望大家少走弯路。
4.1 调度失衡:打分只看显存,算力被忽略
现象:两个推理节点,节点 A 的 GPU 平均利用率已经跑到了 90%,节点 B 只有 5%,但每次新任务发布,调度器还会选择节点 A,导致 A 上延迟明显变高,B 长期闲置。
排查过程:我先看了两个节点的资源上报记录,发现 A 的显存剩余比 B 少,但按默认打分逻辑,B 的分应该更高才对。继续翻调度日志,发现新任务并没有走默认打分,而是命中了亲和性规则,把所有服务都往一组特定标签的节点上拉,这个组恰好只有 A。问题变成了"亲和性规则写太死"。
修复方式:把亲和性规则改为软亲和,也就是有加权加分,但不强制绑定;同时把"节点近期平均算力利用率"加入打分因子。改动上线后,新任务会优先落到 B,等 B 的利用率走到 60% 以上,再继续往上分流量。
这个坑的核心教训是:调度异常不一定是调度器的问题,先看规则,再看打分,最后看资源上报。任何一个环节都可能造成失衡。
4.2 冷启动黑洞:模型加载时间不算资源
现象:每次新版本发布,服务实例调度到新节点后,总有一段长时间不可用,这段时间下游请求大量超时。看起来像是服务启动慢,但从日志看进程明明是活的。
排查过程:一开始以为是代码启动流程里有什么阻塞,后来发现不可用时长和模型权重的大小强相关,模型越大,不可用时间越长。这才定位到根因:实例被调度到新节点后,需要从远端存储拉取完整的模型权重文件,然后加载到显存里完成编译初始化,整个过程要几十秒。这段时间内实例已经注册成功,但对外无法正常服务。
修复方式:三步走。第一,在调度打分里识别哪些节点本地已缓存模型文件,给这类节点加分,提高实例落到"预热节点"的概率;第二,发布流程里加预热步骤,先把模型权重提前推到候选节点;第三,没用探针检查就绪状态之前,不要让流量打到新实例上。
冷启动问题其实非常适合前置处理。推理服务是状态敏感的,模型权重就是它最大的状态,调度机制如果不感知这个状态,就会不断制造"活着但不可用"的实例。
4.3 抢占雪崩:高优先级策略把服务打进重启循环
现象:开启优先级抢占策略之后,低优先级实例频繁被杀死,节点上的实例数量不断抖动,整体成功率反而跌了十几个百分点,部分服务进入反复重启的循环。
排查过程:刚开始只觉得是抢占阈值设得太低,但调整之后情况依然没有好转。仔细看被杀实例的生命周期,发现大多数实例刚启动不到一分钟就被抢占。原因有两层:一是低优先级实例启动时会重新拉模型、建显存缓存,资源峰值很高,短时间内给节点造成很大压力;二是被抢占后客户端重试流量立刻打回同一个服务的新实例,新实例还在冷启动,大量超时又触发了新一轮抢占。这就是典型的抢占雪崩。
修复方式:把抢占策略从"硬抢占"改成"优雅抢占"。具体来说,允许低优先级实例跑完当前批次请求后再退出,给一个最长宽限期;同时所有被抢占后的重试请求加入退避机制,不能立刻重打;延迟敏感服务打上不可抢占标签,不让它参与这类争夺。
抢占是一把双刃剑。它的价值在于保证高优先级服务能及时拿到资源,但代价是低优先级任务频繁中断,而中断成本在推理场景里比训练场景高得多,因为每次重启都要重新加载模型。这个成本没算清楚之前,不要轻易开启抢占。
4.4 一套可参考的验证指标和 A/B 方法
调度机制优化最容易犯的错,是只看一两个指标就下了结论。比如只看 GPU 利用率上升了,但 P99 延迟也同步飙升,这就不算优化。我们后来固定了一套指标组合,每次改动前先跑基线,再小流量灰度对比。
核心指标分四组:
| 分类 | 指标 | 说明 |
|---|---|---|
| 资源指标 | GPU 平均利用率、显存水位、算力余量 | 判断资源是否被用起来 |
| 服务质量指标 | P99 延迟、排队等待时间、超时率 | 判断用户体验是否受损 |
| 单请求成本 | 平均响应时间、批处理均值 | 判断算力利用效率 |
| 稳定性指标 | 实例重启次数、调度失败次数、告警数量 | 判断优化是否有副作用 |
A/B 方法上,推荐先选一个低峰时段的流量切片做灰度,灰度时间至少覆盖一个完整波峰波谷周期。改动面比较大的,比如打分权重调整,可以准备两到三组候选参数同时跑,用上述指标矩阵打分后选择综合更优的,再全量发布。
还有一点容易被忽略:指标采集本身要先做对。GPU 利用率如果只是每 30 秒采一次均值,会漏掉很多瞬时波动,导致判断失真。建议把采样粒度至少做到 5 秒以内,并且同时记录瞬时峰值和周期均值,两个值都要看。
最后说几句个人体会
调度机制优化做了一个多月后,我最大的感受是:这个问题的难点不在算法,而在资源建模和信息打通。GPU 调度之所以难优化,本质上是调度器对 GPU 的真实状态了解太少,对推理负载的运行模式了解也太少。想清楚这一点,很多手段其实水到渠成。
如果你也要做类似的事情,我建议按这个顺序推进:先把 GPU 利用率、显存水位、延迟指标这三个数据源打通,建立观测基础;然后调动态批处理窗口参数,这个改动小、收益大、风险最低;再做调度打分权重优化,让新任务落在真正有余量的地方;最后再去考虑超卖和抢占这类副作用更大的策略。
调度机制优化没有银弹,但它也远没有想象中那么玄学。每一步改动的收益都可以被量化,每一个风险都可以被提前设计。只要把基础打牢,效果就会自己说话。