1. 从一次显存打满说起:异步调度到底在解决什么
如果你部署过 vLLM,大概率遇到过这样的场景:模型权重加载完毕,服务正常启动,前几个请求响应飞快,但并发一上来,GPU 利用率曲线就开始剧烈抖动——一会儿飙到 95%,一会儿掉到 30%,吞吐量怎么调都上不去。更诡异的是,nvidia-smi显示显存几乎打满,但 GPU 的计算单元却有大段空闲。这不是模型的问题,也不是硬件的问题,而是调度层没有把 CPU 侧的准备工作与 GPU 侧的计算任务真正重叠起来。
Model Runner V2 架构里的异步调度,核心目标就一个:让 GPU 尽可能不等待。传统同步调度模式下,CPU 要先把这一批请求的输入张量准备好、KV Cache 块分配好、采样参数整理好,然后才把任务提交给 GPU;GPU 算完之后,CPU 再去处理输出、释放资源、准备下一批。这个"准备—提交—等待—处理"的串行链条里,GPU 有大量时间在空转。异步调度要做的,就是把这条链拆开,让 CPU 在 GPU 计算当前批次的同时,已经在准备下一个批次的数据。
这篇文章适合两类人看:一类是正在做推理服务性能调优的工程师,你已经在用 vLLM 或者类似框架,但吞吐量卡在某个瓶颈上不去;另一类是正在做推理框架二次开发的人,你需要理解调度层和模型执行层之间的接口设计。我会从调度器的内部状态机讲起,把 CPU/GPU 重叠的时序拆开,再落到具体的队列设计、批处理策略和踩坑经验上。全文基于 Model Runner V2 的架构思路展开,但很多设计原则对任何自研推理服务都适用。
先给一个直觉性的类比。同步调度就像一家只有一个服务员的餐厅:服务员去后厨下单,站在窗口等厨师做完,端给客人,再回来接待下一桌。异步调度则是服务员下单后立刻回来接待下一桌,厨师做好后按铃,服务员再去取。餐厅的翻台率取决于厨师(GPU)的产出速度,而不是服务员的往返速度。Model Runner V2 的异步调度,本质上就是在训练这个"服务员"学会不等待。
2. 调度器的状态机:请求从进入到离开经历了什么
2.1 请求生命周期的四个阶段
在 Model Runner V2 里,一个请求从进入系统到返回结果,会经历四个明确的状态:WAITING、RUNNING、SWAPPED、FINISHED。这四个状态不是随便起的名字,它们直接对应调度器在每个调度周期里要做的决策。
WAITING状态表示请求已经到达,但还没有被分配到 KV Cache 块,也没有进入任何执行批次。调度器每个周期会扫描等待队列,根据当前显存水位和批次容量决定放多少个请求进入RUNNING。这里有个容易被忽略的细节:等待队列不是简单的 FIFO,而是按优先级和到达时间做了复合排序。如果你做过线上服务就知道,长请求和短请求混在一起时,纯 FIFO 会让短请求被长请求堵住,尾延迟飙升。Model Runner V2 的做法是给每个请求打一个优先级分数,分数由等待时长和预估生成长度共同决定。
RUNNING状态是请求真正占用 GPU 计算资源的阶段。但注意,RUNNING不等于"正在 GPU 上算"。在一个异步调度周期里,处于RUNNING的请求可能正在被 CPU 预处理,也可能正在 GPU 上执行,还可能已经算完等待 CPU 后处理。调度器需要维护一个"在途批次"的概念,记录哪些批次已经提交但还没回收。
SWAPPED状态是显存不足时的降级策略。当新请求需要 KV Cache 块但显存不够时,调度器会把一些低优先级的RUNNING请求的 KV Cache 换出到 CPU 内存,腾出显存给新请求。被换出的请求进入SWAPPED,等显存宽裕时再换回来。这个机制在长上下文场景下特别关键,因为单个请求的 KV Cache 可能占用几个 GB。
FINISHED状态表示请求已经生成完 EOS 或者达到最大长度,等待资源回收。这里有个坑:请求标记为FINISHED后,它的 KV Cache 块不会立刻释放,而是要等当前在途批次全部执行完才能安全回收,否则可能出现 GPU 还在读这块显存、CPU 已经把它分配给别人了的情况。
2.2 调度周期的时间预算分配
异步调度的核心是给每个调度周期设定一个时间预算。Model Runner V2 默认把周期切成三段:CPU 预处理段、GPU 执行段、CPU 后处理段。理想情况下,这三段应该像流水线一样重叠:当第 N 批在 GPU 上执行时,第 N+1 批正在 CPU 上预处理,第 N-1 批正在 CPU 上后处理。
但实际运行时,三段的时间并不相等。GPU 执行段通常最长,因为矩阵乘法和注意力计算是重头戏;CPU 预处理段次之,主要是 tokenize、位置编码计算、采样参数整理;CPU 后处理段最短,主要是 detokenize 和结果封装。如果预处理比执行还慢,那 GPU 还是会等 CPU,异步就失去了意义。
我实测过一组数据:在 A100 上跑 7B 模型,batch size 32,输入长度 512,输出长度 128 的场景下,GPU 执行段约 18ms,CPU 预处理段约 6ms,CPU 后处理段约 2ms。这个比例下异步重叠效果很好,GPU 利用率能从同步模式的 55% 提到 85% 以上。但如果输入长度拉到 4096,预处理段会涨到 20ms 以上,这时候预处理就成了瓶颈,需要把 tokenize 和位置编码计算也放到独立线程池里并行化。
提示:判断你的服务是否需要异步调度,最简单的办法是看
nvidia-smi的 GPU 利用率曲线。如果曲线是锯齿状、波谷明显,说明 GPU 在等 CPU;如果曲线平稳在高位,说明重叠已经做得不错。
2.3 在途批次的管理与回收
在途批次的管理是异步调度里最容易出 bug 的地方。每个提交给 GPU 的批次都有一个唯一的批次 ID,调度器需要维护一个in_flight_batches字典,记录每个批次的提交时间、包含的请求 ID、以及预期的完成回调。
当 GPU 执行完成时,会通过一个事件通知机制(在 CUDA 里通常是 stream callback 或者 event query)告诉调度器。调度器收到通知后,把批次从in_flight_batches移除,然后触发后处理。这里的关键是:后处理不能阻塞调度主循环。如果后处理里做了耗时的 detokenize,整个调度周期就会被拖长。
Model Runner V2 的做法是把后处理丢到一个独立的线程池,调度主循环只负责把批次标记为"待后处理",然后立刻进入下一个周期的预处理。这样即使某个批次的后处理很慢,也不会影响后续批次的提交。但代价是需要更复杂的内存管理:在途批次的 KV Cache 块在后处理完成前不能释放,否则后处理读到的就是脏数据。
3. CPU/GPU 重叠的时序拆解:流水线是怎么跑起来的
3.1 双缓冲队列的设计
要实现 CPU/GPU 重叠,最直接的办法是双缓冲:准备两个批次槽位,一个正在 GPU 上执行,另一个在 CPU 上准备。当 GPU 执行完槽位 A,CPU 已经把槽位 B 准备好了,立刻提交 B,同时开始准备下一个要填入 A 的批次。
这个设计听起来简单,但实现时有几个细节要处理。第一,两个槽位的批次大小可能不同。如果槽位 A 是 32 个请求,槽位 B 只有 16 个,那 GPU 执行 B 的时候计算单元利用率会下降。Model Runner V2 的策略是尽量让相邻批次的请求数接近,调度器在组批时会参考上一个批次的规模做动态调整。
第二,槽位的生命周期管理。槽位 A 执行完后,它的 KV Cache 块要等后处理完成才能回收,但槽位 A 本身要立刻用来准备下一批。这意味着槽位和 KV Cache 块是解耦的:槽位只是逻辑上的批次容器,KV Cache 块由独立的块管理器分配和回收。
第三,异常处理。如果 GPU 执行某个批次时出错(比如 OOM 或者 kernel 崩溃),调度器需要能识别出是哪个批次出错,把该批次里的请求标记为失败,然后继续处理其他批次。双缓冲下,一个批次出错不应该影响另一个槽位的批次。
3.2 预处理阶段的并行化
预处理阶段包括 tokenize、位置编码计算、attention mask 构造、采样参数整理。这些操作里,tokenize 是纯 CPU 计算,位置编码和 mask 构造涉及张量操作但可以在 CPU 上做,采样参数整理是轻量的。
在同步模式下,这些操作串行执行,耗时累加。异步模式下,可以把它们拆到不同的线程里并行。Model Runner V2 的预处理线程池默认开 4 个 worker,tokenize 单独一个线程,张量准备一个线程,采样参数一个线程,还有一个线程负责和调度器通信。
但并行化不是没有代价的。多线程操作同一批请求的数据结构时,需要加锁或者用无锁队列。我见过一些实现为了图省事,在预处理阶段用全局锁,结果异步带来的收益全被锁竞争吃掉了。正确的做法是每个请求的数据独立,预处理线程只读请求元数据,写自己的局部缓冲区,最后合并时用一次原子操作提交。
还有一个容易忽略的点:Python 的 GIL。如果你的推理服务是 Python 写的,多线程预处理在 CPU 密集操作上并不能真正并行。这时候要么用多进程,要么把预处理逻辑下沉到 C++ 扩展里。vLLM 的做法是把核心的预处理逻辑用 CUDA kernel 或者 C++ 实现,Python 层只做调度。
3.3 GPU 执行段的流管理
GPU 执行段涉及 CUDA stream 的使用。默认情况下,所有 CUDA 操作都在默认流上串行执行。要实现异步,需要创建多个 stream:一个用于计算,一个用于数据传输,可能还有一个用于通信(如果是分布式推理)。
Model Runner V2 里,每个在途批次绑定一个独立的 CUDA stream。这样批次 A 的计算和批次 B 的数据拷贝可以重叠。但要注意,KV Cache 的读写必须在同一个 stream 上,否则会出现数据竞争。调度器在分配 stream 时,会确保同一个请求的所有操作都在同一个 stream 上。
流管理的另一个坑是 stream 的数量。创建太多 stream 会导致上下文切换开销增加,反而降低性能。经验值是 stream 数量不要超过 GPU 的 SM 数量的 1/4。A100 有 108 个 SM,那 stream 数量控制在 20 到 30 个比较合适。Model Runner V2 默认用 8 个 stream 轮转,对大多数场景够用。
3.4 后处理与结果返回的异步化
后处理阶段主要是 detokenize 和结果封装。detokenize 是把模型输出的 token ID 转回文本,这个操作在 CPU 上做,耗时和输出长度成正比。如果输出长度是 512,detokenize 可能要 3 到 5ms。
在同步模式下,这 5ms 是纯等待。异步模式下,detokenize 丢到线程池,调度器立刻去处理下一个批次。但这里有个顺序问题:如果请求 A 和请求 B 在同一个批次里,A 的输出先 detokenize 完,B 的后 detokenize 完,返回给客户端时顺序就乱了。对于流式输出(streaming),顺序很重要,因为客户端是按 token 顺序消费的。
Model Runner V2 的解法是给每个请求维护一个输出缓冲区,detokenize 完成的 token 按序写入缓冲区,由一个独立的发送线程按序读取并推送给客户端。这样即使 detokenize 乱序完成,客户端看到的还是有序的 token 流。
4. 批处理策略:怎么组批才能让 GPU 吃满
4.1 连续批处理与迭代级调度
连续批处理(continuous batching)是 vLLM 带火的概念,核心思想是:不等一个批次里所有请求都生成完再组下一批,而是每个迭代周期都重新组批,已经生成完的请求移出,新到达的请求加入。
Model Runner V2 的异步调度把连续批处理又推进了一步:组批和 GPU 执行解耦。调度器在 GPU 执行第 N 批的同时,已经在为第 N+1 批做组批决策。这意味着组批逻辑不能依赖第 N 批的执行结果,只能基于已知信息做预测。
预测什么?主要是预测哪些请求会在第 N 批执行完后变成FINISHED。如果一个请求的生成长度已经接近最大长度,或者已经生成了 EOS,那它大概率会在本批结束后释放。调度器会把这些请求占用的 KV Cache 块标记为"即将释放",在组下一批时提前把这些块算进可用资源里。
这个预测有风险:如果预测错了,某个请求没结束,那下一批的显存就会超。Model Runner V2 的做法是保守预测,只把确定会结束的请求算进去,不确定的留作缓冲。代价是组批时可用资源偏少,批次规模可能偏小,但避免了 OOM。
4.2 优先级与公平性的平衡
线上服务里,请求的优先级差异很大。有的请求来自付费用户,要求低延迟;有的来自免费用户,可以容忍高延迟;还有的是后台批处理任务,只要最终完成就行。调度器需要在组批时体现这些差异。
Model Runner V2 用的是一个加权分数:score = w1 * priority + w2 * wait_time - w3 * estimated_length。priority 是请求自带的优先级,wait_time 是已经等待的时间,estimated_length 是预估的生成长度。w1、w2、w3 是可调参数,默认是 1.0、0.1、0.05。
这个公式的直觉是:高优先级请求优先,等久了的请求要补偿,预估很长的请求稍微降权(避免长请求堵住短请求)。但参数调优很讲究,w2 太大会导致所有请求都等到很晚才被调度,w3 太大会导致长请求饿死。
我踩过一个坑:早期版本把 w2 设成 1.0,结果等待队列里的请求分数涨得飞快,新来的高优先级请求反而排不上队。后来把 w2 降到 0.1,同时给优先级设了上限,才稳定下来。经验是:等待时间的权重不要超过优先级的权重,否则优先级机制就失效了。
4.3 批次规模的动态调整
批次规模不是越大越好。batch size 增大,GPU 计算效率提升,但显存占用也增加,而且单个请求的延迟会变长(因为要等整个批次算完)。Model Runner V2 会根据当前显存水位和延迟目标动态调整批次规模。
具体策略是:维护一个目标批次规模target_batch_size,初始值设为 32。每个调度周期结束后,根据 GPU 利用率和平均延迟调整。如果 GPU 利用率低于 70% 且延迟在目标范围内,target_batch_size加 4;如果延迟超过目标,target_batch_size减 4。调整幅度限制在 ±8 以内,避免震荡。
这个策略在流量平稳时效果很好,但流量突增时会滞后。比如突然来了一波请求,target_batch_size还停留在 32,但实际可以跑 64。Model Runner V2 加了一个快速通道:如果等待队列长度超过target_batch_size的 2 倍,直接跳到最大批次规模,不等平滑调整。
4.4 预填充与解码的混合批处理
预填充(prefill)和解码(decode)的计算特性完全不同。预填充是计算密集型,输入长度可能几千 token,矩阵乘法规模大;解码是访存密集型,每次只生成一个 token,但需要读取整个 KV Cache。
同步调度下,预填充和解码通常分开组批,因为混在一起会导致 GPU 计算单元利用率下降。但异步调度下,可以把预填充和解码混在同一个批次里,让 GPU 同时处理两种任务,提高利用率。
Model Runner V2 的混合批处理策略是:每个批次里保证至少有一个预填充请求(如果有的话),其余位置填解码请求。预填充请求的 KV Cache 块单独分配,解码请求共享已有的块。这样 GPU 在执行时,预填充部分吃满计算单元,解码部分吃满访存带宽,两者互补。
实测下来,混合批处理能把 GPU 利用率再提 5 到 10 个百分点。但代价是调度逻辑复杂了很多,需要处理预填充请求的块分配和解码请求的块复用之间的冲突。如果预填充请求需要的块数超过可用块数,整个批次就得降级,只跑解码。
5. 显存管理与 KV Cache 块的异步回收
5.1 块分配器的设计
KV Cache 按块管理,每个块固定大小(比如 16 个 token 的 KV)。块分配器维护一个空闲块列表和一个已分配块映射。请求需要新块时,从空闲列表取;请求结束时,块归还到空闲列表。
异步调度下,块分配器要处理一个特殊场景:在途批次的块不能立刻回收。假设批次 A 正在 GPU 上执行,它用了块 1 到块 100。批次 A 执行完后,块 1 到块 100 要等后处理完成才能回收。但后处理是异步的,可能在批次 B 已经提交后才完成。如果批次 B 的组批逻辑不知道块 1 到块 100 还被占用,就会把它们分配给新请求,导致数据竞争。
Model Runner V2 的解法是给每个块加一个引用计数。块被在途批次引用时,计数加一;批次完成后处理完,计数减一。计数为零的块才回到空闲列表。组批时,分配器只从计数为零的块里取。
引用计数听起来简单,但实现时要注意原子性。多个线程可能同时操作同一个块的计数,必须用原子操作或者锁。Model Runner V2 用的是 per-block 的自旋锁,因为块的数量多(几万个),用全局锁会成为瓶颈。
5.2 换出与换入的触发条件
当显存不足时,调度器需要把一些请求的 KV Cache 换出到 CPU 内存。触发条件不是简单的"显存使用率超过 90%",而是综合考虑显存水位、等待队列长度、以及换出成本。
换出成本包括:拷贝数据的时间(和块大小成正比)、换入时重新分配块的时间、以及换出期间请求无法参与计算的机会成本。Model Runner V2 用一个简单的启发式:如果显存使用率超过 85% 且等待队列里有请求在等块,就触发换出。换出的对象是优先级最低且预估剩余生成长度最长的请求。
换入的触发条件是:显存使用率低于 70% 且被换出的请求已经等待超过一定时间。这个阈值设置很关键,太低会导致频繁换入换出(抖动),太高会导致被换出的请求饿死。我实测下来,85% 换出、70% 换入的组合比较稳,抖动少。
注意:换出换入涉及 GPU 到 CPU 的数据拷贝,这个拷贝走 PCIe,带宽有限。如果换出的块很多,拷贝时间可能超过 GPU 执行一个批次的时间,反而拖慢整体吞吐。所以换出策略要控制单次换出的块数,Model Runner V2 限制单次最多换出总块数的 10%。
5.3 显存碎片的处理
长时间运行后,显存会出现碎片:空闲块的总数够,但没有连续的大块,导致新请求分配不到足够的连续块。KV Cache 的块是固定大小的,理论上不需要连续,但有些实现为了简化地址计算,要求块连续。
Model Runner V2 用的是非连续块分配,每个请求的 KV Cache 由一组块组成,块之间不需要连续。这样碎片问题就转化为块级碎片:空闲块列表里有很多小块,但每个块的大小是固定的,所以不存在块内碎片。唯一的碎片是块之间的空隙,但因为块大小固定,空隙也是固定大小的,可以复用。
这个设计的代价是地址计算复杂。读取 KV Cache 时,需要先查块映射表,把逻辑块号转成物理块号,再计算物理地址。这个查表操作在 GPU 上做,会增加一点延迟,但相比碎片带来的 OOM 风险,这点延迟值得。
6. 实测中的坑与调优经验
6.1 调度周期过短导致的 CPU 空转
异步调度的一个常见误区是把调度周期设得很短,以为这样能更快响应。实际上,调度周期太短会导致 CPU 频繁进出调度循环,上下文切换开销增加,而且每个周期组批的请求数少,GPU 利用率反而下降。
我一开始把调度周期设成 1ms,结果 CPU 占用率飙到 80%,GPU 利用率只有 60%。后来改成 5ms,CPU 占用降到 30%,GPU 利用率提到 85%。再往上调到 10ms,GPU 利用率没明显变化,但延迟增加了。最后定在 5ms,兼顾吞吐和延迟。
调度周期的合理值取决于 GPU 执行一个批次的时间。经验公式是:调度周期 = GPU 执行时间 / 2。这样 CPU 有足够时间准备下一批,又不会等太久。如果 GPU 执行时间是 18ms,调度周期设 9ms 左右比较合适。
6.2 后处理线程池的队列积压
后处理线程池如果队列积压,会导致在途批次的块迟迟不能回收,进而触发换出,形成恶性循环。我遇到过一种情况:detokenize 线程池只有 2 个 worker,但并发请求有 64 个,后处理队列排了 30 多个批次,显存被在途批次占满,新请求全部卡在等待队列。
解法是给后处理线程池设一个队列上限,超过上限时调度器暂停提交新批次,先把后处理消化掉。同时增加 worker 数量,但 worker 不是越多越好,太多会争抢 GIL。Python 环境下,后处理 worker 数量建议设为 CPU 核数的 1/4,并且把 detokenize 逻辑尽量用 C 扩展实现。
6.3 流式输出的顺序保证
流式输出场景下,客户端期望按 token 顺序收到结果。异步后处理可能导致乱序,需要额外的排序逻辑。Model Runner V2 的做法是给每个请求维护一个单调递增的 token 序号,后处理完成后按序号写入输出缓冲区,发送线程按序号读取。
这个机制有个边界情况:如果某个 token 的后处理特别慢(比如遇到了需要特殊处理的字符),后续 token 都得等它。为了避免这种情况,可以设置一个超时,超过一定时间还没处理完的 token 直接跳过,但这样会导致输出不完整。实际使用中,detokenize 的耗时很稳定,很少触发超时。
6.4 多卡场景下的调度同步
多卡推理时,每个卡有自己的调度器,但请求可能跨卡。Model Runner V2 用的是张量并行,每个请求在所有卡上都有 KV Cache 副本。调度器需要保证同一个请求在所有卡上的批次一致,否则会出现有的卡在算、有的卡在等。
同步机制用的是 all-reduce:每个调度周期结束后,所有卡的调度器做一次 all-reduce,对齐下一批的请求列表。all-reduce 的通信开销和卡数成正比,8 卡场景下每次 all-reduce 约 0.5ms,可以接受。但如果卡数增加到 16 卡以上,通信开销就不可忽略了,需要考虑分层调度或者异步 all-reduce。
6.5 参数调优的优先级
面对一堆可调参数,新手容易懵。我的建议是按以下优先级调:
| 优先级 | 参数 | 推荐值 | 影响 |
|---|---|---|---|
| 1 | 调度周期 | GPU 执行时间 / 2 | 直接影响重叠效果 |
| 2 | 批次规模上限 | 显存的 70% 能容纳的请求数 | 影响吞吐和延迟 |
| 3 | 后处理 worker 数 | CPU 核数 / 4 | 影响后处理吞吐 |
| 4 | 换出阈值 | 85% 换出,70% 换入 | 影响显存利用和抖动 |
| 5 | 优先级权重 | w1=1.0, w2=0.1, w3=0.05 | 影响公平性 |
先调调度周期和批次规模,这两个对性能影响最大。稳定后再调后处理和换出参数。优先级权重最后调,而且要根据业务特点调,没有通用最优值。
7. 从 Model Runner V2 看异步调度的通用设计原则
异步调度不是 vLLM 独有的,任何推理服务只要想榨干 GPU 性能,都绕不开这个设计。Model Runner V2 的实现里有几个原则值得借鉴。
第一,解耦准备与执行。CPU 侧的准备工作(tokenize、组批、块分配)和 GPU 侧的执行必须能并行,这要求数据结构设计时就把"准备中"和"执行中"的状态分开。很多自研框架把这两者混在一起,导致改一处动全身。
第二,用引用计数管理生命周期。在途批次的资源不能提前释放,引用计数是最简单可靠的方案。但要注意原子性和性能,per-resource 的锁比全局锁好。
第三,预测要保守。组批时对资源释放的预测宁可少算,不可多算。少算导致批次规模偏小,损失的是吞吐;多算导致 OOM,损失的是可用性。
第四,后处理不能阻塞主循环。后处理再慢也要丢到独立线程,主循环只做调度决策。这是保证调度周期稳定的关键。
第五,参数调优有优先级。不要一上来就调所有参数,先调影响最大的调度周期和批次规模,稳定后再调其他。
我在实际项目里把这些原则落地时,最大的体会是:异步调度的复杂度不在单个模块,而在模块之间的交互。块分配器和调度器的交互、后处理和块回收的交互、多卡之间的同步,这些交互点才是 bug 的高发区。写代码时要把这些交互点的状态机画清楚,每个状态转换都要有明确的触发条件和副作用。测试时重点测边界情况:显存刚好用完、后处理刚好超时、某个卡刚好慢一拍。这些边界情况在线上出现的概率不低,一旦出现就是雪崩。
最后分享一个排查异步调度问题的小技巧:在调度器里加一个环形缓冲区,记录最近 1000 个调度周期的时间戳、批次规模、GPU 利用率、等待队列长度。出问题时把缓冲区 dump 出来,画成时序图,一眼就能看出是哪个环节卡住了。这个缓冲区开销很小,但排查效率提升巨大。