正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码完全拆解(十六):C++ 调度器——等待队列、抢占与 DP 投票如何决定每一步跑谁
从第十五篇接上
这个系列已经拆过 DeepInfer 的不少部件:第一篇建立仓库地形与一次前向的全景;第二篇讲 ZOS 如何把 CPU 后处理移出关键路径;第三篇拆 KV 池、状态槽与 YoCo 截断;第四篇讲 EPLB 的专家复制和重排;第五篇拆投机解码的 DSpark/DFlash;第六篇讲结构化输出与确定性;第七篇讲 TileLang 算子;第八篇讲采样、惩罚与确定性校验;第九篇讲视觉编码器;第十篇讲模型层与 mHC 主干;第十一篇讲注意力族与压缩 KV;第十二篇讲 CUDA Graph;第十三篇讲 DP Attention 与 Wide-EP;第十四篇讲权重装载;第十五篇讲 Linear、RMSNorm 与 RoPE。
这些篇章反复出现同一个动作,却始终没有真正打开:LLMEngine.step() 的第一段是 self.scheduler.schedule(self.sequence_metadata_pool),它返回本步要跑的序列列表和一份 SchedulerOutputs。后面的 ZOS、CUDA Graph、投机解码、P/D 分离,全部建立在这份调度结果上。调度器决定了这一步是 prefill 还是 decode、哪些请求能进来、每条序列跑多少 token、KV 块够不够、显存不够时谁被牺牲。它不执行任何矩阵乘法,但它决定了 GPU 每一步看到的工作形状。
这一篇只盯住一个文件:csrc/engine/scheduler.hpp。它有一千三百多行,绝大部分逻辑集中在三个函数:_schedule()、_schedule_prefill()、_schedule_decode()。围绕它们还有 BlockManager、Sequence 的状态账本、SchedulerOutputs 的结果协议,以及 process_outputs() 里的乐观记账。把这些拼起来,才能理解 DeepInfer 为什么要把一个通常写在 Python 里的调度器搬到 C++,又为什么要在里面加入 DP 组投票这种看起来很像分布式共识的机制。
调度器在推理引擎里到底管什么
先把问题摆清楚。一个在线推理引擎同时面对三类约束:
- 请求语义:有的请求刚到,只有 prompt;有的已经生成了一部分 token,要继续 decode;有的 prompt 太长,必须切成几段;有的 prompt 前缀已经被缓存,只需要补算增量。
- 显存语义:KV Cache 按块分配,每条序列的块表动态增长;DeepSeek-V4 这一类模型还额外有每序列一份的缓存状态槽(
state slot),块和状态必须同时拿到;投机解码还会为尚未验证的草稿 token 预留位置。 - 执行语义:一次 GPU 前向的形状由本步批内序列数和 q/k 长度决定。prefill 是计算密集的大矩阵乘,decode 是访存密集的瘦矩阵乘;把两者混在一步里会拉长 decode 的 token 间隔,分开又会牺牲部分算力利用率。
调度器的职责,就是在这三类约束之间做一次逐步骤的重新求解。它要回答五个问题:
- 这一步做 prefill 还是 decode?
- 哪些请求进入这一步,各自跑多少 token?
- 每条请求需要哪些 KV 块和状态槽,当前拿不拿得到?
- 拿不到时,是让新请求等,还是把已在运行的请求抢占掉?
- 在数据并行(DP)部署下,多个引擎如何保证同一步做同一类工作?
DeepInfer 的调度器不是操作系统意义上的时间片调度器,也不是简单的 FCFS 队列。它更接近一个每步运行一次的资源分配器,输入是请求状态、块表、显存水位和运行时估计,输出是一份可以被 Worker 直接消费的批次描述。SchedulerOutputs 里不仅有序列指针,还有 prompt_run、num_batched_tokens、每个序列的 seqlen_q、sum_seqlen_qxk、max_seqlen_k,以及需要克隆或清零的块与状态列表。Worker 侧不需要再理解请求语义,只需要按这份描述组装张量。

(图源:DeepInfer 官方仓库 doc/images/architecture_overview.png。图中调度器位于引擎与执行侧之间,它不参与模型计算,但决定了执行侧每一步的批次形状。)
DeepInfer 的调度器还有两个和通用框架不同的前提。第一,它服务的是 DeepSeek-V4 系列模型,KV 缓存除了常规的 paged KV,还有随序列走的压缩状态与滑动窗口状态,因此调度器要同时管理两本块账;第二,它默认运行在 DP Attention + Wide-EP 的部署里,每个 DP rank 是一个完整引擎,多个引擎必须保持“同一步同型”,否则集合通信会对不上。前者解释了 state_manager 和 yoco 相关字段为什么出现在调度器里;后者解释了 _dp_allreduce 和 ALL/ANY 投票为什么不是可选优化,而是正确性要求。
从历史脉络看,DeepInfer 继承了 Orca 提出的 iteration-level scheduling(连续批处理)和 vLLM 的 PagedAttention 思路:请求不再按整段生命周期独占一个批次,而是每一步重新组批;KV 按块管理,序列按需增长。它和 vLLM V1、SGLang、TensorRT-LLM 的差异不在于“是否连续批处理”,而在于队列策略、抢占语义、DP 协同方式和 P/D 分离的落点。这些差异会在后面的源码里逐一出现。
数据结构:Sequence、SequenceData 与四本账
调度器操作的基本单位是 Sequence。它把一条请求拆成七块状态:请求元信息、输入 token、生成参数、KV 缓存账本、投机解码账本、结构化输出账本、调度账本。理解调度器之前,必须先看清这四本账,因为后面每一个判断都在读写它们。
输入账本:committed token 与 draft token 分开
SequenceData 保存 token_ids 与 prompt_len。get_len() 是当前总长度,get_output_len() 是已生成长度,get_prompt_len() 是原始 prompt 长度。Sequence 同时保存普通 token 与压缩 tokenizer 的 token(compressed_tokens),后者用于 Engram 查表。输入账本只包含已经提交的 token,不包含尚未验证的草稿 token;草稿 token 单独放在 mtp.draft_tokens 里。这个区分很重要:调度器计算块数时要把草稿算进去,但采样和停止判断只能看已提交部分。
KV 账本:乐观推进与提交
KvCacheState 的字段不多,但每一个都参与调度:
1struct KvCacheState {2 int block_size{0};3 int previous_num_tokens{0};4 int num_tokens{0};5 int hit_tokens{0};6 int swa_valid_start{0};7 std::vector<int> block_table;8 int state_id{-1};9 int last_copy_state_token_idx{0};10};num_tokens 是当前已经拥有 KV 的 token 数,block_table 是物理块号列表,state_id 是状态槽编号。advance_optimistically(token_count) 在调度后立刻把 num_tokens 往前推,commit_processed_tokens(token_count) 在拿到采样结果后再按实际接受的 token 数回填。为什么需要两步?因为 ZOS 允许调度和 GPU 执行重叠:调度器在构造下一步批次时,上一步的采样结果可能还没回来。它必须先假设本步会推进 seqlen_q 个 token,才能算出下一步需要多少块;等结果回来,再把多算的草稿位退回。第三篇和第五篇已经分别从 KV 池和投机解码角度讲过这个时序,这里只强调它对调度器的影响:块数计算必须按乐观值,停止判断必须按提交值。
调度账本:在途 token 与 chunk 剩余量
SequenceScheduleState 负责调度器自己的状态:
1struct SequenceScheduleState {2 SequenceStatus status{WAITING};3 bool schedulable{false};4 std::string branch_id{"default"};5 int chunked_rest_len{0};6 std::deque<int> chunk_checkpoints;7 int total_inflight_tokens{0};8 int last_inflight_tokens{0};9};status 是状态机;schedulable 用于多模态预处理未完成时挡住请求;branch_id 用于 fork 出来的前缀分支;chunked_rest_len 记录这条请求还剩多少 prompt token 没算;chunk_checkpoints 是调用方指定的强制切分点;total_inflight_tokens 和 last_inflight_tokens 记录已经发射但还没回写的 token 数。最后两个字段是 ZOS 的核心:调度器在计算块数时,必须把“在途 token”也算进去,否则下一步的块表会少算,GPU 写 KV 时就会越界。
在途账本的更新规则写在两个小函数里:
1void start_inflight_step(int token_count) {2 total_inflight_tokens += token_count;3 last_inflight_tokens = token_count;4}5
6void finish_inflight_step() {7 if (total_inflight_tokens == last_inflight_tokens) {8 total_inflight_tokens = 0;9 last_inflight_tokens = 0;10 } else {11 total_inflight_tokens = last_inflight_tokens;12 }13}它维护的是一个两拍流水:当前步发射后,total 包含本步和上一步;上一步回写完成后,total 退化成只剩本步。这个设计假设最多只有一步在途,和 ZOS 的“延后一拍”一致。如果未来把流水加深,这里必须跟着改,否则块数会算错。
投机与结构化输出账本
MtpState 保存草稿 token、接受率、置信度、info slot 和两个计数器。StructuredOutputState 保存 matcher 与状态码。调度器对它们的使用很克制:MtpState 主要用来分配 info slot 和判断是否有草稿;StructuredOutputState 主要用来在抢占时判断这条请求能不能重算。第五篇和第六篇已经展开过细节,这里只记住一条:结构化输出一旦开始,重算会破坏自动机状态,所以调度器不允许它被重算式抢占。
块数公式
调度器判断一条序列要多少块,用的是 Sequence::get_num_blocks():
其中 L(s) 是已提交 token 数(get_len()),I(s) 是在途 token 数(total_inflight_tokens),D(s) 是草稿 token 数(get_nextn_len()),P 是块大小。向上取整意味着最后一块即使只装了一个 token 也要占一整块。为什么要加 I 和 D?因为这一步的 GPU 前向会把在途 token 和草稿 token 的 KV 写进块表;如果只按已提交长度分配,写指针会落到尚未分配的块上。这个公式是整个准入逻辑的地基,后面 _append_slot()、_block_table_capacity()、_can_allocate_with_state() 都在不同时间点使用它。
SchedulerOutputs 则是调度器的输出协议:
1struct SchedulerOutputs {2 std::vector<SequencePtr> scheduled_seqs;3 bool prompt_run;4 int num_batched_tokens;5 std::vector<SequencePtr> finished_seqs;6 std::vector<SequencePtr> ignored_seqs;7 std::vector<int> seqlens_q;8 int64_t sum_seqlen_qxk;9 int max_seqlen_k;10 int min_seqlen_k;11 double attn_tflops;12 double total_tflops;13 int num_cached_tokens_gpu;14 const std::vector<int> &clone_states;15 const std::vector<int> &clone_blocks;16 const std::vector<int> &zero_states;17};scheduled_seqs 与 seqlens_q 严格同序;prompt_run 决定 Worker 走 prefill 还是 decode 路径;finished_seqs 和 ignored_seqs 是本步要回传结束原因的序列;clone_states、clone_blocks、zero_states 是 fork 或重算时需要做的 GPU 侧搬移。后三个字段是借用调度器内部 vector 的零拷贝视图,Python 侧必须在下一次 schedule() 之前消费完,不能跨步持有。这个约束写在 pybind 注释里,也是调度器与引擎之间最容易踩的接口边界。
队列与状态机:waiting、running、prompted
调度器内部只有三个容器:
1std::deque<SequencePtr> waiting;2std::deque<std::tuple<SequencePtr, bool>> prompted;3std::vector<SequencePtr> running;waiting 是还没拿到完整执行资格的请求,按到达顺序排队;running 是已经持有块表和状态槽、可以进入 decode 的请求;prompted 是一个很窄的队列,专门放“prompt 已经全部缓存、只等资源克隆进 running”的 fork 序列。大多数人对前两个队列很熟悉,对第三个队列容易忽略,但它是理解 DeepInfer 分支缓存(branch)和前缀复用的关键。
入队:add_sequence 只做三件事
add_sequence() 很轻:
1void add_sequence(std::shared_ptr<Sequence> seq) {2 seq->request.arrival_time = get_timestamp_ms();3 if (engine_type == EngineType::NORMAL) {4 seq->reserve_output_tokens();5 seq->cache.block_table.reserve(_block_table_capacity(*seq));6 }7 waiting.push_back(std::move(seq));8}它记录到达时间,预分配输出 token 的 vector 容量,预分配块表的容量,然后把序列推进 waiting。注意这里没有分配任何物理块,也没有检查显存。真正的准入发生在 schedule() 里。这样做的好处是入口线程不会被显存状态阻塞,坏处是 waiting 队列可能堆积大量暂时进不来的请求;调度器必须用 max_num_prompts、prompt_limit 和块检查把它们挡住。
_block_table_capacity() 按“prompt + max_tokens”预留块表容量,避免运行过程中反复扩容:
其中 M(s) 是 max_tokens。多留一块是给投机解码或边界对齐的余量。这个容量只是 vector 的预留,不是物理块的预分配;物理块仍然按需申请。
可调度标志:多模态预处理的队首阻塞
schedulable 默认为 false。文本请求在创建时通常会被立即置为 true;带图像的请求要先经过视觉编码器预处理,multimodal_processor 完成之后调用 scheduler.set_schedulable(request_id) 才放行。调度器在 prefill 循环里遇到 !schedulable 会直接 break,而不是跳过。这是一个刻意的队首阻塞:队列按到达顺序排列,如果队首的图文请求还没准备好,后面的文本请求也不能插队。为什么?因为 DeepInfer 的 waiting 顺序同时承载了公平性和前缀局部性;随意跳过会让早到的请求被不断后推。代价是队首慢请求会拖住整条队列,工程上需要用预处理并发度和超时来控制。
状态机与结束原因
SequenceStatus 有十种状态:
1enum class SequenceStatus {2 WAITING,3 RUNNING,4 SWAPPED,5 FINISHED_STOPPED,6 FINISHED_LENGTH_CAPPED,7 FINISHED_ABORTED,8 FINISHED_IGNORED,9 FINISHED_PREEMPTED,10 FINISHED_SO_FAILED,11 FINISHED_VE_FAILED,12};SWAPPED 在当前调度器里没有实际使用;DeepInfer 不做 KV 换出,只做重算或直接终止。FINISHED_STOPPED 是命中 EOS,FINISHED_LENGTH_CAPPED 是达到 max_tokens 或模型长度,FINISHED_ABORTED 是用户取消,FINISHED_IGNORED 是 prompt 超限,FINISHED_PREEMPTED 是显存不足被终止,FINISHED_SO_FAILED 是结构化输出编译或运行失败,FINISHED_VE_FAILED 是视觉编码失败。get_finished_reason() 把这些状态映射成对外字符串,其中 FINISHED_PREEMPTED 的字符串是 insufficient_system_resource,这解释了为什么线上偶尔会看到这个看起来像系统错误的结束原因。
is_finished() 用一个很巧的写法判断:status >= FINISHED_STOPPED。枚举顺序把“运行中”放在前面,把“已结束”放在后面,因此一次比较就能区分。这个技巧在 C++ 里很常见,但它要求后续新增状态时不能把运行态插到结束态之后。
清理:谁负责把死请求移出队列
abort_sequence() 只负责把请求标记为 FINISHED_ABORTED,不负责从队列里删除。真正的清理有两个入口:prefill 循环遇到已结束的队首会把它放进 ignored_seqs 并弹出;decode 分支在开头会扫描整个 waiting,把已结束的请求移出。为什么 decode 也要扫?因为一个请求可能在多模态预处理阶段失败,带着 FINISHED_VE_FAILED 留在 waiting;如果某个 DP rank 的 waiting 里只剩这类请求,它的 want 会一直是 false,ALL 投票会被它永久压死。源码注释明确写了这个兜底逻辑,这是一个典型的“异步状态机 + 分布式投票”耦合导致的边界情况。
free_finished_seqs() 在 decode 分支开头清理 running 中已结束的序列,同时释放 MTP info slot。注意它只释放 slot,不释放块表和状态槽;块和状态的释放发生在 process_outputs() 之后的 free_seq() 或下一次 free_seq_nolock()。这个延迟释放是 ZOS 的要求:上一步的 GPU 可能还在读这些块,不能立刻回收。第二篇已经详细讲过延迟释放的时序,这里只需要知道调度器的“已结束”和“资源已回收”不是同一时刻。
prompted 队列:fork 序列的临时等候区
prompted 里放的是 (SequencePtr, bool forked)。它来自 fork_seq():当一条序列的 prompt 已经全部缓存,调用方想基于它的前缀开一个新分支时,fork_seq() 会复制 token、pin 住父序列的块和状态,然后根据 prompt 是否完整决定放进 waiting 还是 prompted。如果 prompt 未完成,新序列还要继续 prefill,进 waiting 队首;如果 prompt 已完成,新序列不需要 prefill,只需要在 decode 准入时克隆状态和最后一个可能写了一半的块,然后直接进 running。
_schedule_decode() 开头会把 prompted 里的序列搬进 running:
1while (!prompted.empty() && running.size() < max_seqs) {2 auto &&[seq, forked] = prompted.front();3 if (seq->schedule.status == SequenceStatus::FINISHED_ABORTED) {4 finished_seqs.push_back(std::move(seq));5 prompted.pop_front();6 continue;7 }8 if (forked) {9 if ((state_manager && !state_manager->can_allocate_single_nolock()) ||10 !block_manager->can_allocate_single_nolock()) {11 break;12 }13 _clone_state(*seq);14 int new_block = block_manager->allocate_single_nolock();15 // ... 克隆最后一个块,重写 block_table16 }17 admit(std::move(seq));18}这段代码说明两件事。第一,fork 序列的准入只检查“一个状态槽 + 一个块”,因为父序列的块已经被 pin 住,子序列只需要复制最后一个可能被写过的块,避免父子共享同一个尾部块导致写冲突。第二,prompted 队列也会被 max_num_seqs 限制;如果 running 已满,fork 序列继续等,不会挤掉已在运行的请求。
branch_id 以 _ 开头是 fork 序列的标记。prefill 调度里有一个分支专门处理它:如果 branch_id 以 _ 开头且 block_table 非空,说明这是一条前缀命中的 fork 序列,prefix_caching_hit = true,调度器会剥掉前缀下划线,克隆状态,按需补分配块,并把尾部共享块复制成私有块。这个机制让“同一段系统提示词 + 不同用户问题”的请求可以共享前缀 KV,而不必为每个分支复制整段缓存。fork_seq() 通过 pybind 暴露给了 Python 侧,但当前仓库的公开 LLMEngine 路径并没有调用它;它更像是一个留给上层服务编排的接口,调度器负责把接口语义实现完整,调用策略由更外层的路由决定。
准入算术:BlockManager 与 StateManager 的联合分配
调度器的第一道硬门是显存。DeepInfer 的显存资源分成两类:KV 块和状态槽。KV 块由 BlockManager 管理,状态槽是给 V4 的压缩缓存状态准备的,也用同一套 BlockManager 结构,只是每个“块”的容量是“一条序列的完整状态”。调度器持有两个 shared_ptr<BlockManager>,在构造时分别传入 block_manager 和 state_manager;如果模型不需要状态槽,后者是 nullptr。
空闲块链表:O(1) 分配与引用计数
BlockManager 内部不是简单的 std::vector<bool>,而是一个带引用计数的双向链表:
1struct Blocks {2 Blocks(int _n) : _blocks(_n), _head(1), _tail(_n - 1), _size(_n - 1) {3 TORCH_CHECK(_n > 1, "GPU blocks 太少了: ", _n);4 for (int i = 1; i < _n; ++i) {5 _blocks[i].prev = i - 1;6 _blocks[i].next = i + 1;7 }8 _blocks[_n - 1].next = 0;9 }10 // ... _push_back / _erase / _pop_front11 struct block {12 int ref_count{0};13 int next{0};14 int prev{0};15 };16 std::vector<block> _blocks;17 int _head, _tail, _size;18};块 0 被保留为“空指针”,真实可用的块从 1 到 n-1。allocate() 从链表头弹出一个块并把引用计数加一;free() 把引用计数减一,归零时挂回链表尾;pin_block() 只增加引用计数,不改变链表归属。引用计数让 fork 序列可以共享父序列的块:父序列释放时块不会立刻回到空闲链表,因为子序列还 pin 着它。reset() 会检查 _size == _blocks.size() - 1,如果还有块在用就直接报错,防止初始化阶段把仍有引用的池子清空。
can_allocate_nolock() 的判定很直接:
1AllocStatus can_allocate_nolock(int to_allocate_blocks) {2 if (to_allocate_blocks >= num_blocks) {3 return AllocStatus::NEVER;4 }5 if (blocks.num_free_blocks() >= to_allocate_blocks) {6 return AllocStatus::OK;7 }8 return AllocStatus::LATER;9}三种返回值对应三种调度决策:OK 立即分配;LATER 现在不够但将来可能够,调度器应该停下来等下一轮;NEVER 这条请求永远不可能被满足,直接拒绝。注意 >= num_blocks 用的是大于等于,因为块 0 永远不可用,一个请求最多只能拿到 num_blocks - 1 块;如果它需要的块数达到总块数,等多久都没用。
联合分配:块和状态必须同时到位
单看 KV 块不够。V4 的每条运行序列还需要一个状态槽;如果块够但状态槽不够,请求仍然不能进来。_can_allocate_with_state() 把两个检查串起来:
1AllocStatus _can_allocate_with_state(int num_blocks) {2 auto status = block_manager->can_allocate_nolock(num_blocks);3 if (status == AllocStatus::OK && state_manager &&4 state_manager->can_allocate_nolock(1) == AllocStatus::LATER) {5 status = AllocStatus::LATER;6 }7 return status;8}这个函数只把 LATER 传播出来,不把 NEVER 传播出来。状态槽的 NEVER 在构造时就被排除了:如果模型需要状态槽,槽数一定大于 0;一次只申请一个槽,can_allocate_nolock(1) 只有在总槽数为 0 时才是 NEVER,而那种情况下 state_manager 根本不会被创建。真正需要关心的是“块够、槽不够”的组合,它必须降级成 LATER,否则调度器会错误地拒绝一条其实只是暂时缺槽的请求。
BlockManagerMutexAcquire 负责同时锁住两个池子,并且规定了锁顺序:先锁状态池,再锁 KV 池。这个顺序不是随便定的;如果两个线程以相反顺序加锁,就会死锁。调度器在 _schedule() 开头构造这个 RAII 对象,整个调度过程都持有两把锁,因此内部的 *_nolock() 接口可以安全地直接操作链表。pybind 暴露的 try_allocate_seq() 也会构造同样的 guard,说明这条锁顺序是全局约定。
prompt 长度上限:chunked 与不 chunked 的分岔
在进入 waiting 循环之前,调度器先算出一个 prompt_limit:
如果开启 chunked prefill,一条长 prompt 可以跨多个 step 完成,单步只受 token 预算限制,所以整条 prompt 的上限就是模型最大长度。如果不开启 chunked prefill,一条 prompt 必须在一个 step 内完整 prefill,单步预算就成了硬上限;超过 max_num_batched_tokens 的 prompt 会被直接拒绝,结束原因是 prompt_length_exceeded。这个分岔解释了为什么配置校验里有一条:不开 chunked prefill 时,max_num_batched_tokens 不能小于 max_model_len,否则模型支持的长度和调度器允许的长度不一致。
前缀命中:从已有块表开始补差
一条新请求的 block_table 为空,调度器会先检查整条 prompt 的块数;一条 fork 请求的 block_table 非空,说明前缀已经有 KV,只需要补上增量部分。增量部分的算术是:
1int num_reserve_blocks = seq->cache.num_tokens / seq->cache.block_size;2TORCH_CHECK_LE(num_reserve_blocks, (int)seq->cache.block_table.size());3int to_allocate_blocks = seq->get_num_blocks() - num_reserve_blocks;num_reserve_blocks 是已经完整覆盖的块数。get_num_blocks() 按当前总长度、在途 token 和草稿 token 算出目标块数;两者相减就是还需要补的块数。TORCH_CHECK_LE 保证已有块表至少能覆盖 num_tokens,否则说明前缀缓存的账本已经不一致,宁可报错也不能继续。
如果 num_tokens 不是块大小的整数倍,最后一块只写了一半,不能直接给子序列共享,否则父子写同一个块会互相污染。调度器会先 clone 这个尾块:
1if (seq->cache.num_tokens % seq->cache.block_size) { // 有尾巴,需要 copy2 clone_blocks.emplace_back(seq->cache.block_table[num_reserve_blocks]);3 clone_blocks.emplace_back(new_blocks.front());4}clone_blocks 是一个扁平数组,每两个元素是一对 (src, dst);dst = 0 表示只归还 src、不做拷贝。这个编码在 pybind 里被解释成 [n, 2] 的 int32 数组,Worker 侧按对执行 cudaMemcpy 或归还。调度器只负责算出“哪些块要复制、复制到哪”,不直接碰 GPU。
prefill 的批组装:token 预算、chunk 切点与图像边界
_schedule_prefill() 是调度器里最长的一个循环。它从 waiting 队首开始,逐条判断能不能进来,直到遇到一个硬约束才停。理解它的最好方式是按代码顺序走一遍,每一步都问“为什么这里要停”。
循环骨架
1while (!waiting.empty()) {2 auto &seq = waiting.front();3 if (seq->schedule.status == FINISHED_ABORTED ||4 seq->schedule.status == FINISHED_VE_FAILED ||5 seq->schedule.status == FINISHED_SO_FAILED) {6 ignored_seqs.push_back(seq);7 waiting.pop_front();8 continue;9 }10 if (!seq->schedule.schedulable) {11 break;12 }13 int num_prompt_tokens = seq->get_len() - seq->cache.num_tokens;14 // ... 准入、预算、切分15}前两个分支决定了队列的“可推进性”。已结束的请求会被跳过并放进 ignored_seqs,由引擎回传结束原因;!schedulable 的请求会让整个循环停下,后面的请求即使能跑也不跑。这就是前面说的队首阻塞。它看起来保守,但保证了 waiting 的到达顺序不会被多模态预处理的快慢打乱。
三道硬约束
进入循环后,调度器依次检查三个上限:
- 序列数上限:
num_curr_seqs + 1 > max_num_prompts就停。num_curr_seqs初始化为running.size(),max_num_prompts等于max_num_seqs。这意味着正在 decode 的序列也占用序列名额;如果 running 已经满了,新 prompt 一个也进不来,必须等 decode 结束或抢占腾位。 - sum-K 上限:
prefill_sum_seqlen_k_remain < seq->get_len()就停。prefill_sum_seqlen_k_remain初始化为prefill_max_sum_seqlen_k,每接纳一条 prompt 就减去它的总长度。这个上限控制的是本步所有 prefill 序列的 K 长度之和,不是 token 预算。为什么需要它?因为注意力计算量大致按 ∑iqi⋅ki 增长,而 KV gather 的 workspace 也随 K 总量增长;只限制 q 的总 token 数,不能防止多条超长 prompt 把 attention 中间缓冲撑爆。默认值来自DEEPINFER_PREFILL_MAX_SUM_SEQLEN_K,未设置时用一个很大的常数表示“不限制”。 - token 预算:由
max_num_batched_tokens控制,下一节展开。
这三个上限的顺序也有讲究。序列数上限和 sum-K 上限是“整条请求”级别的约束,先判断可以避免为一个根本放不下的请求做块分配和切分计算;token 预算则是“本次 chunk”级别的约束,放在后面才能决定同一条请求是完整跑还是切一段。
token 预算与 CP 对齐
token 预算的计算不是简单的 num_batched_tokens + seqlen_q。源码里有一个对齐步骤:
1if (seq_workload > (max_num_batched_tokens - num_batched_tokens) / cp_align * cp_align) {2 new_workload = std::min(seq_workload,3 (max_num_batched_tokens - num_batched_tokens) / cp_align * cp_align);4 seqlen_q = new_workload;5}cp_align 在构造时确定为 cp_size > 1 ? cp_size * 2 : 1。也就是说,开启上下文并行(CP)时,每个 chunk 的 token 数必须是 2 * cp_size 的整数倍;不开 CP 时对齐为 1。这个 2 来自 CP 的 zigzag 切分:序列被切成前后对称的两段,每条 rank 拿到一段前缀和一段后缀,chunk 长度必须是偶数才能在两个方向上均匀切。对齐的代价是可能浪费几个 token 的预算,但如果不齐,CP 侧的 gather 和重排会对不上。
预算的扣减同样按对齐后的值:
Tused←Tused+⌈Aq⌉A其中 q 是本条请求本次的 seqlen_q,A 是 cp_align。这个公式解释了为什么 num_batched_tokens 可能略大于实际 token 数:多出来的是对齐填充,不是有效计算。
chunk checkpoints:调用方指定的切点
chunk_checkpoints 是一个 std::deque<int>,里面是从小到大排列的 token 位置。调度器在切 chunk 时会先看队首:
1auto start_offset = seq->cache.num_tokens;2while (!seq->schedule.chunk_checkpoints.empty() &&3 seq->schedule.chunk_checkpoints.front() <= start_offset) {4 seq->schedule.chunk_checkpoints.pop_front();5}6int checkpoint = seq->schedule.chunk_checkpoints.empty()7 ? 0 : seq->schedule.chunk_checkpoints.front();8
9if (start_offset + seqlen_q >= checkpoint && checkpoint > start_offset) {10 seqlen_q = checkpoint - start_offset;11 seq->schedule.chunk_checkpoints.pop_front();12}它做的是“如果本次 chunk 会越过下一个检查点,就在检查点处截断”。检查点可以由调用方在 add_request() 时传入,典型用途是让调度器在某个前缀边界处停下,方便外部做缓存落盘、状态复制或前缀复用。源码没有在公开路径里大量使用这个参数,但机制是完整的:调度器只负责尊重切点,不关心切点为什么存在。
图像边界:不能把 <image_pad> 序列切成两半
多模态请求的 token 流里有 <image>、<image_pad>、<image_new_line> 等特殊 token,连续的 <image_pad> 代表一张图像的 patch 序列。如果 chunk 切在 pad 序列中间,视觉编码器和语言模型看到的图像 token 数会对不上。_adjust_chunk_size() 专门处理这件事:
1size_t _adjust_chunk_size(size_t q_len, size_t old_k_len,2 const std::vector<int> &token_ids, bool make_larger = false) {3 size_t k_len = old_k_len + q_len;4 if (k_len >= token_ids.size()) return q_len;5 if (_is_inside_image_pad_region(k_len, token_ids)) {6 if (make_larger) {7 while (k_len < token_ids.size() && token_ids[k_len] == MM_TOKEN_IMAGE_PAD) {8 ++k_len;9 }10 } else {11 while (k_len > old_k_len && token_ids[k_len - 1] == MM_TOKEN_IMAGE_PAD) {12 --k_len;13 }14 if (k_len > old_k_len && token_ids[k_len - 1] == MM_TOKEN_IMAGE) {15 --k_len;16 }17 }18 return k_len - old_k_len;19 }20 return q_len;21}这段逻辑有两个方向。默认方向是“往前缩”:如果切点落在 pad 区域内,就把切点退到 pad 序列开始之前,宁可这一轮少算几个 token,也不破坏图像边界。make_larger 方向是“往后扩”:当缩完之后剩余的 seqlen_q 太小、甚至为 0 时,调度器会尝试把切点推到 pad 序列结束之后,保证至少有一条完整的图像段可跑。源码先按默认方向算一次,如果加上去会超预算,就用默认结果;否则用扩展结果。这个细节说明多模态调度不是“把 token 当普通文本切”那么简单。
最小 chunk 与 lookahead
切完之后还有一道检查:
1if (seqlen_q < std::max(lookahead_tokens, 1)) {2 break;3}lookahead_tokens 是投机解码和滑动窗口回放需要的前瞻 token 数。如果本次 chunk 小于这个值,调度器宁可不调度,也不发一个“算了但不够验证”的批次。默认 lookahead_tokens = 0,所以普通文本请求的下限是 1 个 token;开启投机解码或特定缓存模式时,这个下限会抬高。这个检查放在所有切分之后,意味着它可能让一条本来能进来的请求被推迟到下一轮,直到预算足够覆盖最小 chunk。
提交副作用:分配、改状态、记账
只有所有检查都通过,调度器才开始做有副作用的操作:
1num_batched_tokens += (seqlen_q + cp_align - 1) / cp_align * cp_align;2
3TORCH_CHECK(seq->schedule.status == SequenceStatus::WAITING, ...);4if (seq->cache.block_table.empty()) {5 acquire.allocate_seq(*seq);6}7
8if (!is_chunked) {9 num_curr_seqs += 1;10 seq->schedule.status = SequenceStatus::RUNNING;11 allocate_mtp_info_slot(*seq);12}13
14scheduled.push_back(seq);15seqlens_q.push_back(seqlen_q);16if (is_chunked) {17 seq->schedule.chunked_rest_len = seq->get_len() - (seqlen_q + seq->cache.num_tokens);18 tokens_chunked_prefill_remaining += seq->get_len();19} else {20 seq->schedule.chunked_rest_len = 0;21}22waiting.pop_front();这段代码有几个容易忽略的点。
第一,allocate_seq() 一次分配 seq->get_num_blocks() 个块,并且同时分配一个状态槽。它假定前面的 _can_allocate_with_state() 已经确认资源足够,所以内部不再检查。这个“先检查、后分配”的分工要求调用方不能在两者之间释放锁或改变池子状态;BlockManagerMutexAcquire 在整个循环里一直持有,保证了这一点。
第二,只有非 chunked 的请求才会被改成 RUNNING 并分配 MTP slot。chunked 请求仍然留在 WAITING,因为它还没完成 prefill,不能进入 decode。它的 chunked_rest_len 记录剩余 prompt 长度,下一轮还会从 waiting 队首被取出。tokens_chunked_prefill_remaining 把这条请求的总长度累加进去,作为本步 chunked 工作量的统计口径。
第三,chunked 请求被调度后会 break(在 EngineType::NORMAL 下):
1if (is_chunked && engine_type == EngineType::NORMAL) {2 break;3}这意味着一个 step 里最多推进一条 chunked 请求的一个 chunk,不会在同一轮里连续给同一条请求切好几段。为什么?因为 chunked prefill 的目的是让长 prompt 不要一次性阻塞 decode;如果一轮里把同一条长 prompt 连切多段,等于变相做完整 prefill,失去了分块的意义。让其他请求在下一轮有机会插入,才是 chunked 的调度价值。
第四,seqlens_q 记录的是每条被调度请求本步的 q 长度,顺序与 scheduled 一致。SchedulerOutputs 的 sum_seqlen_qxk、max_seqlen_k 等统计量会在 schedule() 的后半段按这份列表计算,供日志和 MFU 估计使用。
返回值:空手而归也要说清楚
_schedule_prefill() 的结尾有两种返回:
1if (engine_type != EngineType::NORMAL || !scheduled.empty() || !ignored_seqs.empty()) {2 return _make_outputs(std::move(scheduled), true, num_batched_tokens, {},3 std::move(ignored_seqs), std::move(seqlens_q), 0);4}5return nullptr;对 PROMPT 引擎,即使一条都没调度到,也要返回一个空的 prefill 结果,因为它的职责就是只做 prefill,不能转 decode。对 NORMAL 引擎,如果既没有调度到请求、也没有要忽略的请求,就返回 nullptr,让 _schedule() 决定是否转 decode。这个 nullptr 不是错误,而是一个“本轮 prefill 没有产出”的信号;_schedule() 会用它来做第二次 DP 投票,避免全组空转。
decode 的 LCFS 与抢占:先跑谁,牺牲谁
_schedule_decode() 的输入是 running,输出是本步进入 decode 的序列。它的开头有三件事:清理已结束的序列、清理 waiting 里的死请求、把 prompted 里的 fork 序列搬进来。之后才进入真正的选择逻辑。
LCFS 排序
1std::sort(running.begin(), running.end(),2 [](const SequencePtr &a, const SequencePtr &b) {3 return a->get_output_len() > b->get_output_len();4 });按已输出 token 数从大到小排序。然后调度器从数组尾部开始取:
1size_t l = 0, r = running.size();2while (l < r && scheduled.size() < max_seqs) {3 auto &seq = running[r - 1];4 while (!block_manager->can_allocate_single_nolock() && r - l > 1) {5 auto victim_seq = running[l];6 _preempt(victim_seq, ignored_seqs);7 l += 1;8 }9 if (block_manager->can_allocate_single_nolock()) {10 _append_slot(*seq);11 scheduled.push_back(std::move(seq));12 } else {13 _preempt(seq, ignored_seqs);14 }15 r -= 1;16}尾部是输出最短的序列,也就是最近到达、最年轻的请求;头部是输出最长的序列,也就是最早到达、最老的请求。调度器优先让年轻请求进入本步,显存不足时从头部牺牲老请求。源码注释把这种策略称为 LCFS(last-come-first-served)。
为什么用输出长度而不是到达时间?在同步 decode 的常规路径里,每条 running 序列每个 step 只推进一个 token,输出长度和到达顺序高度相关;用输出长度排序不需要额外维护时间戳,也能在投机解码导致推进速度不一致时自然偏向“欠账少”的请求。但它只是近似:开启变长投机验证后,不同请求每步接受的 token 数不同,输出长度可能交叉,LCFS 的顺序就不再严格等于到达顺序。这一点在分析公平性时不能忽略。
LCFS 的代价与收益
LCFS 和 vLLM V1 的 FCFS 是相反的。FCFS 保护老请求,新请求排在后面;LCFS 保护新请求,老请求在显存不足时先被牺牲。它带来的直接后果是:
- 新请求的首 token 延迟更容易控制。刚到的请求不会被一长串老请求堵住,尤其在突发流量下,年轻请求能更快进入批次。
- 老请求的尾延迟更差。一条已经生成很多 token 的请求,一旦显存紧张,会被优先抢占;如果开启重算,它要重新 prefill 一大段前缀,代价极高;如果开启终止,用户会直接收到失败。
- 块回收更集中。抢占头部的一条长序列,通常能一次性释放较多块,比抢占多条短序列更容易让当前步凑够一个块。这是 LCFS 在“块粒度”上的一个实际好处。
DeepInfer 的默认配置是 stop_preempted_request = true。这意味着大多数生产部署里,抢占不是“悄悄重算”,而是“把老请求终止并返回 insufficient_system_resource”。从服务角度看,这是一种快速失败:与其让一条长请求反复重算、拖慢整批,不如让它早点结束,把容量留给新请求。它把调度器的抢占从“性能优化”变成了“准入控制”,代价是用户可见的失败率。如果业务不能接受这种失败,就必须把 KV 池和 max_num_seqs 配到很少触发抢占,或者显式关闭 stop_preempted_request,接受重算带来的延迟抖动。
抢占的两条路径
_preempt() 根据两个条件决定“终止”还是“重算”:
1const bool so_blocks_recompute = seq->structured_output.status != 0;2if (stop_preempted_request || so_blocks_recompute) {3 seq->schedule.status = SequenceStatus::FINISHED_PREEMPTED;4 free_seq_nolock(*seq);5 ignored_seqs.push_back(seq);6} else {7 seq->reset_for_recompute();8 waiting.push_front(seq);9 seq->schedule.status = SequenceStatus::WAITING;10 free_seq_nolock(*seq);11}第一条路径是终止:状态改成 FINISHED_PREEMPTED,释放块和状态槽,把序列放进 ignored_seqs,由引擎回传结束原因。第二条路径是重算:调用 reset_for_recompute() 把 KV 账本、在途计数、MTP 计数器全部复位,把序列放回 waiting 队首,状态改成 WAITING,然后释放资源。放回队首而不是队尾,是为了让它下一轮优先被重新接纳,避免刚被抢占又被新请求挤到后面。
结构化输出请求不能走重算路径,因为 matcher 的自动机状态和已经接受的 token 绑定在一起,重算会让状态与文本错位。源码直接把它归入终止路径,并打一条警告。这是一个明确的正确性优先决策:宁可让请求失败,也不产生语法上非法的输出。
COMPLETION 引擎还有一条更硬的限制。它在构造时就检查:
1TORCH_CHECK(engine_type != EngineType::COMPLETION || stop_preempted_request,2 "COMPLETION engine cannot recompute preempted requests (no prefill channel); "3 "it must be deployed with stop_preempted_request=true (--stop-preempted-request)");COMPLETION 引擎只做 decode,没有 prefill 通道;一旦允许重算,被抢占的请求回到 waiting 后永远没有引擎能给它补 prefill。这个检查把配置错误挡在启动阶段,而不是等到线上第一次抢占才暴露。
_append_slot():decode 的块增长
decode 每步只推进少量 token,大多数时候不需要新块,只在跨过块边界时申请一块。_append_slot() 的逻辑是:
1int num_blocks = seq.get_num_blocks();2if (block_table.size() < num_blocks) {3 block_table.emplace_back(block_manager->allocate_single_nolock());4} else if (block_table.size() > num_blocks) {5 // 释放多余块6}7TORCH_CHECK(block_table.size() == num_blocks, ...);它同时处理“增长”和“收缩”。增长发生在序列长度跨过块边界,或者投机解码的草稿位让 get_num_blocks() 变大;收缩发生在草稿被拒绝后 commit_processed_tokens() 把 num_tokens 退回,块表多出来的部分要归还。这个函数只检查单个块是否可用,不检查状态槽,因为 decode 序列已经持有状态槽;状态槽只在准入时申请一次。
谁被选中,谁被牺牲
把 decode 的选择过程压缩成一句话:从输出最短的序列开始装批,装不下时从输出最长的序列开始牺牲。这个策略在块充足时等价于“所有 running 都跑”,在块紧张时才显出偏向。它不会像 FCFS 那样让老请求稳定推进,也不会像优先级调度那样支持租户权重;它只有一个目标:用有限的块让尽可能多的年轻请求留在批次里。这个目标是否适合具体业务,需要结合尾延迟要求和失败容忍度来判断,不能只看吞吐数字。
process_outputs:乐观记账与提交
调度器不是只负责“发车”,它还负责“收账”。process_outputs() 在每个 step 的模型输出回来后执行,把采样结果写回 Sequence,更新长度、状态、停止原因和统计。它的核心是“先乐观推进、再按实际接受量提交”。
乐观推进
1seq.cache.advance_optimistically(scheduler_outputs.seqlens_q[i]);advance_optimistically() 把 previous_num_tokens 设为旧的 num_tokens,再把 num_tokens 加上本步的 seqlen_q。对 prefill,这一步加上的是本 chunk 的 prompt token 数;对 decode,加上的是 1 + 草稿数,因为调度器按最坏情况给所有草稿位都预留了 KV。为什么要先按最大值推进?因为下一步调度可能在采样结果回来之前就发生(ZOS 重叠),块数计算必须假设这些 token 都已经写入。等结果回来,再用 commit_processed_tokens(token_cnt) 把 num_tokens 改成 previous_num_tokens + 实际接受数,把未接受的草稿位退回。
写成公式:
L~=Lprev+q,L′=Lprev+a其中 L~ 是乐观值,L′ 是提交值,q 是本步 seqlen_q,a 是实际接受的 token 数(包括 golden token,不包括被拒绝的草稿)。对普通 decode,a=1;对投机 decode,a 可能大于 1,但不会超过 q。
跳过已失效的序列
循环里有两个提前 continue:
1if (seq.is_finished()) { ... continue; }2if (!scheduler_outputs.prompt_run && seq.schedule.status == SequenceStatus::WAITING) {3 // 在途被抢占,整步输出作废4 continue;5}第一个处理“请求在本步执行期间被 abort”的情况:不再记账,只释放 MTP slot;如果这是 prefill 步,还要把序列放回 running,让后续清理逻辑看到它。第二个处理 ZOS 的异步抢占:本步已经发射,但在结果回来之前,调度器在下一步把这个序列抢占了并放回 waiting;它的 reset_for_recompute() 已经把计数清零,这一步的输出必须整条丢弃,否则会把旧输出追加到重算后的序列上。这个分支是异步调度最容易出错的地方,源码用状态判断而不是额外的版本号来解决,前提是 _preempt() 一定把状态改成 WAITING。
prefill 完成与 chunked 续跑
1bool prefill_finish_or_is_decode = (seq.cache.num_tokens >= seq.input.tokens.get_len());对 prefill,如果乐观推进后的 num_tokens 已经达到输入长度,说明这条请求的 prefill 完成了;否则它还是一个 chunked 中间段,调度器会把它放回 waiting 队首,等下一轮继续:
1} else {2 TORCH_CHECK(seq_ptr->schedule.status == SequenceStatus::WAITING, ...);3 waiting.push_front(seq_ptr);4}这里的状态检查很关键:chunked 中间段的序列在调度时仍然是 WAITING,只有完成最后一段才会被改成 RUNNING。如果一个中间段回来时状态不是 WAITING,说明有人在途中改了状态(比如 abort 或抢占),继续把它塞回队列会破坏状态机。
接受草稿与停止判断
decode 完成后,调度器从 accepted_token_ids 的扁平数组里取出本序列接受的草稿 token:
1int num_draft_i = scheduler_outputs.seqlens_q[i] - 1;2for (int j = 0; j < num_draft_i; j++) {3 int token_id = accepted_token_ids_ptr[flat_offset + j];4 if (token_id != -1) accepted_token_ids_vec.push_back(token_id);5 else break;6}7seq.mtp.accepted_tokens = accepted_token_ids_vec;8token_ids = accepted_token_ids_vec;9seq.mtp.last_output_token = output_token_ids_data[i];10token_ids.push_back(seq.mtp.last_output_token);它只接受连续通过验证的前缀,遇到第一个 -1 就停。然后逐 token 追加,每追加一个就调用 _check_stop():
1void _check_stop(Sequence &seq) {2 int token_id = seq.get_last_token_id();3 if (!seq.generation.stopping.ignore_eos && token_id == eos_token_id) {4 seq.schedule.status = FINISHED_STOPPED;5 return;6 }7 if (seq.input.tokens.get_len() > max_model_len) {8 seq.schedule.status = FINISHED_LENGTH_CAPPED;9 return;10 }11 if (seq.get_output_len() == seq.generation.stopping.max_tokens) {12 seq.schedule.status = FINISHED_LENGTH_CAPPED;13 return;14 }15}三个条件分别对应 EOS、模型长度上限和用户 max_tokens。注意长度判断用的是 input.tokens.get_len(),也就是已提交 token 的总长度,不含尚未接受的草稿。这个顺序保证一条投机序列在追加多个 token 时,一旦命中 EOS 就立刻停止,不会把后面已经验证通过的 token 也写进去。
首 token 延迟与输出计数
process_outputs() 还顺手记录了两个指标:first_token_latencies 和 pushed_tokens_before_finish。前者在某条序列的输出长度恰好为 1 时记录“当前时间减去到达时间”,也就是 TTFT;后者记录每条序列在结束前追加了多少 token,用于统计输出长度。两个数组都是进程级静态缓冲,零拷贝借给 Python 侧,下一次调用会覆写;这和 SchedulerOutputs 的借用协议一样,要求调用方在下一次 process_outputs() 之前消费完。
process_outputs() 的结尾还有一个断言:num_output_tokens == scheduled_seqs.size()。它保证本步每个被调度的序列都恰好有一个输出 token 位置,不允许出现 seqlen_q = 0 的“空调度”。这个断言把调度器和采样器的契约固定下来:调度器说这一步跑 N 条,采样器就必须返回 N 个位置的结果。
DP 投票:多个引擎如何同步一步
这是 DeepInfer 调度器最有特色、也最容易被忽略的一部分。在 DP Attention + Wide-EP 的部署里,每个 DP rank 是一个完整引擎,各自有独立的 Scheduler、块池和请求队列;但它们共享专家并行组,前向时的 all-to-all 要求所有 rank 在同一步做同一类工作。如果 rank 0 做 prefill、rank 1 做 decode,两边的 token 数和通信形状对不上,集合通信会直接挂死。
引擎类型与步型
EngineType 有三种:
1enum class EngineType : int {2 NORMAL = 0, // process prompts & completions3 PROMPT = 1, // only process prompts4 COMPLETION = 2, // only process completions5};NORMAL 是常规引擎,既做 prefill 也做 decode;PROMPT 和 COMPLETION 用于 P/D 分离,前者只做 prefill,后者只做 decode。P/D 分离不在本篇展开,第十三篇的并行拓扑和后续的 P/D 篇章会讲;这里只需要知道 NORMAL 引擎的调度器必须自己解决“这一步全组做哪类工作”的问题。
两个尝试点
_schedule() 在 NORMAL 引擎下先看当前 decode_step 是否落在尝试点上:
1bool is_any_point = (decode_step != 0) &&2 (decode_step % prefill_any_attempt_interval == 0);3bool is_all_point = (decode_step % prefill_attempt_interval == 0);4if (!is_any_point && !is_all_point) {5 return _schedule_decode(acquire);6}decode_step 是一个 uint32_t 计数器。每次走 decode 分支就加一;每次走 prompt 分支就清零。两个间隔的默认值来自 deepinfer/environ.py:
1DEEPINFER_PREFILL_ATTEMPT_INTERVAL = EnvVar.int(default=70)2DEEPINFER_PREFILL_ANY_ATTEMPT_INTERVAL = EnvVar.int(default=100)它们不是时间间隔,而是 decode step 的计数间隔。默认配置下,每 70 个 decode step 有一次 ALL 尝试点,每 100 个 decode step 有一次 ANY 保底点。如果两个点重合,代码先判断 is_any_point,按 ANY 处理。
为什么要有两个点?如果只设 ALL 点,一个 rank 有 prefill 工作、其他 rank 没有,它要么永远等不到全员有活,要么单独做 prefill 导致步型不一致。ANY 点是对孤立 rank 的保底:只要有一个 rank 想 prefill,全组就一起做 prompt step,其他 rank 空跑。ALL 点则用于常规的批量 prefill:全员都有活时才做,避免一个 rank 干活、其余 rank 陪跑。
第一次投票:决定步型
1bool want = (get_gpu_cache_usage() <= GPU_CACHE_PREFILL_WATERMARK) &&2 _has_prefill_waiting();3bool do_prefill;4if (is_any_point) {5 do_prefill = (_dp_allreduce(want ? 0 : 1, c10d::ReduceOp::MIN) == 0);6} else {7 do_prefill = (_dp_allreduce(want ? 1 : 0, c10d::ReduceOp::MIN) == 1);8}want 是“本 rank 是否愿意做 prefill”:显存水位不超过 0.9,且 waiting 队首有一条可调度的请求。_has_prefill_waiting() 只检查队首,因为 schedulable 是单调的:队首不可调度时,后面的请求也一定不可调度(prefill 循环遇到队首不可调度就 break)。只查队首可以避免假阳性,减少无谓的组级投票和陪跑。
投票用 MIN 归约,两种点的语义刚好相反:
- ALL 点:每个 rank 送
want ? 1 : 0,取最小值。只有所有 rank 都送 1,结果才是 1。也就是说,全员都有活才做 prefill。 - ANY 点:每个 rank 送
want ? 0 : 1,取最小值。只要有一个 rank 送 0(想 prefill),结果就是 0。也就是说,任一 rank 有活就全组做 prefill。
写成公式:
ALL:rminwr=1,ANY:rmin(1−wr)=0其中 wr∈{0,1} 是 rank r 的 want。这个设计把“或”和“与”都映射到同一个 MIN 原语上,避免为两种语义写两套通信。
显存水位的计算是:
u=NtotalNtotal−Nfree+nplannedget_gpu_cache_usage() 默认 n_planned = 0,也就是只看当前已用块的比例。0.9 的阈值是硬编码的 GPU_CACHE_PREFILL_WATERMARK,不随配置变化。它的作用是防止在 KV 池快满时还大批接纳 prefill,把后续 decode 的块挤没;代价是水位超过 0.9 后 prefill 会被推迟,直到 decode 结束释放块。
第二次投票:确认真的有活
第一次投票只说明“有人想做”,不说明“有人真的排出了批次”。_schedule_prefill() 仍然可能空手而归:slot 满了、KV 块不够、chunked 检查不过。如果全组都空手,却硬做一次空 prompt step,decode_step 会被清零,下一步又落在 ALL 点,又投票、又空手,形成死循环。源码用第二次投票堵住这个循环:
1bool any_scheduled = (_dp_allreduce(outputs ? 0 : 1, c10d::ReduceOp::MIN) == 0);2if (!any_scheduled) {3 return _schedule_decode(acquire);4}5if (outputs) {6 return outputs;7}8return _make_outputs({}, true, 0, {}, {}, {}, 0);outputs 是本 rank 的 prefill 结果,nullptr 表示没排到。第二次投票用 MIN 判断“是否至少有一个 rank 排到了”:有任何一个 rank 送 0,any_scheduled 就为真。如果全组都没有,直接转 decode,避免空转。如果别的 rank 有、本 rank 没有,本 rank 返回一个空 prompt 结果 _make_outputs({}, true, 0, ...),跟着全组做一次空 prefill step。
这个空 step 不是浪费:它保持了步型一致,让集合通信的形状对齐;引擎侧会把它识别为 placeholder step,走一条专门的空执行路径。第二篇讲 ZOS 时提到的“空步也是步”,在这里得到了调度器层面的解释。
步型一致性为什么不会卡在 allreduce
decode_step 在每个 rank 上必须一致,否则投票的次数和语义会对不上,某个 rank 可能停在 allreduce 里等一个永远不会到来的投票。源码的保证来自两条规则:
- 每次
schedule()结束时,如果本步是 prompt,decode_step = 0;否则decode_step += 1。所有 rank 在同一步执行同一类工作,因此计数器同步变化。 - 空 prompt step 也算 prompt,会把
decode_step清零;空 decode step 算 decode,会加一。没有“某些 rank 不更新计数器”的分支。
第二条规则还有一个副作用:刚做完 prefill 的下一步,decode_step == 0,又落在 ALL 点上。如果想连续做 prefill,必须全员都有活;否则下一步会转 decode。这个行为写在源码注释里,是一个有意的节奏控制:prefill 之后先让 decode 推进一段,避免连续的大 prefill 把 token 间隔拉长。
投票的工程边界
这套投票机制有三个前提,缺一不可:
- 所有 rank 的
decode_step初值一致。构造函数把它初始化为 0,之后只通过上述规则更新。 - 所有 rank 的
prefill_attempt_interval和prefill_any_attempt_interval一致。它们来自环境变量,部署时必须统一;如果某个 rank 配了不同的值,投票点会错位。 - 所有 rank 的
want判断只依赖本地状态,不依赖上一轮投票结果。want只看显存水位和队首,不读全局变量,因此不会出现“我等你、你等我”的循环依赖。
只要有一条不满足,系统不会给出错误结果,而是直接挂死或产生形状不一致的通信。这类问题在单机测试里很难复现,必须用多 rank 的集成测试覆盖。
与 vLLM、SGLang、TensorRT-LLM 的对照
把 DeepInfer 的调度器放到四个框架的坐标系里,差异会更清楚。
| 维度 | DeepInfer | vLLM V1 | SGLang | TensorRT-LLM |
|---|---|---|---|---|
| 调度器位置 | C++ Scheduler,pybind 暴露 | Python Scheduler | Python Scheduler + PrefillAdder | C++ CapacityScheduler + MicroBatchScheduler |
| 队列策略 | waiting FIFO;decode 按输出长度 LCFS | FCFS 或显式 priority | 优先级 + 缓存感知排序 | 容量策略 + 微批选择 |
| 抢占语义 | 默认终止;可配置重算;结构化输出禁止重算 | 以重算为主 | retract 回 waiting,保留/重算取决于缓存 | paused_requests,语义是重算 |
| chunked prefill | 可选,CP 对齐、图像边界、checkpoint | 默认开启,统一 token budget | 长 prompt 切 chunk,独立 chunked_req | 支持,微批级切分 |
| DP 协同 | ALL/ANY 两轮投票,强制同步步型 | 各 rank 独立调度,靠引擎层协调 | DP Attention 由调度器/引擎协同 | 各 rank 独立 PyExecutor |
| P/D 分离 | EngineType::PROMPT/COMPLETION | 独立实例 + KV 传输 | 独立实例 + KV 传输 | 独立实例 + KV 传输 |
这张表只列结构性差异,不评价优劣。vLLM 的 FCFS 更适合保护老请求、稳定尾延迟;DeepInfer 的 LCFS 更适合突发流量下快速接纳新请求,但需要配合“默认终止”的抢占策略才能避免重算雪崩。SGLang 的优先级和缓存感知排序面向多租户与高前缀复用;TensorRT-LLM 的两级调度把容量和微批分开,适合静态形状和编译期优化。DeepInfer 的选择是由它的部署形态决定的:DP Attention 要求步型严格同步,V4 的状态槽要求块和状态联合准入,投机解码要求调度器预留草稿位。把这些约束拿掉,LCFS 和投票机制都未必是最优解。

(图源:Sarathi-Serve 论文 Figure 7。图中 A、B、C、D 是四条请求,d 是 decode iteration,p 是完整 prefill。把 prefill 直接插进 decode 序列会造成 generation stall,这正是 DeepInfer 用周期性 prefill 而不是每步 prefill 的原因之一。)

(图源:Sarathi-Serve 论文 Figure 9(a)。图中比较了 decode + 完整 prefill、decode + chunked prefill 等方案的开销。DeepInfer 没有采用混合批,而是把 prefill 和 decode 分成不同的 step,用投票控制节奏;这张图有助于理解“为什么不把两者硬塞进同一步”。)
schedule() 的后半段:从序列到元数据
_schedule() 返回的只是“选谁”和“各自多少 token”;真正交给 Worker 的是一份 SequenceMetadata 列表。转换发生在 Scheduler::schedule() 里,它包住 _schedule(),在拿到结果后做三件事:更新计数器、构造元数据、计算统计量。
计数器与 running 的交接
1schedule_counter += 1;2if (scheduler_outputs.prompt_run) {3 decode_step = 0;4} else {5 running = scheduler_outputs.scheduled_seqs;6 decode_step += 1;7 if (nextn > 0) {8 TORCH_CHECK(get_num_used_mtp_info_slots() == (int64_t)running.size(), ...);9 }10}prompt 步把 decode_step 清零,并且不更新 running;因为 prefill 选中的序列还在 _schedule_prefill() 里被改成 RUNNING 后留在 waiting 之外,由 process_outputs() 在 prefill 完成后放回 running。decode 步则把本步选中的序列直接写回 running,替换掉旧的列表;被抢占的序列已经不在 scheduled_seqs 里,因此自然从 running 消失。
MTP slot 的断言是投机解码与调度器的一个契约:每个进入 decode 的序列必须恰好持有一个 info slot,且 slot 数等于 running 数。如果某条序列在准入时拿了 slot,但没进入本步批次,或者进入批次却没拿 slot,这里会直接报错。这比让错误悄悄传播到采样器要好得多。
构造元数据:q/k 长度与位置上界
对每条选中的序列,调度器计算:
1int seqlen_q = scheduler_outputs.seqlens_q[i];2int seqlen_k = seq->cache.num_tokens + seq->schedule.total_inflight_tokens + seqlen_q;seqlen_k 是这条序列本步注意力要看到的 K 总长度,由三部分组成:已提交的 num_tokens、在途 token、本步 q。再加在途 token 是 ZOS 的要求,因为上一步的 KV 可能还没回写,但本步的注意力必须把它算进去。写进 SequenceMetadata 的还有 swa_valid_start、state_id、block_table、采样参数快照、MTP 的 accepted/draft token、结构化输出 matcher,等等。
位置上界检查是这里一个容易踩的坑:
1int position_end = scheduler_outputs.prompt_run ? seqlen_k2 : seqlen_k - seqlen_q + max_seqlen_q + nextn;3TORCH_CHECK(position_end <= runtime_max_model_len, ...);decode 步的 position_end 不是 seqlen_k,而是 seqlen_k - seqlen_q + max_seqlen_q + nextn。原因是 decode 的 seqlen_q 在 GPU 上会被补到 max_seqlen_q(nextn + 1),DFlash 草稿头还要再往后看 nextn 个位置;RoPE 缓存、位置编码等 buffer 只有 runtime_max_model_len 行,如果按 seqlen_k 检查,会漏掉补位和草稿头越界的可能性。这个检查把“调度时的 q 长度”和“GPU 上的实际 q 长度”区分开,是异步变长验证的又一个正确性边界。
统计量:给日志和 MFU 估计
1scheduler_outputs.sum_seqlen_qxk += (int64_t)seqlen_q * seqlen_k;2scheduler_outputs.max_seqlen_k = std::max(scheduler_outputs.max_seqlen_k, seqlen_k);3...4const auto [attn, total] = flops_estimator->calc_flops(5 seqlen_q, seqlen_k, is_prompt, full_len, is_prompt ? seq->cache.swa_valid_start : 0);6attn_flops += attn;7total_flops += total;sum_seqlen_qxk 是注意力计算量的近似度量:对全注意力,∑iqi⋅ki 与注意力 FLOPs 成正比。FlopsEstimator 进一步按模型结构、YoCo 截断、压缩 KV、MoE 中间宽度等参数换算出 FP8 等价的 attention FLOPs 和总 FLOPs。这些数字不参与调度决策,但会出现在统计日志里,用来计算 MFU 和判断一步的“有效算力”。调度器在这里顺手把 FlopsEstimator 挂在 SequenceMetadata 构造链路上,避免了在 Python 侧再遍历一遍批次。
log_stats 关闭时,这段计算会被跳过;这也是为什么生产环境通常关掉详细统计,只在需要性能归因时打开。
端到端走一遍:一条长 prompt、一段 decode、一次抢占
把上面的机制串起来,用一个小例子走一遍。假设单机单引擎,max_num_seqs = 4,max_num_batched_tokens = 2048,块大小 16,KV 池 256 块,chunked_prefill = true,stop_preempted_request = true。
第一步:请求 A 到达,prompt 长度 3000。 add_sequence() 把 A 推进 waiting。_schedule() 是 NORMAL 引擎,decode_step = 0,落在 ALL 点;want 为真,因为显存水位为 0 且队首可调度。第一次投票后 do_prefill = true,第二次投票发现至少本 rank 排到请求,继续。_schedule_prefill() 先检查 max_num_prompts:0 + 1 <= 4,通过;sum-K 上限不限制;token 预算 2048 小于 3000,A 被切成 2048 个 token 的 chunk。切分后 is_chunked = true,调度器分配 ⌈(3000+7)/16⌉=188 块(按 get_num_blocks(),含 7 个 lookahead/余量),为 A 分配状态槽,把 A 放进 scheduled,seqlens_q = [2048],A 仍在 WAITING,chunked_rest_len = 952。然后 break,本步只做这一段。
第二步:B、C 先后到达,prompt 各 500。 下一步 decode_step 被清零,仍落在 ALL 点;want 为真,投票做 prefill。_schedule_prefill() 从队首 A 开始,A 是 chunked 续跑,剩余 952 个 token,预算 2048 够,A 再切 952 个 token 完成整条 prompt。A 完成后 is_chunked = false,状态改 RUNNING,分配 MTP slot,进入 scheduled;然后循环继续看 B,剩余预算 2048 - 952 = 1096,B 的 500 个 token 可以完整跑,C 也可以完整跑。num_curr_seqs 从 0 变成 3。本步 seqlens_q = [952, 500, 500]。这展示了 chunked prefill 的续跑和同批多请求的组装:一条长 prompt 的剩余部分和新请求可以在同一个 prompt step 里一起跑。
第三步:A、B、C 进入 decode。 process_outputs() 在 prefill 完成后把三条都放回 running。下一步 decode_step 从 0 开始,不落在尝试点,直接 _schedule_decode()。排序后三条输出长度都是 0,顺序不变;每个序列 _append_slot() 按 get_num_blocks() 检查块表,块表已经有足够块,不新增。seqlens_q = [1,1,1]。
第四步:显存逼近阈值。 若干步后,块池降到 20 块,get_gpu_cache_usage() = 1 - 20/256 \approx 0.92,超过 0.9 的水位。下一次 ALL 点,want 为假(水位超阈值),第一次投票后 do_prefill = false,转 decode。这条规则防止在 decode 仍需要新块时接纳大批 prefill,把有限的块挤没。
第五步:decode 中块耗尽,触发抢占。 假设 A 的输出最长,B 次之,C 最短。running 按输出长度降序排列为 [A, B, C];循环从尾部 C 开始取。如果 can_allocate_single_nolock() 为假,内层循环从头部取 A 作为 victim,调用 _preempt()。stop_preempted_request = true,A 被终止:状态改 FINISHED_PREEMPTED,释放 A 的 188 块和状态槽,放进 ignored_seqs。释放后池子恢复,C 拿到一个块,进入批次;然后取 B,块够,进入;最后取 A,但 A 已经不在 [l, r) 区间里,不会重复处理。本步 scheduled = [C, B],返回的 ignored_seqs 里含 A,引擎会把 A 的结束原因回传给上层。
这个例子里,A 的 3000 token 输出只完成了一部分就被终止。用户看到的是 insufficient_system_resource,而不是一条慢慢跑完的长请求。如果业务接受不了,可以关闭 stop_preempted_request,让 A 回到 waiting 队首重算;代价是 A 要重新 prefill 3000 token,而且很可能再次把池子填满,形成抢占-重算的循环。LCFS 加终止的组合,本质上是把“长请求”当成容量管理的第一优先级牺牲品。
第六步:prefill 请求在 decode 期间等待。 假设下一步有新请求 D 到达。decode_step 不在尝试点,D 只能留在 waiting。等下一次 ALL 点,如果 A、B、C 都还有 decode 工作,全员 want 可能为真,D 进入 prefill;如果某个 rank 没有 prefill 工作,ALL 投票会失败,D 继续等,直到 ANY 保底点。在单引擎部署里,ALL 投票退化成“本 rank 想不想”,want 为真就做;所以 D 的等待时间主要取决于两次尝试点之间的距离,默认最多 70 个 decode step。这个等待是 DeepInfer 用“稳定 token 间隔”换“首 token 延迟”的直接体现。
参数调优:调度器的旋钮与它们的耦合
调度器的行为由一组配置和环境变量控制。理解它们的耦合关系,比记住默认值更重要。
| 参数 | 作用 | 调大的收益 | 调大的风险 |
|---|---|---|---|
max_num_seqs | 同时运行的序列上限 | 提高并发和吞吐 | 每条序列都要状态槽和块,抢占概率上升 |
max_num_batched_tokens | 单步 token 预算 | prefill 吞吐更高、长 prompt 少切几段 | decode 批次可能被推迟,token 间隔变长 |
chunked_prefill | 是否允许长 prompt 跨步 | 长 prompt 不阻塞 decode | 增加调度与 launch 开销,chunk 边界可能打断图像段 |
prefill_attempt_interval | ALL 投票间隔 | 更频繁地尝试批量 prefill | 更多空投票和陪跑 |
prefill_any_attempt_interval | ANY 保底间隔 | 孤立 rank 的 prefill 等得更久 | 更频繁的“一 rank 干活、全员陪跑” |
stop_preempted_request | 抢占时终止还是重算 | 终止避免重算雪崩 | 用户可见失败率上升 |
lookahead_tokens | 最小 chunk 与前瞻 token | 给投机解码留出验证空间 | 小请求被推迟 |
gpu_memory_utilization | KV 池占显存比例 | 更多块,降低抢占 | 留给激活和 workspace 的显存变少 |
DEEPINFER_PREFILL_MAX_SUM_SEQLEN_K | prefill 步的 K 总量上限 | 允许更多长 prompt 同批 | attention 中间缓冲和 gather 开销上升 |
几个典型的耦合关系:
max_num_seqs与 KV 池。调大序列数而不增加 KV 池,只会让更多序列同时 running,块更快耗尽,抢占更频繁。如果抢占是终止语义,失败率会直接上升。max_num_batched_tokens与 decode 间隔。调大预算让长 prompt 一次跑完,但一个大的 prompt step 会把后面的 decode step 推迟,token 间隔变长。Sarathi-Serve 的图 7 说明了这个现象;DeepInfer 用周期性 prefill 来缓解,但周期本身也是参数。- ALL 与 ANY 间隔。ALL 点太密,空投票多;ANY 点太密,陪跑多。两者的比例应该根据各 rank 的 prefill 到达分布来定,而不是简单相等。
- chunked prefill 与图像边界。开启 chunked 后,图像请求的 chunk 可能被切在图像段之外,
_adjust_chunk_size()会缩小或扩大 chunk 来对齐;如果预算长期小于一张图像的 patch 数,这条请求可能永远排不上,直到预算被调大或队列前面的请求被消费掉。
调优的顺序建议是:先定 gpu_memory_utilization 和 max_num_seqs,让正常运行时不频繁抢占;再定 max_num_batched_tokens,平衡 TTFT 和 token 间隔;最后调两个投票间隔,观察等待队列长度和空 step 比例。不要一上来就改 prefill_attempt_interval,因为它对单引擎和多引擎部署的影响完全不同。
工程边界与已知取舍
调度器的代码里有不少显式断言和注释,它们记录了设计者已经踩过的坑。挑几个值得注意的:
第一,借用缓冲的生命周期。 SchedulerOutputs 里的 clone_blocks、clone_states、zero_states 是借用调度器内部 vector 的零拷贝视图,pybind 注释明确要求“下一次 schedule() 之前消费完”。process_outputs() 返回的两个数组也是进程级静态缓冲。这些接口为了省拷贝,把生命周期管理交给了调用方;如果 Python 侧把结果存起来跨步使用,会在下一次调度时被静默覆写。这类问题不会报错,只会产生难以复现的错乱结果。
第二,结构化输出的抢占。 源码对 so_blocks_recompute 的处理是“终止而不是重算”,并且只打警告。这意味着开启结构化输出的请求在显存紧张时更容易失败。调度器没有尝试保存 matcher 状态再恢复,因为自动机状态和已提交 token 的对应关系很难在不重算的情况下保持一致。
第三,YoCo 截断与抢占。 第三篇提到过:开启截断后,抢占导致的重算可能与不抢占时结果不同,源码注释把这个行为标为“可接受的既定行为”。对调度器来说,这意味着 reset_for_recompute() 之后的序列不保证与原始序列逐 token 等价;如果业务要求确定性输出,就必须避开截断模式或避免抢占。
第四,DP 投票的同步假设。 投票依赖所有 rank 的 decode_step 和两个间隔一致。源码没有在运行时校验这两点,只在构造时读取环境变量。部署脚本如果把间隔写在不同 rank 的配置里,问题会在第一次投票点错位时出现,表现为 allreduce 挂起或 token 数不一致。排查这类问题要先对比各 rank 的环境变量,而不是先怀疑调度逻辑。
第五,COMPLETION 引擎的配置约束。 构造时的 TORCH_CHECK 把“COMPLETION + 允许重算”挡在启动阶段。这个约束是对的,但它也意味着 P/D 分离的 decode 侧必须接受“显存不足就终止”的语义;如果 decode 侧希望优雅降级,需要在更上层做请求迁移,而不是指望调度器重算。
第六,chunked prefill 的 break。 一个 step 只推进一条 chunked 请求的一个 chunk,这让长 prompt 的完成时间被拉长到多个 step。如果同时有很多长 prompt,它们会在 waiting 队首轮流推进,每条的 TTFT 都会变长。这是分块调度的固有代价,不是 bug;要缩短长 prompt 的完成时间,只能增大预算或减少同批的长 prompt 数量。
小结
DeepInfer 的 C++ 调度器可以用六个动作概括:
- 队列:
waiting按到达顺序 FIFO,running按输出长度 LCFS,prompted给 fork 序列留一个窄通道。 - 准入:块和状态槽联合检查,
OK/LATER/NEVER三种结果对应分配、等待和拒绝;prompt 长度上限随 chunked 开关变化。 - prefill 组装:序列数、sum-K、token 预算三道门,CP 对齐、chunk checkpoint、图像边界、最小 chunk 四类切分约束。
- decode 与抢占:从年轻序列开始装批,从老序列开始牺牲;默认终止,可配置重算,结构化输出禁止重算。
- 收账:乐观推进
num_tokens,按实际接受量提交;跳过已失效序列,处理 chunked 续跑和停止判断。 - DP 投票:ALL/ANY 两轮
MIN归约,强制全组步型一致;第二次投票防止空转死循环。
这些机制的共同主题是“在一个异步、分页、多 rank 的引擎里,把不确定的执行结果包装成确定的调度账本”。C++ 的实现让调度可以在持有锁的情况下快速完成,pybind 的零拷贝视图让元数据不用复制,但代价是生命周期约束和跨 rank 同步假设。理解了这些,再回头看第二篇的 ZOS、第三篇的两本块账、第五篇的投机调度,它们就不再是孤立的技巧,而是同一套账本在不同层面的展开。
下一篇将回到引擎侧,拆 LLMEngine 的 step() 与 Worker 的握手:调度结果如何变成 SequenceMetadata、execute_model_step 如何在驱动侧和 Worker 侧并行推进、post_process 里的采样与输出回传如何闭环,以及 P/D 分离时 PROMPT 与 COMPLETION 两个引擎怎样通过 pe_prepare_save_kvcache 交接。调度器只是发车,下一站是列车怎么跑。
参考资料
- DeepInfer 官方仓库
- DeepInfer C++ 调度器源码 csrc/engine/scheduler.hpp
- DeepInfer 块管理源码 csrc/engine/block_manager.hpp
- DeepInfer Sequence 与状态账本 csrc/engine/sequence.hpp
- DeepInfer MTPVarlen 与运行时估计器 csrc/engine/mtp_varlen.hpp
- DeepInfer FlopsEstimator csrc/engine/flops_estimator.hpp
- DeepInfer 调度相关环境变量 deepinfer/environ.py
- Efficient Memory Management for Large Language Model Serving with PagedAttention(vLLM)
- Orca: A Distributed Serving System for Transformer-Based Generative Models
- Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



