正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(十七):LLMEngine 的 step 与 Worker 握手——调度结果如何变成一次 GPU 前向
从第十六篇接上
第十七篇的前面几篇已经把 DeepInfer 的「账」和「车」分别讲完了:第三篇讲 KV 池与状态槽两本块账,第五篇讲投机解码的草稿与验证,第二篇讲零开销调度 ZOS 如何把 CPU 后处理挪出关键路径,第十六篇把 C++ 调度器从队列、准入、批组装到 process_outputs 完整走了一遍。
第十六篇结尾留了一句话:「调度器只是发车,下一站是列车怎么跑。」这一篇就讲「列车怎么跑」:Python 侧的 LLMEngine.step() 如何把调度结果交给 Worker,LLMEngine 与 GPU Worker 之间到底传了什么、在什么时刻传、哪些数据结构为了省拷贝被做成了「零拷贝视图」,以及为什么引擎和 Worker 必须在同一个时刻做同一件事。本篇不重讲调度器的算术,也不重讲 ZOS 的时序修正,只关注边界:Python 引擎和 GPU Worker 之间的那一层握手。
DeepInfer 的源码与部署接口迭代很快,本篇以 2026 年 10 月仓库 main 分支为基线,引用位置以文件与函数名为主;同一机制在新版本里的命名可能变化,阅读时以代码为准。
先给出整篇要回答的五个问题:
- 一个
LLMEngine对象和它手下的 GPU Worker 进程是怎样的拓扑?Driver Worker 和从属 Worker 的职责边界在哪里? - 一次
step()从 Python 调到 GPU,中间经过哪些环节?哪一段是 CPU 关键路径,哪一段可以隐藏? - 调度器给出的
SchedulerOutputs用什么形式穿过_run_workers这层 RPC?为什么很多字段是numpy数组和uint64指针? - 为什么引擎要有一个
DeferredRelease?不延后释放会出现什么问题? - 当调度器给出空批时,引擎为什么还要让 Worker 跑一次「占位前向」?这个空转步在 P/D 分离和 DP 投票场景下承担了什么职责?
拓扑:一个引擎进程里有几个 Worker

上图是官方仓库给出的整体架构,它把引擎、调度、Worker 与底层 kernel 库放在同一张图里。需要注意的是,这张图是静态的模块关系,不是一次 step() 的时序;下文要补的正是图与图之间那条会动的线:LLMEngine.step() 如何穿过 Worker 边界,以及边界两侧各自在等什么。
进程结构
DeepInfer 的部署形态是「每个 rank 一个进程」。LLMEngine 构造时读取 CUDA_VISIBLE_DEVICES,把第一张卡当作 Driver GPU,在上面同进程构造一个 Worker;其余用来做上下文并行(CP)的卡,各自起一个子进程,子进程里只构造 Worker 本身,不构造 LLMEngine。
1# deepinfer/engine/llm_engine.py, _init_workers2for cp_rank in range(1, cp_size):3 worker = SpawnProcessWithCrashHeader(4 target=_spawn_worker,5 name=f"worker-cp{cp_rank}",6 args=(engine_type, model_config, parallel_config, scheduler_config,7 cache_config, cp_rank % ngpus, cp_rank, distributed_init_method, False),8 daemon=True,9 )10 worker.start()11
12driver_kwargs = {13 "engine_callbacks": EngineCallbacks(14 post_process_fn=self.post_process,15 pre_sampler_sync_fn=self.pre_sampler_sync,16 ),17}18self.driver_worker = Worker(..., local_rank=0, cp_rank=0, ..., is_driver_worker=True, **driver_kwargs)两个细节值得注意:
- 只读的从属 Worker。子进程的
Worker(*args)参数里没有engine_callbacks,并且is_driver_worker=False。在WorkerBase.__init__末尾,非 driver 的 Worker 会直接进入start_slave_worker_loop():一个while True循环,持续从共享内存里接收(method, kwargs)并反射调用同名方法,永远不返回。 - 父进程死亡的兜底。从属进程在进入循环前调用
set_pdeathsig(SIGKILL),通过prctl(PR_SET_PDEATHSIG)请求内核在父进程死亡时杀掉自己。否则当一个引擎进程崩溃,这些 daemon 子进程会残留并占住 GPU 显存。
也就是说,在这个进程模型里,只有 Driver 拥有 LLMEngine 的全部状态;从属 Worker 只持有模型权重、KV 池和 CUDA Graph,不持有调度器,也不持有请求表。它们的唯一入口是反射调用 Driver 广播过来的方法。
共享内存广播
从属 Worker 接收任务的方式是 cp_broadcast_object(None),它读取的是一个由 torch.distributed 进程组承载的共享内存对象,而不是 pickle 序列化的 RPC。Driver 侧在 run_workers 中把 (method, kwargs) 通过同一个对象写出去:
1# deepinfer/worker/base.py, WorkerBase.run_workers2def run_workers(self, method, driver_kwargs=None, **kwargs):3 with cpu_timer.record("broadcast_method", enable=False):4 assert self.is_driver_worker5 cp_group = PG.cpu_cp_group6 if cp_group and cp_group.size() > 1:7 cp_broadcast_object((method, kwargs))8 return getattr(self, method)(**(driver_kwargs or {}), **kwargs)driver_kwargs 和 kwargs 是有区别的:driver_kwargs 只在 Driver 上生效,不会广播。step() 里 execute_model_step 调用中,seq_metadata_list 与 scheduler_outputs 是 Python 对象(C++ 扩展构造的 _C.engine 类型),里面带着 shared_ptr 和裸指针,根本不可能跨进程 pickle;所以在 CP 场景下,Driver 只广播 is_prompt / is_placeholder 这两个布尔量,从属 Worker 拿到的 seq_metadata_list 与 scheduler_outputs 都是 None。真正跨卡的输入数据走的是 broadcast_tensor_dict:_prepare_input_driver 在广播前检查 CP 组大小,把 InputMetadata 拍平成一个 dict 再发出去。这条路径在第十二篇(CUDA Graph 输入)与第十三篇(CP 拓扑)里分别讲过,这一篇只强调一个结论:
引擎到 Worker 的「控制面」广播是轻量且 CP 感知的,而「数据面」广播由 ModelRunner 在
prepare_input中完成。两者不能混在一起。
_run_workers 的真实语义
LLMEngine 里所有”调用 Worker”的地方都写成 self._run_workers("方法名", ...),而它的实现只是一行:
1def _run_workers(self, method, driver_kwargs=None, **kwargs):2 if driver_kwargs is None:3 driver_kwargs = {}4 return self.driver_worker.run_workers(method, driver_kwargs, **kwargs)也就是说,Python 引擎总是先在本地 Driver 上同步执行该方法,run_workers 里那条 cp_broadcast_object 只是顺手把方法名和跨卡参数广播出去,然后返回本地调用的结果。从属 Worker 在自己的循环里执行同一个方法,两边通过 torch.distributed 的集合通信(如 broadcast_tensor_dict)在方法体内部对齐。这解释了一件看起来很怪的事:load_model、init_cache_engine、execute_model_step 这些在 Driver 上”同步”调用的方法,返回值都来自 Driver 本地那次调用。至于各 rank 之间如何对齐,由方法自身决定:execute_model_step 在 prepare_input 里用 broadcast_tensor_dict 对齐输入与采样元数据;load_model 则让每个 rank 各自装载自己那份分片。从 Engine 的角度看不到 RPC 返回值的拼接,需要聚合的结果只会是 torch.distributed 的归约值。
Driver 与从属 Worker 的职责差异
| 环节 | Driver Worker | 从属 Worker(CP) |
|---|---|---|
LLMEngine 实例 | 有 | 无 |
| 调度器 / 请求表 | 有 | 无 |
C++ SchedulerOutputs | 由 Driver 的 C++ 对象构造 | 无,None |
| InputMetadata 准备 | 本机构造后广播 | 从广播的扁平 dict 还原 |
| CUDA Graph 捕获 | capture_max_graph_only 预热 + 全量捕获 | 同左,但只在捕获阶段参与 |
| Sampler | 生成有效采样结果并写回 | 调用同一入口,但只参与 embedding gather 的集合通信 |
post_process_fn | 引擎的 post_process | 不调用 |
pre_sampler_sync_fn | 引擎的 pre_sampler_sync | 只有 maybe_gc_collect |
这张表解释了为什么 Worker.execute_model_step 里几乎每个与「输出」相关的分支前都写着 self.is_driver_worker。从属 Worker 并非完全跳过 sampler:它会走同一个 self.model.sample(...) 入口,但 prepare_sampler_inputs 在非 Driver 分支上只从广播里拿 selected_token_indices / mtp_first_token_positions,Sampler 内部的 driver_inputs 为 None,因此只参与 embedding gather 这类集合通信,产生的采样结果也只在 Driver 上有效。CP 切的是序列维,每张卡分到同一批请求的不同 token 片段,真正的采样输入只在 Driver 上完整。
DP 维度:每个 rank 一个完整引擎
EP / 大 DP 部署时,每个 rank 上都有一个完整的 LLMEngine,彼此通过 PG.data_parallel_group() 通信;DP Attention 只做注意力部分的 All-Gather / Reduce-Scatter,不交换请求表。这带来的直接后果是:
- 调度器在每个 rank 上是独立的;
- 但一次 step 的「步型」(prefill 还是 decode)必须一致,否则 All-Gather 的
seqlen_q对不上; - 第十六篇里的 DP 投票就是解决这个问题的:第二轮
MIN归约把全组的步型强行拉成一致。
引擎与 Worker 的握手对 DP 层面是透明的,但握手时机的选择会直接影响 DP 投票的效果:如果引擎在调度前已经发现本 rank 空转,它会用占位步参与投票,而不是干等。
请求怎么进入这条流水:add_request 的边界职责
step() 只处理”已经在调度器里”的序列;序列是通过 add_request 放进去的。这个方法看起来只是构造对象、塞进队列,实际上它是引擎与外部世界的第一道边界,承担了五类校验与预处理。
输入是 token,不是文本
签名里的 prompt_token_ids: np.ndarray[np.int32] 是硬约束。引擎内部虽然持有一个 HF tokenizer(用于 LLMInference 的便捷入口),但 add_request 不接受字符串提示。entrypoints/llm._add_request 在需要时才调用 self.llm_engine.tokenizer.encode(prompt)。这条边界把分词从引擎关键路径上移走:线上服务里分词发生在 API 网关,引擎只对 token 做调度与计算。
五道预处理
eos_id注入:sampling_params.eos_id = self.eos_id。采样器需要它来判断停止,而SamplingParams本身不携带词表信息。- YoCo 截断下的
prompt_logprobs强制关闭:源码在yoco_prefill_truncation_mode != "off"且请求要求prompt_logprobs时打印 warning 并置False。理由是 compact 模式无法计算被裁掉行的 logprob,mask 模式对矩形之前的 token 返回退化值。与其返回错误数据,不如显式关闭。 - 多模态提示重写:
multi_modal_processor.overwrite_prompt(...)把图像占位区间替换成模型约定的 image token 序列;如果image_bytes为None,直接把序列标记为FINISHED_VE_FAILED,让它在第一次调度时就被回收。 - token 取值范围校验:
min_token_id < 0或max_token_id >= vocab_size + NUM_SPECIAL(多模态时放宽到特殊 token 偏移)会直接丢弃请求并返回None,日志写明原因。这一步防止越界 token 进入 embedding 查表。 - 序列号与压缩提示:
seq_counter单调分配seq_id;启用 Engram 的模型还会用CompressedTokenizer.encode生成compressed_prompt_token_ids,两者形状必须一致。
结构化输出的异步编译
so_args 非空时,结构化输出的语法编译不该阻塞 add_request 的调用方。源码的处理是:
1if so_pending:2 self.so_matcher_builder.submit(seq, sampling_params.so_args, _enqueue)3else:4 _enqueue(seq)真正入队发生在编译完成的回调里。回调还检查 matcher.is_aborted():如果编译阶段就被判定不可满足(例如语法与约束冲突),序列直接标记 FINISHED_SO_FAILED,不进入调度队列。enqueue=False 的调用方(自己管理调度)则同步 .result() 等待编译完成,因为它没有人替它驱动 pump()。
pre_sampler_sync 里的 self._so_matcher_builder.pump() 就是这条异步链的推进器:每拍检查一次已完成的编译任务,把对应序列放进 wait 队列。
abort_request 的边界
abort_request 先调 scheduler.abort_sequence(request_id) 拿回真正被取消的序列,然后:
- 通知
multi_modal_processor.abort; - 对每条序列调用
free_multi_modal_pool_handles(seq.multi_modal_data),归还未消费完的共享池块。
返回值是 (被取消的序列数, 被取消的 token 总数),供上层统计。这里的关键是”真正被取消”:调度器可能已经把该序列标记为完成,abort_sequence 返回空列表时,引擎不会误释放别的序列的资源。
一次 step 的四个阶段
LLMEngine.step() 是 Python 侧唯一对外暴露的推进接口,签名为:
1def step(self) -> np.ndarray[np.uint64]:返回值是一组 uint64 指针,指向 RequestOutput 对象池中的条目。下面沿着 step() 源码逐段走一遍。
阶段一:调度与状态克隆
进入 step() 后先更新时间戳与运行时估计器,然后进入调度:
1with cpu_timer.record("schedule"):2 seq_metadata_list, scheduler_outputs = self.scheduler.schedule(self.sequence_metadata_pool)3 # NOTE 开启 yoco 截断时,允许抢占导致的结果变化4 with cpu_timer.record("schedule:clone"):5 clone_states, clone_blocks, zero_states = (6 scheduler_outputs.clone_states,7 scheduler_outputs.clone_blocks,8 scheduler_outputs.zero_states,9 )10 if clone_states.size > 0 or clone_blocks.size > 0 or zero_states.size > 0:11 to_release, to_release2 = self._run_workers(12 "clone_and_zero_states_and_blocks_on_default_stream",13 clone_states=clone_states,14 clone_blocks=clone_blocks,15 zero_states=zero_states,16 )17 self.deferred_release.collect_clone_unpins(to_release, to_release2)18 with cpu_timer.record("schedule:post"):19 self.post_schedule(scheduler_outputs)这里有几个互相咬合的设计:
scheduler.schedule()是 C++ 实现,它在同一个循环里按下标构造seq_metadata_list与scheduled_seqs。源码注释明确说明两者「严格同序」,后面所有zip(..., strict=True)都依赖这一点。clone_states/clone_blocks/zero_states是序列派生(fork)所需的状态克隆。C++ 侧的clone_states是一个vector<int>的(src, dst)对,clone_blocks同样,dst=0表示「只归还 src、不拷贝」,zero_states则是需要清零的状态槽编号。- 三个数组都是零拷贝 numpy 视图:pybind 用
py::array_t<int>(..., data(), py::capsule([](){ }))直接指向调度器内部vector,属性文档写明「下一次schedule()会清空复用:须在此之前消费完,禁止跨 step 持有」。 - 克隆被放到 GPU 的默认流上执行,返回的
states_to_unpin/blocks_to_unpin由 Worker 给出,但并不是立刻归还的,而是交给DeferredRelease.collect_clone_unpins()。原因是 clone kernel 是异步的,GPU 还在读源页,CPU 侧归还池编号会让调度器在本步内把同一页再分出去,产生数据竞争。 - 克隆完成后才进入
post_schedule()。post_schedule是虚函数,基类是空实现,P/D 分离的引擎在子类里会利用这个钩子做预取或 KV 保存的准备工作。
阶段二:前置副作用
调度之后、模型执行之前,step() 还处理了几类与「GPU 前向无关但与请求生命周期有关」的事情:
1if not scheduler_outputs.prompt_run and self.copy_state is not None: # 处理 kvcache 落盘相关2 to_copy_state_seqs = [seq for seq in3 scheduler_outputs.scheduled_seqs + scheduler_outputs.finished_seqs4 if self.should_copy_state(seq)]5 if to_copy_state_seqs:6 self.do_copy_state(to_copy_state_seqs)7 self.copy_state_event.record()8
9with cpu_timer.record("multi_modal_fetch"):10 if self.multi_modal_processor is not None:11 self.multi_modal_processor.fetch()12 abort_requests = self.multi_modal_processor.fetch_to_abort_requests()13 if abort_requests:14 logger.error(...)15 self.abort_request(abort_requests)do_copy_state把某个序列当前的状态槽拷进双缓冲copy_state,只下发任务、不等待,消费方自行同步。源码的实现直接遍历to_copy_state_seqs,对每个state_id != -1的序列做copy_state[state_id].copy_(gpu_states[state_id]),并记录last_copy_state_token_idx = seq.num_cached_tokens。这个机制服务于 KV 落盘与跨机迁移:源状态槽即将被改写,而落盘线程还在读它,所以必须先拷一份。copy_state_event会在下一次pre_sampler_sync里被同步,同步之后才调用do_save_kvcache。- 多模态的
fetch()是预取。图片预处理放在 CPU 上会很慢(解码 JPEG、缩放),提前一步 fetch 可以让预处理和 GPU 前向重叠。如果某张图损坏,fetch_to_abort_requests()会返回要中止的请求列表,引擎当场 abort 这些请求,而不是等到 embedding 阶段再暴露异常——这保证了后续_process_model_outputs不会见到一个缺少 embedding 的序列。 - 源码里还保留了引擎自己的 tokenizer,但
add_request要求调用方传入prompt_token_ids(np.ndarray[np.int32]),entrypoints/llm._run_engine只在缺失时才用引擎自带 tokenizer 编码。这说明分词位于引擎之外的设计意图:在线上服务里,分词由 API 网关层的分词器集群完成,引擎只接收 token。
阶段三:Worker 前向 + 采样
模型执行被封装成一行调用:
1request_outputs = self._execute_model_async_step(seq_metadata_list, scheduler_outputs)它本身只是 _run_workers("execute_model_step", ...) 的外壳。真正的四段流水在 Worker.execute_model_step 里。这个方法的名字里带 async,但它是同步返回的——「异步」指的是它内部的 CPU 与 GPU 重叠,而不是调用方式。
阶段四:延后收账
step() 返回前,把本步的 request_outputs 和 seq_metadata_list 交给 DeferredRelease:
1self.deferred_release.collect_request_outputs(request_outputs)2return request_outputsseq_metadata_list 在 Worker 侧的 _post_process_step 之后、经 self.engine_callbacks.post_process_fn(step_state) 回到引擎的 post_process() 时被收集(下文详述)。
Driver 侧的四段流水:Forward-Post_process-Sample

上图来自官方部署问答中明确列为参考实现的 NanoFlow:它把每一步拆成 GPU 上的前向与 CPU 上的后处理、调度两段流水,GPU 计算与 CPU 逻辑在时间上叠在一起。DeepInfer 的 ZOS 与它同构,差别在于 DeepInfer 的”重叠单位”是一次完整的 execute_model_step,而流水里的三段(前向、上拍收账、本拍采样)在同一个函数体内交错。下图用同一个视角展示 NanoFlow 如何让多个批次的 CPU 工作被”拆分到极细”从而填满 GPU 的空隙,这与 pre_sampler_sync 把释放、落盘、matcher pump 塞进采样 kernel 阴影里的做法是一回事。

Worker.execute_model_step 的 docstring 把四段流水写得非常清楚:
11. Forward current step (main model)22. Post-process previous step33. Sample current step44. Post-process current step: in place for prompt / non-ZOS steps;5 ZOS decode steps defer it to the next step to overlap with forward第二篇已经详细讲过 ZOS 的时序,这一篇关注为什么这四段要以这个顺序排。
第 1 段:主模型前向
1if not is_placeholder:2 main_model_output = self.model_runner.execute_main_model(3 seq_metadata_list, self.gpu_cache, metadata_zos=metadata_zos, total_q=total_q4 )5else:6 seq_metadata_list = [self.model_runner._placeholder_seq_metadata(is_prompt)]7 main_model_output = self.model_runner.execute_main_model(seq_metadata_list, self.gpu_cache, metadata_zos=metadata_zos)execute_main_model 内部做两件事:_prepare_input 构造 InputMetadata,然后要么 cuda_graph_runner.replay() 要么直接调用 self.model(...)。源码注释用全大写的语气写着:
IMPORTANT: This function must NOT contain any cuda synchronize.
这不是风格要求,而是正确性要求:ZOS 之所以能把 CPU 调度挪到 GPU 前向旁边,依赖的就是前向调用本身是纯异步 launch。如果 execute_main_model 里出现一次隐式同步(比如某个 Tensor 没有 non_blocking=True 的 H2D 拷贝,或某个 .item()),ZOS 的收益会被一次同步抹平。源码里为此专门提供了 forbid_cuda_sync() 上下文管理器,包住 launch_forward 与 launch_sample 两段。
第 2 段:上一拍的 post-process
1request_outputs = self._post_process_step(self.prev_step_state)_post_process_step 会先 sample_end_evt.synchronize(),再调用 model_runner.post_process(...) 和引擎回调 post_process_fn。这个同步点是整个 step 里唯一一次 CPU 等待 GPU 的地方。它等待的是上一拍的采样结束事件,而此刻 GPU 正在执行本拍的前向,所以这次等待通常已经完成,真实的停顿接近零。
1with cpu_timer.record("worker.post_process.sync"):2 step_state.sample_end_evt.synchronize()prompt 步或关闭 ZOS 时,prev_step_state 为 None,_post_process_step(None) 直接返回空数组;本拍的 post-process 会在第 4 段立即执行,此时 CPU 必须等待本拍采样结束——这就是非 ZOS 路径的同步开销。
第 3 段:采样本拍并回调引擎
1if correction is not None:2 if correction.d2h_evt is not None:3 correction.d2h_evt.synchronize()4 scheduler_outputs.update_metadata_for_sampling_zos(5 seq_metadata_list, correction.seqlens_q_cpu, correction.metadata_zos.remap_cpu, self.nextn6 )7
8sampler_output = self.model_runner.execute_sample(9 seq_metadata_list, input_metadata, model_output, self.gpu_cache,10)11
12sample_events = cuda_timer.latest_event_pair("device_sample") if record_runtime else None13with cpu_timer.record("pre_sampler_sync"):14 if self.is_driver_worker:15 self.engine_callbacks.pre_sampler_sync_fn(scheduler_outputs)16 self.maybe_gc_collect()两处关键设计:
correction的 D2H 同步。第二篇详细讲过 ZOS 的 seqlens 修正,下文的「跨拍的 seqlens 修正」会把这三个 kernel 的算术完整展开;这里要注意的是:D2H 同步只发生在 varlen 模式(DEEPINFER_ENABLE_MTP_VARLEN),而且同步的是修正后的 seqlens_q,不是采样结果。非 varlen 路径下 CPU 拿到的 seqlens_q 直接沿用调度器里的值。pre_sampler_sync是一个「引擎钩子」。名字说的是「采样器同步前」,但实际做的远不止同步:释放上一拍的DeferredRelease资源、KV 落盘、结构化输出的 matcher pump、完成序列finalize_seq。它被放在采样 kernel launch 之后、sample_end_evt.record()之前,是因为此刻 GPU 在跑采样,而 CPU 做这些纯 CPU 工作完全不需要碰 GPU——它们被「藏」在采样 kernel 后面。
采样之后立即记录 sample_end_evt,并把它打进 WorkerStepState。这个事件对象是下一拍的同步依据。
第 4 段:本拍 post-process 或延后
1if is_prompt or not envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING:2 request_outputs = np.concatenate([request_outputs, self._post_process_step(curr_step_state)])3 self.prev_step_state = None4else:5 self.prev_step_state = curr_step_stateprompt 步不走延后路径,因为 prefill 是「一次性」的:它的 token 不需要立即进入下一次调度,延后没有收益,反而会拖慢 TTFT 之外的其他路径。decode 步在 ZOS 打开时把 curr_step_state 挂到 self.prev_step_state,留给下一拍。
WorkerStepState 的字段必须活到下一次 _post_process_step,源码注释写得很直白:
Must remain valid until the next step’s
_post_process_step, where engine post_process releases them back to the pool.
这是理解 DeferredRelease 的钥匙:seq_metadata_list 来自 SequenceMetadataPool,request_outputs 来自 RequestOutputPool,两者的所有权都在引擎,因为池在 C++ 侧。Worker 只是借用,必须在用完之后通过回调归还。
引擎侧的后处理:post_process 与 _process_model_outputs
当 Driver 的 _post_process_step 在下一拍被调用时,会经 post_process_fn 调回引擎的 post_process(step_state):
1def post_process(self, step_state: WorkerStepState) -> np.ndarray[np.uint64]:2 seq_metadata_list = step_state.seq_metadata_list3 scheduler_outputs = step_state.scheduler_outputs4 output = step_state.sampler_output if not step_state.is_placeholder else None5 if scheduler_outputs.prompt_run:6 self.pe_do_save_kvcache(scheduler_outputs.scheduled_seqs, scheduler_outputs.finished_seqs)7
8 with cpu_timer.record("bubble"):9 request_outputs, log_stats_func = self._process_model_outputs(output, scheduler_outputs)10 self.post_process_model_outputs()11
12 if step_state.forward_events is not None and step_state.sample_events is not None:13 self.scheduler.update_mtp_runtime(14 len(seq_metadata_list),15 step_state.graph_size,16 step_state.forward_events[0].elapsed_time(step_state.forward_events[1]),17 step_state.sample_events[0].elapsed_time(step_state.sample_events[1]),18 )19 ...20 self.deferred_release.collect_sequence_metadata(seq_metadata_list)21 return request_outputs这里有三个容易被忽略的「时间线倒置」:
- 事件对时的时间戳是真实的。
forward_events/sample_events是cuda_timer.latest_event_pair()抓到的 CUDA Event 对,在 post_process 阶段才读取elapsed_time。读取事件时长需要 GPU 侧完成,而此刻该事件早已完成(同步点在第 2 段),因此读取不会阻塞。这就是「post-process 延后一拍」能顺带做实为时估计的原因。 - 运行时估计器的更新是异步的。
scheduler.update_mtp_runtime(...)把 (num_seqs, graph_size, forward_ms, sampler_ms) 喂给 C++ 的MTPVarlen估计器,供下一拍的置信度调度使用。由于 post_process 延后了一拍,估计器永远用的是再前一拍的实测值,这就是第二篇里说的「用实测中位数喂调度决策」。 cpu_timer.record("bubble")的命名。这一段的 CPU 工作发生在 GPU 执行本拍前向的时候,命名为「气泡」是因为它是引擎视角下的 CPU 气泡——虽然对 GPU 来说这段时间已经被填充了计算。
_process_model_outputs 本身做四件事:
1process_MTPInfo(scheduler_outputs, use_nextn, next_n_token_ids, next_n_token_confidence_rates)2scheduler.process_outputs(scheduler_outputs, accepted, token_ids, so_status)3... build_request_outputs(...)4... stats_logger.log_step(...)process_MTPInfo 把投机解码的 next_n_token_ids 与置信度回写给调度器的 MTP info 槽位;scheduler.process_outputs 是第十六篇讲的乐观记账+提交;build_request_outputs 把 token、logprobs、接受统计打包成 RequestOutput。这三步都是纯 CPU,且在 GPU 本拍前向期间完成。
用一个 cpu_timer 账本判断握手是否健康
step() 与 execute_model_step 里埋了大量 cpu_timer.record(...)。它们不是装饰,而是这套握手唯一的内建观测面。理解每个分段测的是什么,才能在上线后判断”慢”到底慢在引擎、Worker,还是同步点。
| 计时器名 | 覆盖范围 | 变大意味着什么 |
|---|---|---|
schedule | 整个调度块 | 调度器本身或克隆下发变慢 |
schedule:cpp | scheduler.schedule() | 等待队列长、前缀匹配重、准入算术贵 |
schedule:clone | 状态/块克隆下发 | 序列 fork 多,或 clone_and_zero_... 的 kernel launch 排队 |
schedule:post | post_schedule() 虚函数 | P/D 分离子类的预取/准备工作变重 |
multi_modal_fetch | 图像预取与失败检查 | 图像解码慢,或 fetch 队列积压 |
run_workers | execute_model_step 整体 | 四段流水总耗时;需要结合下面几项拆分 |
prepare_input | InputMetadata 构造与 H2D | pinned 拷贝多、图选择失败、元数据字段多 |
broadcast_input_metadata | CP 广播 | CP 组大、元数据大;单机 EP 部署应为 0 |
launch_forward | 前向 launch | 只测 launch,不含 GPU 执行;包含图重放或 eager 调用 |
worker.post_process.sync | 等上一拍 sample_end_evt | 最关键的同步指标:持续变大说明上一拍采样没有被本拍前向遮住 |
worker.post_process.do_process | model_runner.post_process + 引擎回调 | 收账、构建输出、统计打印变重 |
pre_sampler_sync | 释放、落盘、SO pump、finalize | KV 保存或资源释放进入关键路径 |
bubble | 引擎 _process_model_outputs | 输出构建、C++ 记账、日志变重 |
三个使用要点:
worker.post_process.sync是 ZOS 的体温计。它测的是”CPU 等上一拍采样结束”的时间。理想值接近 0;如果它和launch_forward同量级,说明本拍前向太短或 GPU 已经被别的任务占满,重叠失效。先查这里,再查调度器。bubble不是真的气泡。它测的是引擎侧后处理的时间,这段 CPU 工作发生在下一拍 GPU 前向期间,所以叫 bubble 是”引擎视角的气泡”;如果它超过一拍前向的时长,就会把 GPU 真正拖空,此时应优先看process_model_outputs与build_request_outputs。broadcast_method被enable=False关闭,说明设计者认为方法名广播的成本可以忽略;真正需要计量的是方法体内部的broadcast_tensor_dict。这也从侧面印证了”run_workers的广播只是一条控制消息”的判断。
数据怎么过河:SchedulerOutputs 与 SequenceMetadata
这一节回答第三个问题:调度结果以什么形式交给 Worker。
零拷贝的 SchedulerOutputs
SchedulerOutputs 是 C++ 对象(csrc/engine/scheduler.hpp 第 46 行起),Python 只能看到 pybind 暴露的字段:
1.def_readonly("prompt_run", &SchedulerOutputs::prompt_run)2.def("is_empty", &SchedulerOutputs::is_empty)clone_states、clone_blocks、zero_states 是 torch::Tensor,它们之所以能被 np.int32 数组接收,是因为 C++ 侧用零拷贝视图把它们包成 numpy 数组交给 Python。step() 里直接写 clone_states.size > 0,而不是 len(clone_states),说明 Python 看到的是 numpy 风格的数组接口。
生命周期约束:这些视图指向调度器内部 vector 的存储,
pybind注释要求「下一次schedule()之前消费完」。第十六篇结尾已经点过这个约束——如果 Python 侧把它们存进队列跨步使用,就会在下一次调度时被静默覆写。
SequenceMetadata 与对象池
seq_metadata_list 与 scheduled_seqs 同序。SequenceMetadata 由 SequenceMetadataPool 分配,C++ 侧持有 shared_ptr,Python 侧拿到的是一组引用。schedule() 在构造输出时顺手把已完成序列的状态写回,并释放对应的序列组。此时序列的 KV 块和状态槽可能还在被 GPU 使用(本拍前向尚未执行),所以释放是「立即」的,但块的归还通过 zero_states / clone_unpins 的延后机制处理——这是调度器和引擎之间的另一层默契。
outputs.hpp 里的 RequestOutputPool 更简单:调用方拿到 uint64 指针数组,解码为 Python 对象时才知道具体字段。build_request_outputs 在 C++ 中直接构造,Python 侧只做薄包装。这种「指针数组」设计的好处是:
- 输出构造的循环完全在 C++ 里跑,Python 不需要为每个请求做一次对象分配;
- 返回值跨
_run_workers时只是一个小 numpy 数组,没有 pickle 开销; - 释放与否由
DeferredRelease统一决定,生命周期是集中的。
代价是 RequestOutput.clone(raw_output) 这一步——entrypoints/llm._run_engine 必须显式克隆,否则下游拿到的对象会在下一拍被池回收并覆写。源码注释写得很明确:
RequestOutputwill be automatically released in the next step() call. If you need to retain them beyond the current step, you must callpool.clone(...).
InputMetadata 的另一条路
SchedulerOutputs 与 SequenceMetadata 只到 Worker 的 execute_model_step;从 Worker 到模型,还要再构造一次 InputMetadata。这一步由 ModelRunner._prepare_input 完成,走的是完全不同的路径:
- 非 CUDA Graph 路径:
_prepare_input_tensors调用模型元数据的prepare_inputmetadata_dict,得到一个 dict,构造为InputMetadata,再recursive_fn(lambda x: x.cuda(non_blocking=True))全部非阻塞搬到 GPU。 - CUDA Graph 路径:
_prepare_input_tensors直接往cuda_graph_used.input_metadata_source.to_dict()里原地填,不返回新对象;GPU 侧看到的是同一块input_metadata_device缓冲区。第十二篇讲过 ZOS 下input_on_device=True的捕获行为,这里补一句:这正是「输入在设备上构造」的真正含义——CPU 写的是 pinned source,Device 端持有的是另一份只读副本。
graph_size 把这两条路径统一起来:它不是 total_q,而是选中的那张图对应的 batch 大小。padding_seq_metadata_list[: graph_size - total_q] 就是补齐用的虚拟序列。graph_size 被写进 WorkerStepState,再喂给 update_mtp_runtime——估计器按图的规模而不是真实请求数来分桶,这与第十二篇讲的「按档位缓存」口径一致。
跨拍的 seqlens 修正:握手时最容易被低估的一块
上面把四段流水讲成”上一拍的采样结果在下一拍收账”,但在投机解码 + ZOS 组合下,还有一个更隐蔽的问题:下一拍的 seqlen_q 与 seqlen_k 本身依赖上一拍的采样结果。
问题:调度器在第 N+1 拍时并不知道第 N 拍采了几个
回顾一次 decode 步的正常口径:一个序列在第 N 拍输入 1 个 token(或 nextn+1 个 token),采样后得到 1 个(或若干被接受的)token,于是第 N+1 拍的 seqlen_k 应该等于”历史长度 + 第 N 拍实际写入 KV 的 token 数”。在非 ZOS 路径里,post_process 在采样后立即把接受数写回 seq.num_tokens,第 N+1 拍的调度器读到的是准确值。ZOS 路径把 post_process 挪到第 N+1 拍,调度器在 schedule(N+1) 的那一刻拿到的是乐观估计:它按”本拍最多能接受 nextn+1 个 token”来记账。于是:
seqlens_k会被高估,最多高出nextn - accepted;- 在 varlen 置信度调度下,
seqlens_q是”按置信度把总 token 预算分给各序列”的结果,也必须用上一拍真实的接受数与置信度来重算。
如果不修正,注意力 kernel 会在第 N+1 拍读到一个比真实 KV 更长的 seqlen_k,读到尚未写入的槽位;seqlen_q 对不上采样时的输入位置则会让 KV 写入的 mtp_info_write_slot 错位。
解法:在 GPU 上用两个融合 kernel 改回来
Worker 在 execute_model_step 的第 1 段之前调用 _correct_seqlens_qk,分三步:
1remap_cpu, seqlens_q_cpu, seqlens_k_cpu, has_overlap = prepare_zos_inputs(2 seq_metadata_list, prev.seq_metadata_list, n_seq_with_padding, self.nextn3)4if not has_overlap:5 return None6remap = remap_cpu.cuda(non_blocking=True)7seqlens_q = seqlens_q_cpu.cuda(non_blocking=True)8seqlens_k = seqlens_k_cpu.cuda(non_blocking=True)9
10if envs.DEEPINFER_ENABLE_MTP_VARLEN:11 compute_seqlens_q_by_confidence_rates(total_q, remap[:n_seq], prev.sampler_output.confidence_rates_gpu, seqlens_q[:n_seq])12if self.nextn > 0:13 compute_seqlens_k_zos(seqlens_k[:n_seq], seqlens_q[:n_seq], remap[:n_seq],14 prev.sampler_output.num_accepted_tokens_gpu, nextn=self.nextn)第一步:prepare_zos_inputs(C++)建 remap。 它在上一拍与这一拍的批次之间建立映射 remap[i] = j,要求 remap[i] 指的上一批序列与本批第 i 条是同一个 seq_id,否则记 -1。它先尝试一条快路径:如果两批长度相同、且逐下标 seq_id 都相等(并且上一拍不是占位步),直接 remap[i] = i;否则退化成哈希表查找。全部为 -1 时返回 has_overlap=false,Python 侧直接跳过修正——这一拍的序列全部是新加入的,没有”上一拍”可参考。
这个函数同时给出两个初值:
seqlens_q[i]=seq.seqlen_q,seqlens_k[i]=seq.seqlen_k−(nextn>0 ? seq.seqlen_q:0)其中 seqlens_k 初值先把”本拍将要写入的 q 个 token”从 k 里扣掉,留作下一步再加回实际接受的部分。padding 槽位(i >= len(seq_metadata_list))填 remap=-1, seqlens_q=1, seqlens_k=1,与 CUDA Graph 的图规模对齐。
第二步:compute_seqlens_q_by_confidence_rates(TileLang kernel)。 它只处理上一拍留下的”尚未真实消费”的 token:先把上一拍的置信度按 remap 散射到本拍序列上(新序列视为 0),沿 nextn 维做累乘得到 cum_rates[i, p]——含义是”第 p 个草稿 token 及它之前全部被接受的概率”。然后把 total_q - num_seqs 个额外 token 预算按 cum_rates 从大到小逐个分配,平局按下标优先。结果是整批 seqlens_q 之和恰好等于 total_q,预算没有浪费。
第三步:compute_seqlens_k_zos(TileLang kernel)。 它把实际接受数补回 k:
源码 compute_seqlens_k_zos.py 的参考实现把这段写成三行 torch 运算:先 seqlens_k += seqlens_q,再用 torch.where(mask, nextn - accepted, 0) 减去”上一拍没被接受、被乐观记进去的那部分”。融合 kernel 把这三次遍历合成一次。
为什么修正必须发生在 GPU 上、并且在第 1 段之前
修正依赖的输入(confidence_rates_gpu、num_accepted_tokens_gpu)都在 GPU 上,调度时还没有 D2H 回来。如果让 CPU 先同步取值再算,ZOS 的整条重叠链就断了。所以 DeepInfer 的选择是:在 GPU 上把 seqlens_q/k 原地改好,作为 MetadataZOS 的一部分直接喂给 execute_main_model,CPU 侧只需要拿到 remap(小数组,由 C++ 在 pinned 内存里生成)。
MetadataZOS 这个 dataclass 把修正结果打包得非常完整:
| 字段 | 形状 | 用途 |
|---|---|---|
remap_cpu / remap | [curr_n_seq_with_padding] int32 | CPU 侧原样、GPU 侧副本,供 kernel 散射 |
seqlens_q / seqlens_k | [curr_n_seq_with_padding] int32 | 修正后的注意力长度 |
prev_sampled_token_ids | [prev_n_seq] | 上一拍采到的 token |
prev_predict_token_ids | [prev_n_seq, nextn] | 上一拍草稿头预测 |
prev_num_accepted_tokens | [prev_n_seq],范围 [0, MTP_value] | 上一拍实际接受数 |
注意 remap 的文档写法:remap[i] = j 若本批第 i 条与上一批第 j 条匹配,否则 -1。这个方向(本→上)决定了后面两个 kernel 的散射语义,方向写反会让整批序列的接受数错配,症状是输出出现重复或跳字。
D2H 与 update_metadata_for_sampling_zos
第三步之后,varlen 模式还会把修正后的 seqlens_q 从 pinned 缓冲拷回 CPU,并记录一个 d2h_evt:
1if envs.DEEPINFER_ENABLE_MTP_VARLEN:2 seqlens_q_cpu[:n_seq].copy_(seqlens_q[:n_seq], non_blocking=True)3 d2h_evt = torch.cuda.Event()4 d2h_evt.record()采样前,Worker 同步这个事件,然后调用 C++ 的 scheduler_outputs.update_metadata_for_sampling_zos(...),把 CPU 侧的采样元数据(token 位置、投机验证的起点)刷新到修正后的长度上。如果少了这一步,采样 kernel 会按”乐观记的 seqlen_q”去读 logits 位置,接受率统计就会和实际前向对不上。
这条 D2H 只在 varlen 模式发生,而且它同步的是 seqlens_q 的拷贝而不是整条采样链——这也是为什么 pre_sampler_sync 仍然能藏在采样 kernel 后面:真正挡住 CPU 的只有这个小数组的往返,不是整个 batch 的 logits。
工程含义
- 修正只在有交集时发生。大批量 decode 时,相邻两拍的批次交集通常很高,修正几乎每拍都跑;小批量时容易整批换人,
has_overlap=false直接跳过。这等价于”修正成本随批次稳定性自动增减”,而不是固定开销。 - CUDA Graph 与 padding 槽。
_correct_seqlens_qk里的n_seq_with_padding用的是图选中的cuda_graph_batch_size,而不是真实请求数;padding 槽的remap=-1保证它们不会被散射到上一拍的任意序列上。 - 它与投机解码的置信度调度是同一个账本。
compute_seqlens_q_by_confidence_rates决定”下拍每条序列带几个 token 进前向”,compute_seqlens_k_zos决定”注意力读到多长”。前者是预算,后者是边界;两边必须基于同一份prev.sampler_output,否则会出现”预算算对了但 KV 读错了”的隐性 bug。
空转步:is_placeholder 与「不跑也得跑」
第四个问题:调度器空了,为什么还要跑一次前向?
step() 里有一条断言:
1if not scheduler_outputs.is_empty() and self.engine_type == EngineType.PROMPT:2 assert scheduler_outputs.prompt_run, "Prompt engines should not perform decoding"3 assert seq_metadata_list[0].seqlen_q > 0, "seqlen_q should be > 0"它说明 PROMPT 引擎的角色:只做 prefill,不参与 decode。反过来,is_empty() 为真时,_execute_model_async_step 传 is_placeholder=True,Worker 会构造一个假的 _placeholder_seq_metadata(request_id=PLACEHOLDER_REQUEST_ID、seq_id=0、单 token、block_table=[0])跑一次前向。
空转步存在的理由,按重要程度排列:
- DP 投票必须集体行动。EP64/DP64 部署里,一个 rank 暂时没有 decode 请求,不代表其他 rank 也没有。如果它直接跳过本拍前向,DP Attention 的 All-Gather 就会在集合通信上卡死。占位步让所有 rank 都在同一时刻发起同一形状的集合通信,代价是一次 1-token 图的重放。
graph_size与total_q必须非零。CUDA Graph 的档位是从total_q连续值里切出来的,total_q=0没有对应的图;占位步把total_q拉回 1,保证走图的路径可用。mtp_info_write_slot的默认值。_placeholder_seq_metadata的注释说「默认落到 padding/dummy 槽 0,store kernel 会跳过它」。如果没有占位步,投机解码的 MTP info 槽位读写边界会不连续,下一拍的置信度调度就会错位。- 保持 CUDA Graph 热。反复捕获/释放代价很高,持续重放能保持 L2 与 TLB 的热度。
占位步的 output 在引擎 post_process 里被显式置为 None:
1output = step_state.sampler_output if not step_state.is_placeholder else None_process_model_outputs 拿到 None 后构造一个空的 SamplerOutput(),process_MTPInfo 处理空 next_n 时走 get_nextn_pad_token_ids() 分支。这条路径的存在说明:占位步在语义上是「合法的空批」,而不是「跳过」。
DeferredRelease:为什么释放要延后一拍
第五个问题。延后释放的对象有四类:
1self.sequence_metadata_to_release # SequenceMetadata2self.request_outputs_to_release # uint64 指针数组3self.clone_unpins_to_release # states/blocks 的 unpin4free_deferred_pool_handles() # 多模态共享池 handlerelease() 被调用两次可能的时机:pre_sampler_sync() 里(正常 ZOS 路径),以及外部调用 flush_inflight_reqs() 时(收尾)。它们都在 GPU 正在忙(采样或前向)的时候被调用。
延后一拍的必要性,可以用一个「不延后」的假设来理解:
- 若第 N 拍 post-process 之后立即归还
seq_metadata_list,调度器在第 N+1 拍的schedule()里就会把同一块SequenceMetadata重新分配给新请求。但第 N+1 拍的第 1 段前向用的是第 N 拍seq_metadata_list的派生结果(prev_step_state仍指向它,用于prepare_zos_inputs的 remap 比较),一旦被覆写,remap 就错了。 - 若第 N 拍的
clone_unpins立即归还,GPU 上的 clone kernel 可能还没开始执行,源页已被调度器复用。 - 若
RequestOutputPool的指针立即回收,_run_engine返回给调用方的RequestOutput会是悬空引用;这正是源码要求调用者先clone的原因。
clone_done_evt 的用法能看出这层小心:
1def collect_clone_unpins(self, states_to_unpin, blocks_to_unpin):2 assert self.clone_unpins_to_release is None3 self.clone_done_evt.record()4 self.clone_unpins_to_release = (states_to_unpin, blocks_to_unpin)5
6def release(self):7 ...8 if self.clone_unpins_to_release is not None:9 states_to_unpin, blocks_to_unpin = self.clone_unpins_to_release10 self.clone_unpins_to_release = None11 self.clone_done_evt.synchronize()12 ...assert ... is None 保证了每拍最多只有一批 clone unpin——因为 step() 里克隆只在调度尾发生,且只在有 clone 时发生。synchronize() 是「clone 完成」的强保证,不是「本拍前向完成」;它被放在 release() 里,意味着这次同步通常落在采样 kernel 的阴影里。
free_deferred_pool_handles() 则归还多模态共享内存池的 handle。阶段二(multi_modal_fetch)里的 fetch() 会申请 handle,embedding 回填之后 handle 才能归还;如果前向之后立即归还,input_metadata 里的 embedding 指针会失效。
收尾:flush_inflight_reqs 与 DP 的「一起停」
step() 不是唯一的推进入口。entrypoints/llm._run_engine 的循环是:
1while True:2 unfinished = self.llm_engine.has_unfinished_requests()3 step_outputs = self.llm_engine.step() if unfinished else self.llm_engine.flush_inflight_reqs()4 for raw_output in step_outputs:5 output = RequestOutput.clone(raw_output)6 ...7 if not unfinished:8 break这里有两个不显眼但很关键的细节。
为什么没有未完成请求时还要 flush
ZOS 把上一拍 decode 的 post_process 挂在 prev_step_state 里。当调度器认为”所有请求已完成”时,上一拍的采样结果可能还没被消费。如果直接退出循环,最后 1–nextn 个 token 永远不会出现在 RequestOutput 里,用户看到的输出会短一截。
flush_inflight_reqs 正是为此存在:
1def flush_inflight_reqs(self):2 request_outputs = self._run_workers("flush_inflight_reqs")3 self.deferred_release.collect_request_outputs(request_outputs)4 self.process_output(request_outputs)5 return request_outputsWorker 侧只做一件事:_post_process_step(self.prev_step_state),然后清空 prev_step_state。引擎侧把它收集进 DeferredRelease,再调用虚函数 process_output(ZOS 下处理的是”上一拍输出的 token”)。注释也承认这一步”may cause CPU-GPU synchronization”,因为等待的是最后一拍的采样结束事件,而不是被下一拍前向遮住的事件。
has_unfinished_requests 为什么要跨 DP 归约
1unfinished = self.scheduler.has_unfinished_seqs()2unfinished = unfinished or self.so_builder_has_pending()3if dp_group.size() > 1:4 unfinished = torch.full((1,), unfinished, dtype=torch.int64, device=device)5 dist.all_reduce(unfinished, op=dist.ReduceOp.MAX, group=dp_group)6 unfinished = unfinished.item() > 0如果每个 rank 独立判断”我这边空了就退出”,会出现两种灾难:
- 还有请求的 rank 继续调用
step(),空了的 rank 已退出循环,下一次 DP Attention 的 All-Gather 永远等不到对端; - 空了的 rank 提前销毁进程组,未完成的 rank 在 collective 上挂起。
MAX 归约保证”只要全组有一个 rank 还有活,所有 rank 都继续走”,这就是”一起停”的语义。ZOS 打开时,代码把通信组换成 PG.cpu_data_parallel_group()、设备换成 CPU:这样这次归约不触碰 CUDA 流,不会在重叠窗口里插入一次设备同步。非 ZOS 时用 CUDA 组,unfinished 直接留在 device 上,省一次 D2H,但 item() 本身已经是一次同步——因为非 ZOS 路径本来就要在采样后同步,多这一次不改变关键路径。
P/D 分离:同一份握手协议下的两个引擎
三种引擎类型
EngineType 只有三个值,C++ 侧用同名枚举与 Python 保持一致(engine_type.py 的注释明确要求这一点):
1class EngineType(enum.Enum):2 NORMAL = 0 # process prompts & completions3 PROMPT = auto() # only process prompts4 COMPLETION = auto() # only process completionsEngineArgs.__post_init__ 用它来设置两个环境变量的默认值:
1os.environ.setdefault("DEEPINFER_ZERO_OVERHEAD_SCHEDULING",2 "1" if self.engine_type.default_zero_overhead_scheduling else "0")3os.environ.setdefault("DEEPINFER_USE_MULTIMODAL_POOL",4 "1" if self.engine_type != EngineType.COMPLETION else "0")而 default_zero_overhead_scheduling 的属性是:
1return self in (EngineType.NORMAL, EngineType.COMPLETION)这行代码给出了 P/D 分离的第一个非对称性:PROMPT 引擎默认不启用 ZOS,COMPLETION 引擎默认启用。原因不难从 step 的语义推出来:prefill 的「后处理」没有跨拍依赖,启用 ZOS 只会把 TTFT 拉长;decode 的每一步都要重复「调度—前向—采样—收账」,才有把收账藏到下一拍的价值。这也解释了 step() 里那条断言:
1if not scheduler_outputs.is_empty() and self.engine_type == EngineType.PROMPT:2 assert scheduler_outputs.prompt_run, "Prompt engines should not perform decoding"3 assert seq_metadata_list[0].seqlen_q > 0, "seqlen_q should be > 0"PROMPT 引擎永远不应该出现 decode 步。反方向上,COMPLETION 引擎在构造 C++ 调度器时被 TORCH_CHECK 禁止「允许抢占后重算」(见第十六篇),因为它的显存预算按「只跑 decode、短序列、大并发」配置,重算一条长 prompt 的成本不可接受。
钩子分布在握手的哪几个位置
P/D 分离需要跨进程交接 KV。交接点不在 Worker 里,而在引擎这一侧的两个回调里——这一点从虚函数的调用位置可以直接读出来:
1def pre_sampler_sync(self, scheduler_outputs):2 self.deferred_release.release()3 if not scheduler_outputs.prompt_run:4 if self.copy_state_event is not None:5 self.copy_state_event.synchronize()6 self.do_save_kvcache(scheduler_outputs.scheduled_seqs, scheduler_outputs.finished_seqs)7 else:8 self.pe_prepare_save_kvcache(scheduler_outputs.scheduled_seqs, scheduler_outputs.finished_seqs)9 if self._so_matcher_builder is not None:10 self._so_matcher_builder.pump()11 if self.engine_type in [EngineType.COMPLETION, EngineType.NORMAL]:12 for seq in scheduler_outputs.finished_seqs:13 self.finalize_seq(seq)四个钩子各有分工:
| 钩子 | 调用时机 | 基类行为 | 设计意图 |
|---|---|---|---|
pe_prepare_save_kvcache | pre_sampler_sync,prompt 步 | 空 | prefill 侧为「把 KV 交给 decode」做预备 |
pe_do_save_kvcache | post_process 开头,prompt 步 | 空 | 采样结束后真正落盘/发送 |
do_save_kvcache | pre_sampler_sync,decode 步 | 空 | decode 侧按 token 边界增量保存 |
send_processed_prompts | post_process 末尾,PROMPT 引擎 | 空 | 把已 prefill 完的序列交给 COMPLETION 引擎并从调度器移除 |
pe_prepare_save_kvcache 与 pe_do_save_kvcache 的两段式位置很有讲究:prepare 位于采样 kernel 已经 launch、但采样结果还没被 CPU 消费的时刻;do 位于 post_process 拿到采样结果之后。前者可以做与采样重叠的准备(比如把 KV 块句柄、状态槽编号整理进待发送队列),后者才能确定这一条 prompt 最终产生了多少 token、块表长什么样。prompt 步通常没有投机解码,所以两段之间的差异不是「接受数未知」,而是「采样输出(含 logprobs、SO status)未知」——pe_do_save_kvcache 放在 _process_model_outputs 之前,说明它只需要序列长度和块表,不需要采样结果;两段式更像是把「可在采样期间做的准备」和「必须在采样后做的提交」分开。
send_processed_prompts 与 finalize_seq 是互斥的两条收尾路径:PROMPT 引擎把完成的 prompt 序列送走,所以不能在 pre_sampler_sync 里 scheduler.free_seq;COMPLETION / NORMAL 引擎没有下游,才在 pre_sampler_sync 里真正释放。
开源仓库能读到哪,读不到哪
必须把证据边界说清楚:上面这些钩子在开源仓库里全部是空实现,注释统一写着「虚函数」。具体的 PROMPT / COMPLETION 子类不在 deepseek-ai/DeepInfer 里。官方部署问答也明确了原因:
The KV cache management and load balancing components of the inference system are tightly coupled to our internal infrastructure, so that code is not yet open-sourced.
所以对 P/D 分离的源码级分析只能停在「引擎在什么时刻调用哪个钩子、传什么参数」这一层,不能进一步断言子类内部如何编码 KV、如何选路。能确认的是:
- 钩子挂在引擎而不是 Worker 上,说明 KV 的保存/发送以
scheduler_outputs的序列账本为准,而不是以 GPU 上的张量为准; pre_sampler_sync先deferred_release.release()再落盘,说明「释放上一拍资源」必须早于「为本拍保存 KV」,否则被释放的块可能在落盘之前被重新分配;- decode 侧的
copy_state_event.synchronize()在do_save_kvcache之前,说明落盘读的是拷贝缓冲而不是活跃状态槽,活跃槽在本拍之后还会被继续写。
官方问答给出的部署口径补上了外部约束:prefill 用 EP2–8 / DP2–8、decode 用 EP64–128 / DP64–128,Ppe_* 钩子必须存在:P 侧跑完的序列不是「结束」,而是「移交给另一个进程继续 decode」,引擎的账本因此要跨进程转移。
与 SGLang、vLLM、TensorRT-LLM、llama.cpp 的 step 对照
pre_sampler_sync_fn 这个钩子的位置,恰好是理解「DeepInfer 引擎握手」与「SGLang 重叠调度」差异的切入点。
| 维度 | SGLang Overlap Scheduler | DeepInfer ZOS |
|---|---|---|
| 前后拍重叠的载体 | Future token 占位,调度时使用上一拍的输出 | prev_step_state 持有上一拍的 seq_metadata_list 与 scheduler_outputs |
| CPU 后处理位置 | 在 forward(N) 之后、schedule(N+1) 之前的回调 | _post_process_step 在 execute_model_step 内,与 forward(N) 同时 |
| 输出生命周期 | 由 scheduler 的 future map 管理 | 由 DeferredRelease + RequestOutputPool 统一管理 |
| 空转处理 | 调度为空时跳过 | 构造占位步跑一次前向(DP 投票需要) |
两者的共同点是:都必须在下一拍调度之前拿到上一拍的接受 token 数。差别在于,SGLang 是通过 future token 把「未决」显式建模在调度器里;DeepInfer 则让调度器只负责「乐观记账」,把「回填」交给引擎的 post_process 回调。前者把复杂度放在调度器,后者把复杂度放在引擎与 Worker 之间的回调链上。
pre_sampler_sync 的存在,实际上替代了 SGLang 里那个「调度与采样之间的屏障」:它一次性做完了释放、落盘、matcher pump 和 finalize。把所有需要「上一拍资源已可安全使用」的事情集中在一个钩子里,是 DeepInfer 在这个边界上的简洁之处。
把另外三个框架的「单步」放在同一张表里,差异会更清楚:
| 框架 | 单步推进点 | CPU 后处理位置 | 跨 rank 机制 | 空批/空转 |
|---|---|---|---|---|
| DeepInfer | LLMEngine.step() → Driver execute_model_step | 上一拍收账,藏在下一拍前向后面 | 共享内存广播方法名 + broadcast_tensor_dict | 显式占位步,保证集合通信对齐 |
| SGLang | Scheduler 主循环 + Overlap Scheduler | 由 future token 表示未决,重叠路径把输出处理挂到下一轮 | 进程组 + ZMQ(前端/TokenizerManager) | 调度为空时跳过 |
| vLLM V1 | EngineCore.step(),前端 LLMEngine.step() 只消费结果 | 同一事务内,采样结果返回后立即 process_outputs | 多进程 EngineCore + ZMQ + 进程组 | 空批通常直接返回,不跑模型 |
| TensorRT-LLM | PyExecutor 的 _executor_loop_overlap | _process_previous_batch 延后到下一轮 | MPI/进程组 + Communicator | 由调度器与服务层处理 |
| llama.cpp | server_context::update_slots 主循环 | 同一轮 llama_decode 之后,经结果队列回传 | 单进程,无跨 rank | 没有可用 slot 时任务留在 deferred 队列 |
三点值得展开:
- vLLM 的边界不一样。V1 的
EngineCore.step()是一个”调度—执行—提交”事务,execute_model(..., non_block=True)返回 Future 后,它才去取结构化输出的 bitmask,最后future.result()收口。它没有 DeepInfer 这种”延后一拍再收账”的结构,CPU 后处理仍在同一事务里;跨进程的那道坎在 EngineCoreProc 与前端之间,用 ZMQ 传递请求与输出。DeepInfer 则把 CP 的 worker 做成同进程可反射的从属进程,控制面用共享内存广播,这是面向”一个模型、一套固定拓扑”的特化选择。 - TensorRT-LLM 是最接近的同构方案。
_executor_loop_overlap明确把「更新资源」与「产出响应」挪到下一步的 GPU 时间窗口里,而且 KV 块的回收也因此多存在一轮;这与 DeepInfer 的DeferredRelease要解决的”上一拍资源在本拍是否可安全归还”是同一个问题。差异在记账粒度:TensorRT-LLM 用ResourceManager的计数与scheduled_batch,DeepInfer 用 C++ 调度器的块表 + 池化对象 + CUDA Event。 - llama.cpp 是最小形态。
update_slots每轮组装 batch、调用llama_decode、把结果通过结果队列回传给 HTTP 线程;没有跨拍后处理,也没有占位步,因为它没有 EP/DP 的集合通信要维持对齐。它的”握手”实际上是队列与锁的握手,不是引擎与 Worker 的握手。
这张表也解释了 DeepInfer 为什么把 pre_sampler_sync 放在 Worker 里而不是引擎的 step() 里:只有 Worker 知道采样 kernel 何时 launch、何时记录结束事件;引擎只知道调度账本。把”释放上一拍资源”和”保存本拍 KV”安排在采样 kernel 的阴影里,需要一个同时看得见 GPU 事件与引擎状态的位置——这个位置就是 Driver Worker 的回调点。
工程边界与陷阱
- 只有 Driver 才有完整状态。调试时如果只看从属 Worker 的日志,会发现
seq_metadata_list=None、没有采样输出,这是设计而非 bug。定位问题应该先看 Driver 的cpu_timer分段。 step()返回值必须克隆。entrypoints/llm._run_engine调用RequestOutput.clone(raw_output)是正确用法;直接缓存step_outputs会在下一拍读到被覆写的数据。这个错误不会崩溃,只会产生难以复现的错乱输出。driver_kwargs与kwargs不可混用。给 CP 从属 Worker 传 Python 对象会触发静默丢弃(它们拿到的是None),而不会报错。新增 Worker 方法时要明确:哪些参数是 Driver 专有,哪些需要跨卡。forbid_cuda_sync()的语义。它拦截的是显式同步调用;隐式的 H2D 阻塞拷贝、.item()、torch.cuda.synchronize()之外的隐式同步(如部分 D2H 路径)仍需人工审查。ZOS 一旦被一次隐式同步打断,cpu_timer里的worker.post_process.sync会显著变大,这是排查的首选指标。- 空转步与
has_unfinished_requests的相互作用。LLMEngine.has_unfinished_requests在 DP 组上做了一次MAX归约:只要全组有任何 rank 未完成,本 rank 就继续 step。所以「本 rank 空」不会导致本 rank 停止,会变成占位步;如果所有 rank 都空,step()会被_run_engine替换成flush_inflight_reqs(),把 ZOS 在途的那一拍 post-process 掉。 flush_inflight_reqs可能造成同步。docstring 自己承认了这一点:收尾时prev_step_state的sample_end_evt等的是「上一次采样」,而非「本拍采样」,所以同步开销通常还是被遮挡;但如果持续输出大量短请求,每轮最终都会 flush 一次。- 多模态失败被转成 abort。
fetch_to_abort_requests()返回的列表会直接触发abort_request,并发一条 error 日志。这类 abort 会出现在post_process之前的请求表里,调试时要先检查该日志,而不是先怀疑调度器。 - YoCo 截断与抢占的交互。
step()里那条注释NOTE 开启 yoco 截断时,允许抢占导致的结果变化是设计决定:截断之后被抢占序列的重算不再保证逐 token 等价。需要确定性输出的场景应关闭截断或避免抢占。
小结
LLMEngine 与 Worker 的握手可以概括为四句话:
- 拓扑是「单引擎 + 反射 Worker」:Driver 持有全部状态,从属 Worker 只认方法名,输入元数据由 ModelRunner 单独广播。
- 四段流水是「本拍前向、上拍收账、本拍采样、本拍收账或延后」:唯一强制同步在「上拍采样结束事件」,恰好被本拍前向遮住。
- 数据过河靠两类对象:
SchedulerOutputs/SequenceMetadata是零拷贝视图,生命周期约束到下一次schedule();RequestOutput是指针池,必须克隆后才能跨拍持有。 - 延后一拍是三种机制的结合:
DeferredRelease管资源,WorkerStepState管状态,pre_sampler_sync管时机。
下一篇将拆 DeepInfer 的启动装配线:EngineArgs.create_engine_configs 如何把命令行与环境变量变成 ModelConfig / CacheConfig / ParallelConfig / SchedulerConfig,_init_cache 的显存 profiling 如何推算出块数,warm_up_model 的预热、CUDA Graph 捕获与 decode_perf_curve 测量如何串成冷启动闭环,以及 P/D 分离时 EngineType.PROMPT 与 EngineType.COMPLETION 两个进程如何各自装配。
参考资料
- DeepInfer 官方仓库
- DeepInfer 引擎源码 deepinfer/engine/llm_engine.py
- DeepInfer Worker 源码 deepinfer/worker/worker.py
- DeepInfer WorkerBase 源码 deepinfer/worker/base.py
- DeepInfer ModelRunner 源码 deepinfer/worker/model_runner.py
- DeepInfer 引擎类型 deepinfer/engine/engine_type.py
- DeepInfer 官方部署问答 doc/inference_deployment_qa.md
- DeepSeek V4.1 Flash 推理系统知乎技术文章
- SGLang:高效执行结构化语言模型程序(RadixAttention 与重叠调度)
- NanoFlow:向最大吞吐浮点操作靠拢的批处理 LLM 推理
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



