正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(三十七):EngineType 与 P/D 分离接口——同一个引擎类如何分饰预填充、解码与混合三种角色
一个枚举值,决定了一次前向的命运
DeepInfer 的 LLMEngine 构造函数里有一个默认参数:
1engine_type: EngineType = EngineType.NORMAL,它看起来只是一个开关,实际上决定了这个引擎实例在集群里扮演什么角色:NORMAL 是预填充(prefill)与解码(decode)都做的混合引擎,适合离线推理和单机服务;PROMPT 只做预填充,属于生产部署里的 PE(Prefill Engine);COMPLETION 只做解码,属于 CE(Completion Engine)。三者共用同一个 LLMEngine 类、同一套 Worker、同一份模型代码,差异被压缩成几处 if engine_type != ... 的分支和一组预留的虚函数。
本篇要做的事,是把这种”一个类、三种角色”的设计从源码层面拆开:
- 这个枚举在 Python 与 C++ 两侧怎样保持一致,为什么必须一致;
- 构造参数
__post_init__怎样根据角色自动改写环境变量,chunked_prefill默认值与角色之间有什么隐含关系; - 调度器
_schedule()里三种角色的三套分支,以及NORMAL引擎为什么需要 DP 投票来决定”这一步全组做预填充还是解码”; - P/D 分离的一组虚函数(
init_pd_split、finalize_seq、post_schedule、should_copy_state、do_save_kvcache、pe_prepare_save_kvcache、pe_do_save_kvcache、send_processed_prompts)分别在_init_cache()/step()/pre_sampler_sync()/post_process()的哪个时刻被调用,它们共同勾勒出的预填充到解码交接时序; - 角色如何继续下沉到显存估算、CUDA Graph、投机草稿模型、EPLB 重排和 MoE 缓冲尺寸这些模块;
- 官方文档里描述的 E/P/D 三阶段分离架构,在开源代码里补齐了哪些、又留白了哪些。
调度器本身的准入、抢占、LCFS 等细节已经在 DeepInfer 源码完全拆解(十六):C++ 调度器 里讲过,这一篇只关心与角色有关的部分。
三种角色的定义与边界
2.1 枚举定义
Python 侧的定义在 deepinfer/engine/engine_type.py:
1class EngineType(enum.Enum):2 """3 LLMEngine type4
5 请和 csrc/engine/scheduler.hpp 中的 enum class EngineType 保持一致6 """7
8 NORMAL = 0 # process prompts & completions9 PROMPT = enum.auto() # only process prompts10 COMPLETION = enum.auto() # only process completions11
12 @property13 def default_zero_overhead_scheduling(self) -> bool:14 return self in (EngineType.NORMAL, EngineType.COMPLETION)C++ 侧在 csrc/engine/scheduler.hpp:
1/// LLMEngine 类型。2/// 请和 deepinfer/engine/engine_type.py 中的定义保持一致3enum class EngineType : int {4 NORMAL = 0, // process prompts & completions5 PROMPT = 1, // only process prompts6 COMPLETION = 2, // only process completions7};两侧必须一致,是因为 SchedulerConfig 把 engine_type.value 作为一个 int 传给 nanobind 包装的 C++ 调度器,C++ 侧用 static_cast<EngineType> 直接转回来。一旦 Python 侧插入新枚举值,C++ 侧的 NORMAL=0/PROMPT=1/COMPLETION=2 就会错位,NORMAL 可能被当成 PROMPT 来解释——整个集群的步型都会错。所以两边都写了”保持一致”的中文注释,csrc/engine/scheduler.hpp 的构造函数里还有一句兜底:
1TORCH_CHECK(false, "unknown engine_type ", int(engine_type));_schedule() 的 switch 没有 default 分支而是在 switch 之后断言,任何未识别的值都会在第一次调度时炸出来,而不是静默走错路径。
default_zero_overhead_scheduling 是一个容易看漏的地方:PROMPT 引擎默认不开启零开销调度(ZOS)。原因是 ZOS 的价值在于把解码步的 CPU 后处理移到下一拍,和 GPU 前向重叠;预填充引擎的每一步都是一个大批次 prefill,前后处理体量与 decode 完全不同,重叠收益有限,而且 PE 需要尽快把处理完的请求连同 KV 发出去,延后一拍反而增加交接延迟。ZOS 的步编排细节可以参考 DeepInfer 源码完全拆解(二):异步调度与零开销调度 ZOS。
2.2 构造期:角色改写环境变量
EngineArgs 是一个 dataclass,engine_type 只是字段之一。在 __post_init__ 里,角色被翻译成三个副作用:
1def __post_init__(self):2 if self.tokenizer is None:3 self.tokenizer = self.model4 os.environ.setdefault(5 "DEEPINFER_ZERO_OVERHEAD_SCHEDULING",6 "1" if self.engine_type.default_zero_overhead_scheduling else "0",7 )8 environ.rearm("DEEPINFER_ZERO_OVERHEAD_SCHEDULING")9 os.environ.setdefault(10 "DEEPINFER_USE_MULTIMODAL_POOL",11 "1" if self.engine_type != EngineType.COMPLETION else "0",12 )13 environ.rearm("DEEPINFER_USE_MULTIMODAL_POOL")这里用 setdefault 而不是直接赋值,含义是:如果运维已经在环境变量里显式写了 DEEPINFER_ZERO_OVERHEAD_SCHEDULING=1,就以运维为准,角色只提供默认值。随后 environ.rearm 让 DeepInfer 的环境变量缓存重新读取该键——DeepInfer 的环境变量是模块加载时一次性快照的,若不在改写后重读,后面读到的还是旧值。
DEEPINFER_USE_MULTIMODAL_POOL 对 COMPLETION 引擎强制关闭,这一点后面还有旁证:LLMEngine.__init__ 里 mm_enabled 的判定是
1mm_enabled = hasattr(model_class, "multi_modal_mapper") and engine_type != EngineType.COMPLETION也就是说解码引擎根本不构造 MultimodalProcessor。理由很直接:图像编码是预填充阶段的工作,解码侧的输入只有 token id 和已经就位的 embedding,没有任何多模态数据需要处理。视觉编码器 VE 与预填充分离后,CE 连多模态对象的引用都不需要持有,自然也就不应该为它预留显存和 CPU 线程。
2.3 chunked_prefill 的默认值与角色
EngineArgs 里 chunked_prefill: bool = False,看起来是个与角色无关的开关。但 C++ 调度器里有一行:
1prompt_limit = chunked_prefill ? max_model_len : std::min(max_model_len, max_num_batched_tokens);而 SchedulerConfig._verify_args() 在 chunked_prefill=False 时要求:
1if (not self.chunked_prefill) and self.max_num_batched_tokens < self.max_model_len:2 raise ValueError(...)合起来看:不开分块预填充时,最长的一条 prompt 必须能在一个批次里放下,因此 max_num_batched_tokens 至少等于 max_model_len。此时 max_num_batched_tokens 的上界——也就是一步能携带的 token 预算——必须撑得住”最长 prompt 整条塞进来”,但 _schedule_prefill 仍然可以在这个预算内放多条短 prompt。真正的差别在于长 prompt:一条超过预算的 prompt 在不分块时只能独自成批,短 prompt 才有机会拼批。PE 的职责是以尽可能大的批量吸收大量请求,它应当显式配置 chunked_prefill=True,让调度器按 token 预算切分长 prompt,从而把多条 prompt 的尾块和短 prompt 拼进同一个批次,而不必让一条长 prompt 独占一拍。
换句话说,chunked_prefill 的默认值暴露了一个隐含假设:默认配置面向”一条一条来”的通用引擎;P/D 分离部署里的 PE 需要显式切换到批量模式。代码层面并没有强制检查这一点,这是一处需要部署者自行意识到的角色化配置。
数据流:一次 step 在三种角色下的差异
先把 LLMEngine.step() 的主干画出来,再看角色在哪里插入。下面是去掉计时与日志后的主干:
1step():2 seq_metadata_list, scheduler_outputs = scheduler.schedule(...)3 post_schedule(scheduler_outputs) # 虚函数4 if prompt_run: ... SO 状态检查 ...5 if not prompt_run and copy_state is not None:6 to_copy = [seq for seq ... if should_copy_state(seq)] # 虚函数7 if to_copy: do_copy_state(to_copy)8 if multi_modal_processor: multi_modal_processor.fetch()9 if engine_type == PROMPT: assert prompt_run # 角色断言10 request_outputs = _execute_model_async_step(...) # Worker 侧 4 段11 ...12 pre_sampler_sync(scheduler_outputs) # 引擎回调13 if not prompt_run: event.synchronize(); do_save_kvcache(...) # 虚函数14 else: pe_prepare_save_kvcache(...) # 虚函数15 if engine_type in [COMPLETION, NORMAL]:16 for seq in finished_seqs: finalize_seq(seq) # 虚函数17 ...18 post_process(...) # 引擎回调19 if prompt_run: pe_do_save_kvcache(...) # 虚函数20 if engine_type == PROMPT: send_processed_prompts() # 虚函数这里有三个观察点。
第一,角色断言只有一条。 if scheduler_outputs 非空且 engine_type == PROMPT 时,代码断言这一步必须是 prompt_run。这是为了保护 PE 的对外契约:PE 的 KV 交接逻辑写在 pe_do_save_kvcache 里,如果某一步意外跑成了 decode 步,pe_do_save_kvcache 不会被调用,而 decode 步的 do_save_kvcache 分支又被 prompt_run 判定屏蔽,结果就是这一拍的 KV 写不进交接流程——PE 静默丢失一格。断言把这种状态机错误变成崩溃,而不是数据不一致。
第二,交接钩子分散在三个时点。 pre_sampler_sync 在采样 kernel 下发之后立刻执行(此时还没同步采样结束事件),本步前向与采样的 kernel 都已下发、KV 已写入设备缓存;post_process 要等采样结束事件同步之后才执行(_post_process_step 的第一行就是 sample_end_evt.synchronize())。中间隔着的正是 GPU 上尚未完成的采样核——采样不改变 KV,所以 PE 可以在这个”GPU 还在算”的窗口里启动 KV 交接,等 pe_do_save_kvcache 时才去收尾。这一点在下文的时序图里会详细展开。
第三,结束请求的回收被角色切分。 finalize_seq 只在 COMPLETION 和 NORMAL 引擎里调用。PROMPT 引擎不调用它,因为 PE 里”结束”的含义不同:PE 的请求在前向完成、KV 交接之后就从 PE 的调度器里删掉(注释原文是”把已经处理完的 prompt sequence 发送给 completion engine 并从 scheduler 中删除”),而不是常规意义上的 scheduler.free_seq。如果 PE 也走 free_seq,KV 写入还没完成就把块还回池子,下一个请求可能踩到这块内存。
下面是这条数据流在 E/P/D 三阶段下的示意图。图中展示的是官方文档给出的参考架构:左侧是视觉编码器实例,中间是预填充实例,右侧是 Wide-EP 的解码实例,底部是专用的 KV Cache 存储集群。

图片来源:DeepInfer 官方文档 doc/inference_deployment_qa.md 中的推理系统架构图。
图中几个模块必须和源码对上号:
- 每台 PE 内部就是若干个
EngineType.PROMPT的实例,每个实例都持有完整的模型层堆栈(DeepInfer 不做张量并行,详见 DeepInfer 源码拆解(十三):DP Attention 与 Wide-EP),所以交接的 KV 是整模型级的,不需要按层协商。 - 每台 CE 内部是
EngineType.COMPLETION的 Wide-EP 实例,EP64–EP128 的解码实例把每个 token 的 MoE 权重搬运量压到最小。 - 中间那条从 PE 到 CE 的”KV 搬运”边,在开源代码里就是
pe_do_save_kvcache/do_save_kvcache这组虚函数的落点——只不过真实落点是它们被子类重写后的实现,而开源仓库里尚未提供这些子类。 - 底部的 3FS 存储集群在源码层面体现为
do_save_kvcache的语义:pre_sampler_sync里已经同步了copy_state_event,说明它读的是已经落到 GPU 副本区的状态,再交给存储层搬运。
源码解读:调度器的角色分支
4.1 _schedule() 的三种分支
调度器 _schedule() 的骨架非常短,因为角色差异被平铺成三个 case:
1switch (engine_type) {2case EngineType::PROMPT:3 // PROMPT 引擎只做 prefill,即使没调度到请求也返回(空的)prefill 结果4 return _schedule_prefill(acquire);5case EngineType::COMPLETION:6 // COMPLETION 引擎只做 decode7 return _schedule_decode(acquire);8case EngineType::NORMAL: {9 // NORMAL 引擎:决定这一步全组做 prompt 还是 decode10 ...11}12}13TORCH_CHECK(false, "unknown engine_type ", int(engine_type));PROMPT 与 COMPLETION 两个分支都只有一行,这是角色化设计最彻底的地方:PE 永远只看等待队列,CE 永远只看运行队列,它们不需要任何”这一步做什么”的判断。所有复杂度都集中在 NORMAL 分支。
PROMPT 分支还有一条隐含语义:即使 waiting 为空,它也要返回一个空的 SchedulerOutputs,而不是 nullptr。因为它的调用方——PE 的主循环——需要一个统一的”SchedulerOutputs 结构”来做后续处理;返回 nullptr 会让上层走到”转 decode”的逻辑,而 PE 根本没有 decode 通道。这一条在下文 4.2 节会对比 NORMAL 的空手返回。
4.2 _schedule_prefill 的收口
_schedule_prefill 的尾部有两处角色判断:
1// ... 逐条从 waiting 里挑请求 ...2if (is_chunked && engine_type == EngineType::NORMAL) {3 break;4}5
6if (engine_type != EngineType::NORMAL || !scheduled.empty() || !ignored_seqs.empty()) {7 return _make_outputs(std::move(scheduled), true, num_batched_tokens, {}, std::move(ignored_seqs), std::move(seqlens_q), 0);8}9
10// NORMAL 引擎且本轮没有调度到任何 prompt,交由 _schedule 决定是否转为 decode11return nullptr;第一处 break 的含义是:NORMAL 引擎在开分块预填充时,一旦挑中一条分了块的请求,就停止继续挑选,把本步的 token 预算留给定块。原因是 NORMAL 引擎的 prefill 步之后紧接的就是 decode 步,如果一步放进去太多分块请求,本步的时间会显著变长,后面等待 decode 的请求全部被拖慢;相比之下,PE 引擎可以一路挑完,因为它不需要为这一步的后续 decode 让步。
第二处是返回值语义的分叉:
| 角色 | 本轮调度为空时的行为 | 原因 |
|---|---|---|
PROMPT | 返回空 SchedulerOutputs(prompt_run=true) | PE 没有 decode 通道,空步就是合法的空步 |
NORMAL | 返回 nullptr | 交给 _schedule() 决定转 decode |
COMPLETION | 不走 _schedule_prefill,直接进 decode | CE 只有 decode 队列 |
PROMPT 能返回空的 prompt_run 结果,依赖 step() 里对空步的处理:is_placeholder 路径会用 _placeholder_seq_metadata(is_prompt) 拼一个占位 meta,让 Worker 侧执行一次”空前向”。所以 PE 的每一拍都有一次前向,哪怕什么都没排到。这是有意的:PE 的主循环被设计成固定节拍,空拍前向的结果会被丢弃,但保证了 PE 的 CPU 侧与 GPU 侧的节拍对齐。
4.3 NORMAL 引擎的 DP 投票
NORMAL 分支是整个调度器最绕的部分。关键前提是 NORMAL 引擎的 DP 组内所有 rank 必须一起走同一个步型(都做 prefill 或都做 decode)。这和 vLLM、SGLang 不一样——那两家的 DP 副本各自独立调度,一个 rank 做 prefill 时另一个 rank 可以做 decode。DeepInfer 之所以要求步型一致,是因为它的 DP Attention 和 MoE EP 共用同一组集合通信(详见 DeepInfer 源码拆解(十三):DP Attention 与 Wide-EP),MoE 层的 All-to-All 需要所有 rank 在同一拍上都有 token 要交换;一个 rank 做 prefill 另一个做 decode 会导致通信域内的形状不一致。
投票逻辑可以拆成两个尝试点:
1bool is_any_point = (decode_step != 0) && (decode_step % prefill_any_attempt_interval == 0);2bool is_all_point = (decode_step % prefill_attempt_interval == 0);3if (!is_any_point && !is_all_point) {4 return _schedule_decode(acquire);5}6bool want = (get_gpu_cache_usage() <= GPU_CACHE_PREFILL_WATERMARK) && _has_prefill_waiting();7bool do_prefill;8if (is_any_point) {9 do_prefill = (_dp_allreduce(want ? 0 : 1, c10d::ReduceOp::MIN) == 0); // ANY:任一 rank 想做即触发10} else {11 do_prefill = (_dp_allreduce(want ? 1 : 0, c10d::ReduceOp::MIN) == 1); // ALL:全员想做才触发12}两个默认间隔来自环境变量:DEEPINFER_PREFILL_ATTEMPT_INTERVAL 默认 70,DEEPINFER_PREFILL_ANY_ATTEMPT_INTERVAL 默认 100。
- ALL 点(每 70 个 decode step 一次):所有 rank 都有可调度的 prefill 才触发,用
MIN归约want ? 1 : 0,只有全组都是 1 才做 prefill。这样避免一个 rank 有活、其余 rank 陪跑空 prefill 步。 - ANY 点(每 100 个 decode step 一次):任一 rank 有活就全组一起做 prefill,用
MIN归约want ? 0 : 1。它的作用是给孤立 rank 兜底:如果某个 rank 的 waiting 队列里只有一条请求,而其余 rank 都没有,ALL 点永远触发不了,这条请求就会无限期饿死。ANY 点保证它最多等 100 个 decode step。
还有第二道投票,处理”想做但排不出”的情况:
1if (want) {2 outputs = _schedule_prefill(acquire);3}4// 第二轮投票:确认这轮真有 rank 排出了 prefill。5bool any_scheduled = (_dp_allreduce(outputs ? 0 : 1, c10d::ReduceOp::MIN) == 0);6if (!any_scheduled) {7 return _schedule_decode(acquire);8}9if (outputs) {10 return outputs;11}12// 组内确有 rank 在做 prompt:本 rank 陪跑空 prompt step 保持步型一致13return _make_outputs({}, true, 0, {}, {}, {}, 0);want 只看 waiting 非空和 KV 水位,并不保证 _schedule_prefill 一定能排到(可能 max_num_seqs 满了,也可能 KV 块不够)。因此第一轮投票之后还要再投票一次”本轮全组是否有任何 rank 真的排到了”。如果全组都空手,就必须转 decode;否则会出现死循环——running 满 + waiting 非空,want 恒为真,每一步都空手做 prefill,每个 prefill step 又会把 decode_step 清零,导致下一次又落在 ALL 点……代码里专门留了注释说明这一点。
第二轮投票还有一层含义:如果组内某个 rank 排到了而本 rank 没排到,本 rank 必须陪跑一个空 prefill 步,返回 prompt_run=true 的空 SchedulerOutputs,而不是 nullptr。因为如果返回 nullptr,本 rank 就会转去 decode,与组内其他 rank 的 prefill 步型不一致,MoE 通信域立刻错位。
这三层投票合起来,可以用一张表概括 NORMAL 引擎每一步的决策:
| 条件 | 结果 |
|---|---|
| 不在 ALL 点也不在 ANY 点 | 直接 decode |
在 ALL 点,全组 want | 全组 prefill;然后第二轮投票 |
在 ANY 点,任一 rank want | 全组 prefill;然后第二轮投票 |
| 第一轮通过,第二轮全组空手 | 全组转 decode |
| 第一轮通过,第二轮本 rank 空手 | 本 rank 陪跑空 prefill |
| 第一轮通过,第二轮本 rank 排到 | 本 rank 正常 prefill |
4.4 请求入队时的角色差异
Scheduler::add_sequence 是请求进入等待队列的唯一入口,它内部有一处只在 NORMAL 引擎里生效的预分配:
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}reserve_output_tokens 预先给 token 缓冲预留 max_tokens 的空间,_block_table_capacity 按”整个序列长度 + 最大输出 + 一个块的余量”预留块表容量。两者都是 std::vector::reserve,只申请容量不改变大小。为什么只在 NORMAL 里做?
NORMAL引擎的请求从入队到结束一直在同一个调度器里,token 会一个接一个地追加到input.tokens,块表也会逐块生长;一次性预留够整个生成过程的空间,可以避免 decode 过程中反复realloc并暂停调度线程(std::sort/vector扩容发生在调度临界区内)。COMPLETION引擎的请求是交接进来的,块表在入队时已经被 PE 那边的布局钉过(见fork_seq的pin_blocks),由交接流程决定容量,不是调度器自己从零长起来的。PROMPT引擎的请求在前向完成后就转出去了,它只处理 prompt token,不需要为max_tokens预留输出空间。
这条差异很小,却是”同一引擎类、三种角色”设计里最典型的体现:某个优化只在某一角色下成立,就在角色上挂一个 if,而不是复制一份 Scheduler。
4.5 半途接手的序列:fork_seq 与 prompted 队列
CE 接到的请求不是从零开始的 prompt,而是”前缀已经算好、只差继续生成”的序列。调度器里专门有一条入口处理这种序列:
1SequencePtr fork_seq(const SequencePtr &seq, py::object blocks, std::string &&target_branch_id,2 py::object sampling_params, py::object so_matcher, int sp_token, int replace_last_token_id,3 int64_t seq_id, double arrival_time) {4 TORCH_CHECK(seq->cache.num_tokens > 0, "Cannot fork a sequence without a computed prefix");5 TORCH_CHECK(!sampling_params.attr("prompt_logprobs").cast<bool>(), "Forking a cached prefix does not support prompt logprobs");6 const bool prompt_incomplete = seq->cache.num_tokens < seq->get_len() - 1;7 ...8 if (prompt_incomplete) {9 waiting.emplace_front(fork_seq);10 } else {11 prompted.emplace_back(fork_seq, true);12 }13}几个细节值得展开。
prompt_incomplete 的判定是 num_tokens < get_len() - 1。 num_tokens 是 KV 已经算到的位置,get_len() 是序列当前 token 总数。如果 num_tokens == get_len() - 1,说明除最后一个 token 之外全部算完——这正好是”预填充完成、第一个输出 token 已由 PE 采样出来”的状态:最后一个 token 就是 PE 采出的首个输出 token,它没有自己的 KV(它刚被生成,还没作为 query 跑过),但它是有效的输入。于是 prompt_incomplete=False,序列进入 prompted 队列而不是 waiting。这与 DeepInfer 源码拆解(三十六):选行与采样输入 里”prefill 只采最后一行”的设计严丝合缝:PE 只需要为一个位置采样,CE 拿到的就是”前缀 + 首个输出 token”。
prompted 队列在 decode 调度里先于 waiting 被处理。 _schedule_decode 的注释写明”先把 prompted 队列中的 seq 移入 running,再按 LCFS 挑选本轮 decode 的 seq”:
1// prompted 队列头部的 seq 依次转入 running(或已结束的转入 finished),2// forked seq 需要先克隆 state 和最后一个(可能写了一半的)block。3while (!prompted.empty() && running.size() < max_seqs) {4 auto &&[seq, forked] = prompted.front();5 ...6 if (forked) {7 if ((state_manager && !state_manager->can_allocate_single_nolock()) || !block_manager->can_allocate_single_nolock()) {8 break;9 }10 _clone_state(*seq);11 int new_block = block_manager->allocate_single_nolock();12 ...13 }14}forked=true 的序列在转入 running 之前要做两件事:克隆状态槽(_clone_state)和复制最后一个可能写了一半的块。这两件事与 step() 里 clone_states / clone_blocks 的处理是同一套机制:_schedule_decode 把它们记进 SchedulerOutputs.clone_states 与 clone_blocks,step() 在调度之后、前向之前调用 Worker 的 clone_and_zero_states_and_blocks_on_default_stream 完成设备侧的复制。
blocks 参数允许外部传入块表。 fork_seq 的 blocks 可以是 None(沿用源序列的 block_table),也可以是一个显式的 int 数组。后者正是跨实例交接需要的形状:CE 上的块表由交接协议给定,而不是 CE 自己从零分配。传了 blocks 之后,fork_seq 会 TORCH_CHECK(!init.cache.block_table.empty(), "Cannot fork a sequence without allocated KV blocks")——没有块表就不允许 fork。
需要说明的是,fork_seq 在开源代码里主要服务于同实例内的分支/前缀复用场景(target_branch_id、replace_last_token_id 这些参数都指向分支管理),它并不等同于”P/D 交接协议”。但它是代码里唯一一个”接收已有前缀并把它送进解码队列”的入口,其形状与交接所需完全一致;未开源的 P/D 子类是否直接调用它、还是走一条等价的私有路径,从开源部分无法确认。这也是读这类”接口先行”代码时需要保留的边界:能确认的是结构与不变量,不能确认的是未公开实现的具体调用关系。
4.6 COMPLETION 引擎的一条硬约束
CE 的调度分支虽简单,但构造期有一条必须成立的约束:
1// COMPLETION engine 没有 prefill 调度通道,无法重算被抢占的请求。启动时拦截,禁止该配置组合2TORCH_CHECK(engine_type != EngineType::COMPLETION || stop_preempted_request,3 "COMPLETION engine cannot recompute preempted requests (no prefill channel); "4 "it must be deployed with stop_preempted_request=true (--stop-preempted-request)");当 KV 块不足时,调度器会把某个仍在运行的请求抢占。被抢占的请求有两种处理方式:终止(stop_preempted_request=true),或者放回 waiting 队首等待重新 prefill(stop_preempted_request=false)。CE 没有 prefill 通道,第二种方式的请求会永远留在 waiting 里,has_unfinished 恒为真,整个实例就被一条死请求拖住。这也解释了为什么 DeepInfer 的 CE 在线上部署里必须接受”显存不足就终止”的语义。关于抢占之后请求如何回收、取消如何跨实例传播,可以参考横向对比文章 请求的取消与抢占:五个推理框架为什么在同一个「停」字上,走出五条不同的回收路径。
还有一条与 CE 有关的断言写在 LLMEngine.__init__ 里:
1if self.parallel_config.context_parallel_size > 1:2 assert engine_type != EngineType.COMPLETIONCE 不能开上下文并行。原因与 CP 的语义有关:CP 把一个长序列的 attention 切成 zigzag 段分布到多 rank 上,每个 rank 只负责一段,KV 收集由 CP 组的 mirror 布局完成。对预填充来说,这可以显著降低单条长序列的 attention 开销;对解码来说,它不但没有收益,还会在每一拍引入跨 rank 的 KV 收集。而且 CP 的切分粒度是按序列的 token 位置切,解码时一个序列只有 1 个 query token,却要收集所有 rank 上的 KV,代价远大于收益。所以 CE 的角色是”用 Wide-EP 扩大并行、用整序列 KV 做 attention”,与 CP 的定位互斥。
P/D 分离的钩子:一组虚函数
5.1 虚函数清单
LLMEngine 在 init_pd_split 之前定义了一组中文注释为”虚函数”的回调。Python 里没有 virtual 关键字,这里的”虚函数”只是架构约定:一个基类提供空实现或默认实现,生产部署的 LLMEngine 子类(未在开源仓库里)重写它们。清单如下:
| 钩子 | 默认实现 | 调用位置 | 推测职责 |
|---|---|---|---|
init_pd_split() | pass | _init_cache() 里,engine_type != NORMAL 时 | 初始化 P/D 相关数据结构(交接队列、远端通道、KV 保存器) |
finalize_seq(seq) | scheduler.free_seq(seq) | pre_sampler_sync() 里,仅 COMPLETION/NORMAL 引擎 | 处理结束的请求;PE 不调用 |
post_schedule(outputs) | pass | step() 里,调度完成之后、模型执行之前 | 按调度结果做 PE/CE 侧的记账(例如把本次要交接的请求登记) |
should_copy_state(seq) | return False | step() 里,仅 decode 步 | 判断这个 seq 是否需要把当前状态复制到 GPU 副本区 |
do_save_kvcache(scheduled, finished) | pass | pre_sampler_sync() 里,仅 decode 步 | CE 侧的 KV 保存(写到磁盘或转给存储层) |
pe_prepare_save_kvcache(scheduled, finished) | pass | pre_sampler_sync() 里,仅 prefill 步 | PE 侧在采样同步前下发 KV 交接指令 |
pe_do_save_kvcache(scheduled, finished) | pass | post_process() 里,仅 prefill 步 | PE 侧采样后执行 KV 交接 |
send_processed_prompts() | pass | post_process() 里,仅 PROMPT 引擎 | 把处理完的 prompt sequence 发给 CE 并从调度器删除 |
表里八条是因为 should_copy_state 和 do_copy_state 配对,do_copy_state 不是虚函数,它是基类提供的一个具体实现,把 seq.state_id 对应的 GPU 状态槽复制到 copy_state 缓冲区。虚函数只决定”要不要复制”,具体怎么复制是基类的事。
5.2 为什么 PE 分成两个钩子
把 PE 的交接拆成 pe_prepare_save_kvcache(采样前)和 pe_do_save_kvcache(采样后)两段,是为了让交接的动作可以跨过采样 kernel。一次 prefill 步的 GPU 侧顺序大致是:
- 前向:注意力 + MoE + 写出 KV 到设备块表;
- 采样:对本步挑选的”最后一行” logits 下发采样 kernel,结果要等到事件同步后才可读;
- 采样结束事件。
pre_sampler_sync 在 2 的 kernel 下发之后、3 之前触发,此时 KV 已经把 1 产出的数据写在设备上,且 copy_state_event(如果记录过)已经同步。PE 可以在这个时点把”要交接的请求”打包、发起 D2G 或远端传输的启动指令——这一步只下发,不等 GPU 计算完。post_process 在 3 之后触发,_post_process_step 先 sample_end_evt.synchronize() 再调用引擎的 post_process_fn,pe_do_save_kvcache 才真正落实交接:等待传输完成、更新请求状态、记录交接的 KV 范围。
这样拆的收益是延迟:采样核本身不大,但 post_process 里的 Python 侧后处理开销不小(_process_model_outputs、RequestOutput 构造、日志),如果整段交接都在采样之后做,PE 的每拍就会有”GPU 等 CPU”的空窗。把交接的启动放进采样同步点之前,就让交接指令的下发与前向、采样在 GPU 上排队执行,而不是等采样同步后 CPU 再一条条发指令。
5.3 decode 侧的 copy_state
do_copy_state 是基类里一个完整的实现:
1def do_copy_state(self, seqs: list[Sequence]) -> None:2 """把给定 seq 当前的 GPU state 拷贝到 copy_state buffer,并更新 last_copy_state_token_idx。3
4 该方法仅下发任务到 GPU,不等待完成,消费方需自行同步。5 """6 if self.copy_state is None:7 return8 for seq in seqs:9 if seq.state_id != -1:10 state_id = seq.state_id11 self.copy_state[state_id].copy_(self.driver_worker.cache_engine.gpu_states[state_id])12 seq.last_copy_state_token_idx = seq.num_cached_tokens注释里点名”仅下发任务,不等待完成”。对应的同步点在 pre_sampler_sync:
1if not scheduler_outputs.prompt_run:2 if self.copy_state_event is not None:3 self.copy_state_event.synchronize()4 self.do_save_kvcache(scheduler_outputs.scheduled_seqs, scheduler_outputs.finished_seqs)也就是说,step() 里记录 copy_state_event,等到下一拍的 pre_sampler_sync 才同步。这样 do_save_kvcache 读到的一定是完整的副本,而不是半拷贝的状态。这个模式与官方文档对 CE 的 SWA KV 复制描述完全一致:每一次请求跨越 128 token 边界时,把当前窗口复制一份到设备侧的副本区;请求结束时,从副本区最近一次完成的拷贝中取存储——拷贝位置最多越过边界 5 个 token,由槽位环形缓冲多出的 6 个位置吸收。源码里的 copy_state / copy_state_event 正是这个”副本区 + 异步拷贝”实现的骨架。
5.4 结束请求的分叉
finalize_seq 的默认实现只有一句 scheduler.free_seq(seq),但它只在 COMPLETION/NORMAL 引擎里被调用:
1if self.engine_type in [EngineType.COMPLETION, EngineType.NORMAL]:2 for seq in scheduler_outputs.finished_seqs:3 self.finalize_seq(seq)PE 的结束请求不走这里。PE 的请求生命周期终点是”被交接出去”,而不是”完成生成”。PE 的 send_processed_prompts 在 post_process 的末尾被调用——注释是”把已经处理完的 prompt sequence 发送给 completion engine 并从 scheduler 中删除”。这意味着 PE 的请求是被”转走”而不是”释放”:KV 归属从 PE 的调度器转移到 CE 和 KV 存储系统,PE 自己的块表在交接完成后才能回收。
这与 CE 侧形成一条清晰的所有权边界:PE 在请求在途期间持有 KV 块;交接动作成功之后,块的所有权转移到 KV 存储系统或 CE 的块管理器;CE 从等待队列接走请求时,要重新建立自己的块映射。这条边界如果画在虚函数内部,就是 pe_do_save_kvcache 与 finalize_seq 的职责分界。
时序:预填充到解码的一次交接
把上面的钩子按时间顺序排开,可以得到一次 P/D 交接的时序:
1 PE (PROMPT) CE (COMPLETION)2 ─────────── ──────────────3 schedule → _schedule_prefill4 post_schedule(outputs)5 forward prefill (kernel 已下发;KV 写入设备块表)6 launch sample (kernel 已下发,尚未同步)7 ─── pre_sampler_sync ─── [采样结束事件仍在本步末尾记录]8 pe_prepare_save_kvcache(scheduled, finished)9 ├─ 打包要交接的请求10 └─ 下发 KV 传输 / 落盘指令(不等完成)11 _post_process_step:12 sample_end_evt.synchronize() ← 等前向与采样全部落地13 post_process14 pe_do_save_kvcache(scheduled, finished)15 ├─ 等待传输完成16 └─ 更新交接记录17 send_processed_prompts()18 └─ 请求转出,调度器删除19 [收到请求 + KV]20 add_request(enqueue=True)21 schedule → _schedule_decode22 decode step ...23 ─── pre_sampler_sync ───24 copy_state_event.synchronize()25 do_save_kvcache(...)26 ...这张时序图里有两处值得强调。
第一,交接的”数据面”和”控制面”是分开的。 pe_prepare_save_kvcache 发起数据面的传输,pe_do_save_kvcache 处理控制面的记账。数据面在采样同步点之前就下发,控制面在采样结果同步之后才收尾。如果两者合并成一个钩子,PE 就必须在 post_process 里既发传输又等传输,把整段传输延迟计入每一拍的临界路径。
第二,CE 侧的请求是从 add_request 进入的,不是从调度器内部”偷”进来的。 源码里 add_request 有一个 enqueue: bool = True 参数,注释是”是否加入 scheduler waiting 队列”。这个参数的存在,说明交接链路上可能会调用 add_request(enqueue=False) 先构造 Sequence(例如让 CE 先分配状态槽、建立块映射或登记接收缓冲),等 KV 到位后再显式入队。这比在调度器内部硬塞一个请求要清晰得多,也让错误处理有了下手的地方——可以在入队之前淘汰,而不需要在调度器运行中做回滚。
时序里还有一条隐含规则:PE 交接出去的请求,在 CE 侧第一次被调度时必须”视为纯 decode”。这约束了 CE 在准入时不能对交接来的请求重新计算 prompt:CE 没有 prefill 通道,如果交接来的请求只带了部分 KV(例如 SWA KV 缺一段),CE 无法自行补齐。这正是官方文档里”命中长度由块 KV 决定、末块是否重算由 SWA KV 决定,SWA 缺了就往回退一块重算”这条策略必须在 PE 侧完成的原因。PE 负责把 KV 补齐到可交接的状态,CE 负责在补齐的 KV 之上继续解码。
角色下沉:构造参数与模块行为
engine_type 不只是调度器的分支,它还通过 ParallelConfig.engine_type 向下游传播。下面逐层看它在下游改写了什么。
7.1 显存估算的 profile 形状
WorkerBase.profile_num_available_blocks 会调用 ModelRunner.profile_run(cache_config) 测量峰值显存。profile_run 里有一句:
1is_prompt = self.parallel_config.engine_type != EngineType.COMPLETION然后按 is_prompt 走不同的假序列形状:
- PE / NORMAL:按真实的 prefill 形状构造假序列,长队列的
seq_len_q分布、seq_len_k从 1 到max_model_len不等,让峰值显存覆盖完整的长 prompt 前向; - CE:每条假序列
seq_len_q = 1 + nextn,seq_len_k = 1 + nextn,seq_data_len = 1。也就是说 CE 的 profile 只测单 token 解码的峰值。
这样测出的峰值决定了 num_gpu_blocks,也就是这台机器能装多少 KV 块。CE 因为前向时激活量小(只有少量 token),可以装更多块;PE 因为激活量大,同一张卡能装的块就少。角色化后同一个模型、同一种硬件,PE 和 CE 算出来的 KV 容量会相差不少——这也是为什么 P/D 分离里 P 和 D 的 GPU 配比不是 1:1 的直接原因之一。
7.2 CUDA Graph 的角色差异
ModelRunner.__init__ 里:
1self.use_cuda_graph = not envs.DEEPINFER_ENFORCE_EAGER and self.parallel_config.engine_type != EngineType.PROMPTPROMPT 引擎默认关闭 CUDA Graph。原因在 DeepInfer 源码完全拆解(十二):CUDA Graph 捕获与重放 里讲过:图捕获的分档是按 batch size 划分的,decode 的 batch size 变化小、形状相对规整,捕获几百张图即可覆盖绝大多数场景;prefill 的”形状”由每条序列的 seq_len_q 分布决定,变化范围大,按批大小分档无法充分覆盖。对 PE 来说,图捕获的收益(把 kernel 调度开销压出去)不足以抵消一次 prefill 本身的计算量与捕获成本。
DFlash(DeepInfer 的投机草稿头)也有类似的角色判断:
1self.prefill_only = cfg.engine_type == EngineType.PROMPT2if self.prefill_only:3 logger.info("DFlash prefill_only 模式:只加载 main-cache 路径参数")草稿头在 PE 里只保留主 KV 的投影参数——因为草稿生成只在 CE 发生,PE 不需要草稿 KV 路径。这一条能直接省显存:DFlash 每层都要多一份草稿 KV 投影权重,prefill_only 模式下这些权重不物化。投机解码在 DeepInfer 里的完整链路可以参考 DeepInfer 源码完全拆解(五):投机解码。
7.3 MTP 信息池只在 CE 侧构造
ModelRunner.load_model 里:
1if self.is_driver_worker and self.parallel_config.engine_type != EngineType.PROMPT and self.model.MTP_module is not None:2 self.model.MTP_module.init_mtp_info_pool(self.scheduler_config.max_num_seqs)MTP 信息池是投机解码的槽位分配器:每个请求占用一个槽,存放草稿 token、置信度、接受状态等。PE 不会做投机验证,所以不分配这个池;CE 和 NORMAL 都分配,尺寸是 max_num_seqs。这也是角色差异的显存体现。
7.4 EPLB 重排的时刻
ExpertLoadBalancer 的构造函数接收 is_normal_engine 参数,并据此配置两个开关:
1self._schedule_rearrangement_after_prefill = is_normal_engine and envs.DEEPINFER_REARRANGE_AFTER_PREFILL_IN_NORMAL_ENGINE2self._skip_rearrangement_on_prefill = is_normal_engine and not self._schedule_rearrangement_after_prefill重排逻辑的核心是:
1def _update_rearrangement(self, is_prompt: bool) -> None:2 if self._rearrangement_interval == 0:3 return4 if is_prompt and self._skip_rearrangement_on_prefill:5 return6 ...7 if is_prompt and self._schedule_rearrangement_after_prefill:8 # 下一个非 prefill step 立即 rearrange。9 self._rearrangement_step = self._rearrangement_interval - 110 return11 self._rearrangement_step += 112 if self._rearrangement_step < self._rearrangement_interval:13 return14 self._rearrangement_step = 015 self._rearrange_experts()对 NORMAL 引擎,prefill 步不走重排计数,默认等到下一个 decode 步再重排;对 PROMPT / COMPLETION,is_normal_engine=False,两个开关都是 False,重排计数在每一步(无论是 prompt 还是 decode)都推进。原因很直接:NORMAL 引擎的 prefill 步通常会改变专家的热度分布(大批 prompt 触发的专家与 decode 触发的专家往往不同),如果在 prefill 步内做重排,会把 prefill 的时间进一步拉长;而 P/D 分离时,PE 和 CE 各自是纯粹的 prefill / decode 引擎,重排按自己的节拍走即可。EPLB 的放置策略参考 DeepInfer 源码完全拆解(四):MoE 专家并行与 EPLB。
7.5 MoE 与 Engram 的缓冲尺寸
MoE 层与 Engram 层都保存了 engine_type,据此选择缓冲尺寸:
1self.max_num_tokens = self.max_num_decoding_tokens if self.engine_type == EngineType.COMPLETION else self.max_num_prefilling_tokensconfig.py 里的定义是:
1max_num_decoding_tokens = max_num_seqs * (1 + envs.DEEPINFER_USE_NEXTN_DECODE)2return max(max_model_len, 4096, max_num_decoding_tokens)真正的定义在 model_loader.finalize_model_config 里:
1model_cfg.gpt.max_num_decoding_tokens = model_cfg.gpt.max_num_seqs * (1 + envs.DEEPINFER_USE_NEXTN_DECODE)2model_cfg.gpt.max_num_prefilling_tokens = model_cfg.gpt.max_num_batched_tokens // PG.context_parallel_group().size()max_num_prefilling_tokens 以 max_num_batched_tokens 为基数(默认至少 max_model_len),再按 CP 组大小整除——开了 CP 之后,一条序列的 prefill token 被切分到多个 CP rank 上,每个 rank 只看自己那一段。max_num_decoding_tokens 则是 max_num_seqs * (1 + nextn),因为 decode 一步最多处理 max_num_seqs 条序列、每条 1 + nextn 个 token(1 个 query + 最多 nextn 个草稿)。MoE 层的 workspace 不是一次性按角色定死,而是按 input_metadata.is_prompt 在每个批次上二选一:
1num_max_tokens_per_rank=self.max_num_prefilling_tokens if input_metadata.is_prompt else self.max_num_decoding_tokens,因此”引擎类型决定缓冲尺寸”这一条,严格说是 NORMAL 引擎里的保守上限——NORMAL 两类批次都会出现,取 prefill 上界;单独的 PE / CE 引擎取各自的上界。MoE 层本身的执行细节参考 DeepInfer 源码拆解(三十五):MoE 层的 MLP。
7.6 注意力层的融合路径
v4_modules/attn.py 里:
1self.use_fused_norm_rope_attn_rope_cast = (2 ((not envs.DEEPINFER_BATCH_INVARIANT and cfg.engine_type == EngineType.PROMPT) or self.is_sparse or not self.has_packed_kv)3 and self.num_heads in (64, 128)4 and PLATFORM.is_gpu5)融合 norm-RoPE 的路径只在三种情况之一开启:PE 引擎且非 batch-invariant;或注意力是稀疏的;或没有 packed KV。其中”PE 引擎”这一项的含义是:PE 默认不开 batch-invariant(若开了 DEEPINFER_BATCH_INVARIANT,即便角色是 PE 也要回退到分算路径)。batch-invariant 的推理结果对 batch 组成不敏感,在 Batch-invariant 推理完全拆解 里讲过它的代价是必须在 kernel 层面固定归约顺序;PE 只做前向、不产出用户可见 token,因此默认不需要这条约束,融合 kernel 就可以把 RMSNorm、RoPE 的写回省掉一次。CE 与 NORMAL 引擎只有在该层是稀疏注意力或没有 packed KV 时才例外启用融合。融合 kernel 的实现参考 DeepInfer 源码拆解(三十二):fused_transform。
横向对比:DeepInfer 与另外四个框架的角色切分
P/D 分离在各框架里都有实现,但角色切分的方式差异很大。下表对照 DeepInfer 与 vLLM、SGLang、TensorRT-LLM、llama.cpp 在角色概念上的差别:
| 维度 | DeepInfer | vLLM | SGLang | TensorRT-LLM | llama.cpp |
|---|---|---|---|---|---|
| 角色表示 | EngineType 枚举,单类分支 | 配置 + Connector 双角色 | 独立的 dispatcher/prefill/decode 进程角色 | 调度器角色 + 引擎配置 | 无 P/D 分离 |
| 是否同一类 | 同一 LLMEngine | 同一 LLMEngine | 同一 Scheduler 不同 Role | 同一调度器不同策略 | — |
| 混合引擎 | NORMAL 一等公民 | 默认 | 默认 | 默认 | 唯一模式 |
| 步型一致性 | DP 组内强制一致(投票) | 各 DP rank 独立 | 各 DP rank 独立 | 各实例独立 | — |
| 结束请求回收 | PE 转出 / CE 释放 | Connector 调度 | Dispatcher 回收 | 请求迁移 | — |
| 交接点 | 虚函数(预处理后、采样后) | KVConnector 的 save/load | kv_transfer 通道 | 独立传输层 | — |
DeepInfer 的独特之处有三点。
其一,角色不新增类,只新增分支。 vLLM 的 P/D 分离需要引入 KVConnector 体系(参考 vLLM 源码完全拆解(十七):KV Connector),SGLang 需要引入 dispatcher 进程,TensorRT-LLM 需要独立的传输层;DeepInfer 只用一个枚举 + 一组虚函数,其余全部复用。这一设计让”混合引擎”和”分离部署”共用同一套代码,测试覆盖也共用;代价是基类里散落着很多 if engine_type ...,读代码时需要时刻记住当前实例的角色。
其二,步型一致性是 NORMAL 的特有复杂度。 因为 DeepInfer 默认不做张量并行,DP 组内的 MoE 通信是 All-to-All,必须全组同型。vLLM 和 SGLang 的 MoE 有张量并行作为兜底,DP rank 之间可以独立步型。DeepInfer 用三层 DP 投票换来了这套设计,也把”混合引擎”的调度复杂度提高了一个量级——这也是生产部署里干脆把 P 和 D 拆成独立实例的原因。
其三,交接钩子被设计成”半开”状态。 虚函数空实现,注释明确写了它们的位置,step() 里所有调用点也已经就位,只是子类没有开源。这比”完全没实现”更容易被误读——只看 LLMEngine 会觉得 P/D 分离已经跑通,实际上缺的是子类里的 KV 传输实现和 PE/CE 之间的请求迁移协议。官方 README 的表述也印证了这一点:
Required for a complete inference service (not included in this release):
- A KV cache system
- VE/Prefill/Decode disaggregation (most required engine functionality is already implemented)
- Load balancing across data-parallel replicas for both prefill and decode
“most required engine functionality is already implemented” 指的正是本节列出的这些钩子与分支;“not included” 指的是 KV 存储子系统、跨实例的请求路由与负载均衡。
为什么解码侧要 Wider:一张官方基准图
官方文档给出了一张 EP8 与 EP64 的解码吞吐对比图,用来解释 Wide-EP 对解码的收益:

图片来源:DeepInfer 官方文档 doc/inference_deployment_qa.md。
这张图与角色切分的联系在于:CE 的 EngineType.COMPLETION 之所以能在 EP64 这种规模下工作,是因为它的步型是纯 decode,所有 rank 都在同一拍处理”每个请求 1 + nextn 个 token”的小批量。MoE 路由出的专家 token 数虽少,但均匀分到 64 个 GPU 上之后,每个 GPU 的专家权重搬运量被压到最小;而且 EP64 的专家冗余度更高,EPLB 的重排间隔可以拉到 10 000 步(预填充只拉到 1 000 步),因为解码的专家热度分布比预填充更稳定。
反过来说,PE 侧的 EP 只到 EP2–EP8(文档原话”use the smallest EP/DP that HBM allows”)。PE 的一步是完整的长 prompt,即使只用 EP2,每个 GPU 上的专家 token 数也已经足够多,再扩 EP 只是增加了通信。所以同一个模型在 P 侧和 D 侧选择完全不同的 EP 规模,而这对引擎代码来说,只是在 ParallelConfig 里传不同的 expert_parallel_size——角色枚举本身并不感知 EP 大小。
把 P/D 两侧的配置差异整理成一张表:
| 配置项 | PE (PROMPT) | CE (COMPLETION) | 依据 |
|---|---|---|---|
| EP / DP | EP2–EP8 / DP2–DP8 | EP64–EP128 / DP64–DP128 | 预填充每步 token 多、解码每步 token 少 |
| chunked_prefill | 建议 True | 不适用 | PE 需批量吸收多请求 |
| max_num_seqs | 按 EP 与 KV 容量 | 按并发目标 | 解码的并发是主要吞吐变量 |
| CUDA Graph | 默认关 | 默认开 | prefill 形状大、decode 形状规整 |
| ZOS | 默认关 | 默认开 | prefill 每步后处理体量大,延后一拍收益低 |
| MTP 信息池 | 不分配 | 分配 | 投机只在解码侧 |
| 多模态池 | 分配 | 不分配 | 图像编码只在预填充侧 |
| CP | 可开 | 禁止 | 解码每步只有 1 个 query token |
stop_preempted_request | 可关 | 必须 True | CE 无 prefill 通道 |
这张表就是本篇的骨架:角色枚举本身只有 3 个值,但它向下改写了几十个默认值与分支。读 DeepInfer 源码时,遇到”为什么这里有一个 engine_type != ... 的判断”,只要回头看这张表就能对上。
预填充侧的另一张基准图
解码侧的 Wide-EP 图说明了 CE 的并行规模从哪里来;预填充侧也有对应的 pareto 图,官方文档给出的口径是”非缓存输入的吞吐 vs 延迟”:

图片来源:DeepInfer 官方文档 doc/inference_deployment_qa.md。
这张图对应的角色是 EngineType.PROMPT。它和上一张解码图的读法不同:
- 横轴上的”延迟”是单条请求的预填充时延,不是解码的 token 间隔。 预填充的延迟由”这条请求的 prompt 有多长、分几个 chunk、排队等了多久”决定,PE 侧做负载均衡时按上下文长度分桶,正是因为延迟对长度极其敏感。
- 吞吐由每步处理的 token 总量决定。 PE 的一步可以把多条 prompt 拼成一个大 batch(开 chunked prefill 时还包括长 prompt 的尾块),所以它的吞吐曲线在 batch 足够大时接近硬件的算力上限,而不是像解码那样受限于权重搬运带宽。
- 曲线的拐点来自 KV 容量与
max_num_seqs。 当批次继续增大、KV 块不够时,PE 只能拒绝新请求或等待;曲线的平台段就是”显存装不下了”。
这也解释了为什么 PE 的默认配置里 CUDA Graph 和 ZOS 都是关的:预填充的每一步都是大块计算,kernel 启动开销在毫秒级的前向上占比很小,捕获图带来的收益不足以覆盖形状多样性;而 ZOS 的”延后一拍”重叠对 prefill 没有意义——PE 的瓶颈是算力,不是 CPU 调度。
启动一条 P/D 链路:顺序上的角色差异
把构造函数与初始化路径按顺序走一遍,可以看出角色在什么时候被”冻结”进系统。下表按 LLMEngine.__init__ 与 _init_cache() 的实际顺序排列:
| 阶段 | 代码位置 | NORMAL | PROMPT | COMPLETION |
|---|---|---|---|---|
| 参数默认值 | EngineArgs.__post_init__ | ZOS 默认开、多模态默认开 | ZOS 默认关、多模态默认开 | ZOS 默认开、多模态默认关 |
| 引擎命名 | LLMEngine.__init__ | NORMAL_<gpu> | PROMPT_<gpu> | COMPLETION_<gpu> |
| 多模态处理器 | mm_enabled | 构造 | 构造 | 不构造 |
| Worker 启动 | _init_workers | 传 engine_type | 传 engine_type | 传 engine_type |
| 显存 profile | WorkerBase.profile_num_available_blocks → ModelRunner.profile_run | prefill 形状 | prefill 形状 | decode 形状 |
| 模型加载 | ModelRunner.load_model | 分配 MTP 池 | 不分配 MTP 池 | 分配 MTP 池 |
| 缓存引擎 | _init_cache → init_cache_engine | 无 P/D 初始化 | init_pd_split() | init_pd_split() |
| CP 约束 | _init_cache 之后 | 允许 CP | 允许 CP | 禁止 CP |
| 调度器构造 | scheduler_config.engine_type | 0 | 1 | 2 |
| 预热 | warm_up_model | 捕获 CUDA Graph | 跳过 | 捕获 CUDA Graph |
有两个顺序细节值得注意。
第一,init_pd_split() 发生在 init_cache_engine 之后、warm_up_model 之前。 也就是说 P/D 子类拿到的是一个已经分配好 KV 池、但还没有捕获 CUDA Graph 的引擎。这个顺序是有讲究的:init_pd_split 里如果要为 KV 交接分配额外的缓冲或注册内存,必须发生在 CUDA Graph 捕获之前,否则这些缓冲不会出现在图的捕获范围里,后续在图重放期间使用会触发非法访问或隐式同步。warm_up_model 里 if self.model_runner.use_cuda_graph: 的分支也说明,PE 根本不进捕获路径,所以 PE 的 init_pd_split 不受这条约束;CE 则必须把交接相关的设备缓冲在图捕获前注册好。
第二,engine_type 传给 Worker 的时间早于调度器构造。 _init_workers 在 _init_cache 之前就把 engine_type 拷进了 ParallelConfig,所以 profile_run、load_model 拿到的都是最终角色;调度器却是最后才通过 scheduler_config.engine_type = engine_type.value 拿到它。两条路径各拿各的副本,任何一方改角色都不会自动同步到另一方——这也是为什么两侧都要在启动时打印角色名,出了配置事故可以从日志里对齐。
角色化设计的七条不变量
把全文涉及的约束收拢,可以得到一份”角色化设计的不变量清单”。这些不变量不是文档里写的,而是从代码里的 TORCH_CHECK、assert、if engine_type 和注释中归纳出来的;它们共同定义了三种角色不能越过的边界。
| 不变量 | 代码位置 | 违反后的后果 |
|---|---|---|
| Python 与 C++ 的枚举值必须逐项对齐 | engine_type.py 与 scheduler.hpp 的注释 | NORMAL 被当成 PROMPT,整组步型错误 |
PROMPT 引擎的每一步必须是 prefill | step() 里的 assert scheduler_outputs.prompt_run | PE 静默丢失一步 KV 交接 |
COMPLETION 引擎不允许重算被抢占的请求 | Scheduler 构造函数的 TORCH_CHECK | 请求永久滞留 waiting,实例被拖死 |
COMPLETION 引擎不允许开 CP | LLMEngine.__init__ 的 assert | 解码步每拍都要跨 rank 收集 KV,得不偿失 |
PROMPT 引擎不调用 finalize_seq | pre_sampler_sync 的角色判断 | KV 未交接完就把块还池,踩内存 |
decode 状态的副本必须在 do_save_kvcache 前同步 | pre_sampler_sync 里的 copy_state_event.synchronize() | 保存到写了一半的状态 |
PROMPT 空步必须返回空 prompt_run 结果,而不是 nullptr | _schedule_prefill 的返回分支 | 上层误转 decode,与 PE 职责冲突 |
这七条里,第二条到第四条有运行期断言兜底,第一条只有 Python 与 C++ 两侧的注释约定;后三条没有断言兜底,只由代码结构与调用顺序保证。对于读源码的人来说,后三条更值得注意:一旦被违反,症状往往是”性能莫名其妙变差”或”偶发结果不一致”,而不是立刻崩溃。P/D 子类在重写钩子时,最需要小心的是第五条——finalize_seq 默认实现会释放块,但如果 PE 侧误调它,块的所有权会在 KV 交接完成之前被交还,后续的交接就会读到已被复用的块。
局限与开源的边界
把 LLMEngine、Scheduler、Worker、ModelRunner 这些模块的源码读完,P/D 分离的”接口层”已经相当清楚;但真正让 P/D 分离跑起来还缺三块。
其一,KV 传输的实现不在开源里。 do_save_kvcache、pe_do_save_kvcache 都是空实现,KV 从 PE 到 CE 的传输路径、协议和错误处理都在未开源的子类里。从虚函数的签名可以推断出一些线索:do_save_kvcache(scheduled, finished) 接收的是本次调度的 seq 列表与本次结束的 seq 列表,说明保存粒度是”按请求、按步”;pe_prepare_save_kvcache 和 pe_do_save_kvcache 接同样的参数,说明 PE 侧保存的也是”本步调度到的请求”。请求迁移靠 add_request(..., enqueue=False) 从 CE 侧重新构造,可以推断迁移是在进程外完成的。
其二,负载均衡与路由不在开源里。 调度器负责”决定这一步跑谁”,但请求到达哪台 PE、哪台 CE,是更上层的入口服务决定的。官方文档描述了四类负载均衡器(Pre/Vision/Decode/EP 负载均衡),也给出了预填充按上下文长度分桶、解码按”请求数 + 总 KV 长度”双维度路由的算法,但这些都不在 deepinfer/ 目录下——entrypoints/llm.py 只是离线推理入口。
其三,交接的调用契约没有可验证的样例。 fork_seq 提供了”接收已有前缀”的形状,但它的参数(target_branch_id、replace_last_token_id)明显是为同实例分支服务的;开源仓库里没有任何 P/D 子类或交接测试,无法确认 PE 交出的请求究竟怎样被 CE 接住、块表以什么格式传入、失败时如何回滚。能确认的只有接口:init_pd_split 在 KV 池就绪后调用、pe_prepare_save_kvcache 在采样同步前下发、pe_do_save_kvcache 在采样同步后收尾、send_processed_prompts 在 post_process 末尾转出。把这些调用点当作约束去读子类实现,比把开源代码当成完整系统更容易避免误判。
从源码角度看,DeepInfer 的 P/D 分离设计是”接口先行”:把角色枚举、虚函数、调用点、配置改写全部摆在开源代码里,把与内部基础设施强绑定的 KV 存储和路由隐藏起来。对读者来说,看懂 engine_type 的分支与钩子调用位置,基本就能理解 DeepSeek 的生产推理系统里 P/D 两端的引擎如何协作。
小结
EngineType有三个值:NORMAL、PROMPT(PE)、COMPLETION(CE)。Python 与 C++ 两侧的值必须一致,因为前者把int值传给后者。- 角色在构造期改写两个环境变量:
PROMPT默认关 ZOS、COMPLETION默认关多模态池。PROMPT默认不开 CUDA Graph、MTP 信息池和大量解码侧缓冲,原因都是”每步承担的是大块 prefill”,解码相关的机制在 PE 上没有用武之地。 - 调度器三种角色三套分支;
NORMAL需要三层 DP 投票来保证全组同型步,PROMPT返回空 prefill 结果而不是nullptr,COMPLETION必须stop_preempted_request=true且禁止开 CP。 - 八条 P/D 钩子分散在
_init_cache、step、pre_sampler_sync、post_process四个时点。PE 的交接拆成采样前”准备”与采样后”落实”两段,让交接启动与采样 kernel 重叠。 - 角色继续下沉到显存估算的假序列形状、CUDA Graph、DFlash 的预填充模式、MTP 信息池、EPLB 重排时刻、MoE/Engram 缓冲尺寸、注意力融合路径。
- 开源版本实现了接口层,缺的是 KV 传输实现、请求路由与四类负载均衡器;官方文档的 E/P/D 分离架构图正是由这些缺失部分补齐的完整形态。
参考资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



