
1. 从一次显存打满说起异步调度到底在解决什么如果你部署过 vLLM大概率遇到过这样的场景模型权重加载完毕服务正常启动前几个请求响应飞快但并发一上来GPU 利用率曲线就开始剧烈抖动——一会儿飙到 95%一会儿掉到 30%吞吐量怎么调都上不去。更诡异的是nvidia-smi显示显存几乎打满但 GPU 的计算单元却有大段空闲。这不是模型的问题也不是硬件的问题而是调度层没有把 CPU 侧的准备工作与 GPU 侧的计算任务真正重叠起来。Model Runner V2 架构里的异步调度核心目标就一个让 GPU 尽可能不等待。传统同步调度模式下CPU 要先把这一批请求的输入张量准备好、KV Cache 块分配好、采样参数整理好然后才把任务提交给 GPUGPU 算完之后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 上执行时第 N1 批正在 CPU 上预处理第 N-1 批正在 CPU 上后处理。但实际运行时三段的时间并不相等。GPU 执行段通常最长因为矩阵乘法和注意力计算是重头戏CPU 预处理段次之主要是 tokenize、位置编码计算、采样参数整理CPU 后处理段最短主要是 detokenize 和结果封装。如果预处理比执行还慢那 GPU 还是会等 CPU异步就失去了意义。我实测过一组数据在 A100 上跑 7B 模型batch size 32输入长度 512输出长度 128 的场景下GPU 执行段约 18msCPU 预处理段约 6msCPU 后处理段约 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 执行完槽位 ACPU 已经把槽位 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 个 workertokenize 单独一个线程张量准备一个线程采样参数一个线程还有一个线程负责和调度器通信。但并行化不是没有代价的。多线程操作同一批请求的数据结构时需要加锁或者用无锁队列。我见过一些实现为了图省事在预处理阶段用全局锁结果异步带来的收益全被锁竞争吃掉了。正确的做法是每个请求的数据独立预处理线程只读请求元数据写自己的局部缓冲区最后合并时用一次原子操作提交。还有一个容易忽略的点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 上做耗时和输出长度成正比。如果输出长度是 512detokenize 可能要 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 批的同时已经在为第 N1 批做组批决策。这意味着组批逻辑不能依赖第 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%。后来改成 5msCPU 占用降到 30%GPU 利用率提到 85%。再往上调到 10msGPU 利用率没明显变化但延迟增加了。最后定在 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优先级权重w11.0, w20.1, w30.05影响公平性先调调度周期和批次规模这两个对性能影响最大。稳定后再调后处理和换出参数。优先级权重最后调而且要根据业务特点调没有通用最优值。7. 从 Model Runner V2 看异步调度的通用设计原则异步调度不是 vLLM 独有的任何推理服务只要想榨干 GPU 性能都绕不开这个设计。Model Runner V2 的实现里有几个原则值得借鉴。第一解耦准备与执行。CPU 侧的准备工作tokenize、组批、块分配和 GPU 侧的执行必须能并行这要求数据结构设计时就把准备中和执行中的状态分开。很多自研框架把这两者混在一起导致改一处动全身。第二用引用计数管理生命周期。在途批次的资源不能提前释放引用计数是最简单可靠的方案。但要注意原子性和性能per-resource 的锁比全局锁好。第三预测要保守。组批时对资源释放的预测宁可少算不可多算。少算导致批次规模偏小损失的是吞吐多算导致 OOM损失的是可用性。第四后处理不能阻塞主循环。后处理再慢也要丢到独立线程主循环只做调度决策。这是保证调度周期稳定的关键。第五参数调优有优先级。不要一上来就调所有参数先调影响最大的调度周期和批次规模稳定后再调其他。我在实际项目里把这些原则落地时最大的体会是异步调度的复杂度不在单个模块而在模块之间的交互。块分配器和调度器的交互、后处理和块回收的交互、多卡之间的同步这些交互点才是 bug 的高发区。写代码时要把这些交互点的状态机画清楚每个状态转换都要有明确的触发条件和副作用。测试时重点测边界情况显存刚好用完、后处理刚好超时、某个卡刚好慢一拍。这些边界情况在线上出现的概率不低一旦出现就是雪崩。最后分享一个排查异步调度问题的小技巧在调度器里加一个环形缓冲区记录最近 1000 个调度周期的时间戳、批次规模、GPU 利用率、等待队列长度。出问题时把缓冲区 dump 出来画成时序图一眼就能看出是哪个环节卡住了。这个缓冲区开销很小但排查效率提升巨大。