DeepInfer
相信的心就是你的力量。
1.总览
DeepInfer主要支持DeepSeek - V4.1 - Flash
高性能 Attention / MoE Kernels
Attention DP\ MoE EP and Expert Load Balancing
Dspark Speculative Decoding and mobile verification
Sliding- Window Attention State recovery
Batch- invariant Inference and Deterministic Sampling
局限性: Deepinfer 并不是完全开放的Online Inference Serving. ReadMe says that the complete serbing needs KV Cache ... etc.
本Markdown将以一个请求为例,自顶向下来分析。
注意:本blog分析去掉了大量的多模态代码和多模态的分析
2.STAGE1. 一条请求怎么穿过DeepInfer
file: examples/offline_inference.py
1 2 3 4 5 6 outputs = llm.generate( prompts = prompts, sampling_params = sampling_params, multi_modal_data = multi_modal_data, use_tqdm = False , )
LLM.generate() 内部会给每个Prompt添加请求,并循环执行 LLMEngine.step() 直到请求完成。
调用链大概如下
LLM.generate() 离线入口,Tokenize、 创建请求
LLMEngine.add_request() 构建Sequence 把请求交给Scheduler
Scheduler.schedule() 选择本轮请求,Prefill/Decode 类型,KV Blocks
Worker.execute_model_step() 准备Tensor,执行Model Forward and sampling
process_outputs() 处理接受结果,更新Sequence,输出Tokens
主要源码(我手动删掉额外处理之后的 精简版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def generate ( self, prompts: str | list [str ] | None = None , sampling_params: SamplingParams | list [SamplingParams] | None = None , prompt_token_ids: list [list [int ]] | list [np.ndarray[np.int32]] | None = None , use_tqdm: bool = True , multi_modal_data: dict | list [dict ] | None = None , ) -> list [list [RequestOutput] | str ]: num_requests = len (prompts) if prompts is not None else len (prompt_token_ids) for i in range (num_requests): prompt = prompts[i] if prompts is not None else None token_ids = None if prompt_token_ids is None else prompt_token_ids[i] self ._add_request( prompt, sampling_params if isinstance (sampling_params, SamplingParams) else sampling_params[i], token_ids, None if multi_modal_data is None else multi_modal_data[i], ) return self ._run_engine(use_tqdm)
根据里面的注释,里面有写
"For the performance, put all of your prompts into a single list and pass it to this method."
也就是说这个函数希望接受一个直接的列表prompt.
函数的开头先做错误处理,随后是把str转换为list格式
剩下的不多说,然后看下面
函数会逐个从prompts里面提取prompt 和 token_ids , 然后调用requset函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def _add_request ( self, prompt: str | None , sampling_params: SamplingParams, prompt_token_ids: list [int ] | np.ndarray[np.int32] | None = None , multi_modal_data: dict | None = None , ) -> Sequence : sampling_params = copy.deepcopy(sampling_params) request_id = str (next (self .request_counter)) if prompt_token_ids is None : prompt_token_ids = self .llm_engine.tokenizer.encode(prompt) prompt_token_ids = np.asarray(prompt_token_ids, dtype=np.int32) return self .llm_engine.add_request( user_id="0" , request_id=request_id, sampling_params=sampling_params, prompt_token_ids=prompt_token_ids, multi_modal_data=multi_modal_data, )
add_request 是一个辅助函数,用于llm的generate向engine里面的中间搭桥.
这个函数对prompt进行tokenize,然后把prompt-token id进行构建,然后调用engine类的构建request.
2.2 llm_engine.py
这个函数把request 加入到 request pool 里面,随后被scheduler 使用engine.step() 处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 @cpu_timer.record("add_request" ) def add_request ( self, /, user_id: str , request_id: str , sampling_params: dict | SamplingParams | None , prompt_token_ids: np.ndarray[np.int32], multi_modal_data: dict | None = None , arrival_time: float | None = None , extra: dict | None = None , enqueue: bool = True , chunk_checkpoints: list [int ] | None = None , ) -> Sequence | None : """Add a request to the engine's request pool. The request is added to the request pool and will be processed by the scheduler as `engine.step()` is called. The exact scheduling policy is determined by the scheduler. Args: request_id: The unique ID of the request. prompt_token_ids: 1-D np.ndarray[np.int32]. String prompts must be tokenized by the caller beforehand. sampling_params: The sampling parameters for text generation. arrival_time: The arrival time of the request. If None, we use the current monotonic time. Details: - Set arrival_time to the current time if it is None. - Create a :class:`~deepinfer.Sequence` object - Add the :class:`~deepinfer.Sequence` object to the scheduler. Example: >>> # initialize engine >>> engine = LLMEngine.from_engine_args(engine_args) >>> # set request arguments >>> example_prompt = "Who is the president of the United States?" >>> sampling_params = SamplingParams(temperature=0.0) >>> request_id = 0 >>> >>> # add the request to the engine >>> engine.add_request( >>> user_id="user", >>> request_id=str(request_id), >>> sampling_params=sampling_params, >>> prompt_token_ids=np.asarray(engine.tokenizer.encode(example_prompt), dtype=np.int32)) >>> # continue the request processing >>> ... """
这个函数里面各种处理太多,我就不放完整的函数了。
这个函数的主要工作是接受llm.py模块里面的传达的请求。
在函数的最后, 他们使用了一个
1 2 3 4 5 6 7 8 9 10 11 12 if enqueue: def _enqueue (s: Sequence ) -> None : if s.so_matcher is not None and s.so_matcher.is_aborted(): s.status = SequenceStatus.FINISHED_SO_FAILED self .scheduler.add_sequence(s) if self .multi_modal_processor is not None : self .multi_modal_processor.add(request_id, multi_modal_data) if so_pending: self .so_matcher_builder.submit(seq, sampling_params.so_args, _enqueue) else : _enqueue(seq)
的内部函数。
通过把之前的sequence传入,并通过add_sequence来加入scheduler里面等待调用。
step()
step函数进行一次inference流程。返回np.ndarray装着的token ids.
(同样的,这个代码很长,我去除了所有的log、 timer、错误处理等代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 def step (self ) -> np.ndarray[np.uint64]: seq_metadata_list, scheduler_outputs = self .scheduler.schedule( self .sequence_metadata_pool ) clone_states = scheduler_outputs.clone_states clone_blocks = scheduler_outputs.clone_blocks zero_states = scheduler_outputs.zero_states if clone_states.size > 0 or clone_blocks.size > 0 or zero_states.size > 0 : to_release, to_release2 = self ._run_workers( "clone_and_zero_states_and_blocks_on_default_stream" , clone_states=clone_states, clone_blocks=clone_blocks, zero_states=zero_states, ) self .deferred_release.collect_clone_unpins(to_release, to_release2) self .post_schedule(scheduler_outputs) request_outputs = self ._execute_model_async_step( seq_metadata_list, scheduler_outputs ) self .generate_step += 1 return request_outputs
函数首先调用scheduler里面的schedule方法,并传入调度尺里面的元数据。
在调度指挥,其返回的output里面有clone的状态,准备其抢占和清理。再准备好之后,把这些东西传入给_run_worker函数,让其开始执行。随后执行调度后处理。
在workers处理好之后,状态在克隆和清零之后,开始执行model。
随后返回output.
_execute_model_async_step()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 def _execute_model_async_step ( self, seq_metadata_list: list [SequenceMetadata], scheduler_outputs: SchedulerOutputs ) -> np.ndarray[np.uint64]: request_outputs = self ._run_workers( "execute_model_step" , driver_kwargs=dict ( seq_metadata_list=seq_metadata_list, scheduler_outputs=scheduler_outputs, ), is_prompt=scheduler_outputs.prompt_run, is_placeholder=scheduler_outputs.is_empty(), ) self .deferred_release.collect_request_outputs(request_outputs) return request_outputs
这个函数通过异步执行推理,同driver_kwargs封装sequence metadata 和 scheduler的反馈。
_run_workers()
1 2 3 4 5 6 7 8 9 def _run_workers ( self, method: str , driver_kwargs: dict [str , Any ] | None = None , **kwargs: Any , ) -> Any : if driver_kwargs is None : driver_kwargs = {} return self .driver_worker.run_workers(method, driver_kwargs, **kwargs)
_run_workers 函数最后调用driver_worker的这个函数,把事情继续往下传。
driver workder不再细讲,其牵扯到一个全新的外部类,driver worker会创建一个class worker,里面接受engine type, model config parallel策略等参数
2.3 scheduler.hpp
_schedule()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 std::unique_ptr<SchedulerOutputs> _schedule() { py::gil_scoped_release release; BlockManagerMutexAcquire acquire{block_manager, state_manager}; clone_states.clear (); zero_states.clear (); clone_blocks.clear (); tokens_chunked_prefill_remaining = 0 ; switch (engine_type) { case EngineType::PROMPT: return _schedule_prefill(acquire); case EngineType::COMPLETION: return _schedule_decode(acquire); case EngineType::NORMAL: { bool is_any_point = (decode_step != 0 ) && (decode_step % prefill_any_attempt_interval == 0 ); bool is_all_point = (decode_step % prefill_attempt_interval == 0 ); if (!is_any_point && !is_all_point) { return _schedule_decode(acquire); } bool want = (get_gpu_cache_usage () <= GPU_CACHE_PREFILL_WATERMARK) && _has_prefill_waiting(); bool do_prefill; if (is_any_point) { do_prefill = (_dp_allreduce(want ? 0 : 1 , c10d::ReduceOp::MIN) == 0 ); } else { do_prefill = (_dp_allreduce(want ? 1 : 0 , c10d::ReduceOp::MIN) == 1 ); } if (!do_prefill) { return _schedule_decode(acquire); } std::unique_ptr<SchedulerOutputs> outputs; if (want) { outputs = _schedule_prefill(acquire); } bool any_scheduled = (_dp_allreduce(outputs ? 0 : 1 , c10d::ReduceOp::MIN) == 0 ); if (!any_scheduled) { return _schedule_decode(acquire); } if (outputs) { return outputs; } return _make_outputs({}, true , 0 , {}, {}, {}, 0 ); } } TORCH_CHECK (false , "unknown engine_type " , int (engine_type)); return nullptr ; }
schedule函数接受 py那边传过来的状态。
通过swith函数,决定执行模式。
PROMPT -> 调用 prefill 引擎
COMPLETION -> 调用decode
NORMAL引擎 则是可以借鉴这篇文章:
Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve](https://arxiv.org/abs/2403.02310 )
在Prefill之间和Decode之间做抉择。
同一个Step并不会混合执行Prefill和Decode,因为Deepseek的生产架构本身是采用Prefill和/ Decode Disaggregation 采用不同的P D 分别独立运行在独立的实例,未必在同一个GPU Batch 内混合两类工作。
具体的内部的选择的话,这里不过多展开叙述,读者可以认为函数内部采用了类似dp投票的方式,选择pre_fill和decode在不同的地方的执行的一个比例。从而尽量减少bubble.
_schedule_prefill()
我仍然只保留了核心的业务逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 std::unique_ptr<SchedulerOutputs> _schedule_prefill(BlockManagerMutexAcquire &acquire) { std::vector<SequencePtr> scheduled; std::vector<SequencePtr> ignored_seqs; std::vector<int > seqlens_q; int num_curr_seqs = running.size (); int num_batched_tokens = 0 ; int prefill_sum_seqlen_k_remain = prefill_max_sum_seqlen_k; while (!waiting.empty ()) { auto &seq = waiting.front (); if (!seq->schedule.schedulable) { break ; } int prompt_len = seq->get_len (); int cached_tokens = seq->cache.num_tokens; int workload = prompt_len - cached_tokens; if (seq->cache.block_table.empty ()) { if (prompt_len > prompt_limit) { seq->schedule.status = SequenceStatus::FINISHED_IGNORED; ignored_seqs.push_back (seq); waiting.pop_front (); continue ; } auto alloc_status = _can_allocate_with_state( seq->get_num_blocks () ); if (alloc_status == AllocStatus::LATER) { break ; } if (alloc_status == AllocStatus::NEVER) { seq->schedule.status = SequenceStatus::FINISHED_IGNORED; ignored_seqs.push_back (seq); waiting.pop_front (); continue ; } } if (num_curr_seqs + 1 > max_num_prompts) { break ; } if (prefill_sum_seqlen_k_remain < prompt_len) { break ; } int available_tokens = (max_num_batched_tokens - num_batched_tokens) / cp_align * cp_align; int seqlen_q = std::min (workload, available_tokens); bool is_chunked = seqlen_q < workload; if (is_chunked && !chunked_prefill) { break ; } if (seqlen_q < std::max (lookahead_tokens, 1 )) { break ; } prefill_sum_seqlen_k_remain -= prompt_len; num_batched_tokens += (seqlen_q + cp_align - 1 ) / cp_align * cp_align; if (seq->cache.block_table.empty ()) { acquire.allocate_seq (*seq); } if (!is_chunked) { ++num_curr_seqs; seq->schedule.status = SequenceStatus::RUNNING; allocate_mtp_info_slot (*seq); } scheduled.push_back (seq); seqlens_q.push_back (seqlen_q); seq->schedule.chunked_rest_len = is_chunked ? workload - seqlen_q : 0 ; if (is_chunked) { tokens_chunked_prefill_remaining += prompt_len; } waiting.pop_front (); if (is_chunked && engine_type == EngineType::NORMAL) { break ; } } if (engine_type != EngineType::NORMAL || !scheduled.empty () || !ignored_seqs.empty ()) { return _make_outputs( std::move (scheduled), true , num_batched_tokens, {}, std::move (ignored_seqs), std::move (seqlens_q), 0 ); } return nullptr ; }
看完这段代码,很适合用来理解 LLM Serving种的 Admission Control 准入控制和 Chunked Prefill Scheduling 分块预填充调度。
首先给出结论,就是这个Serving是一个FIFO的队列,支持分块prefill,优先从Waiting Queue 中接纳请求,资源不足的时候截断prompt.
首先我们根据这个表格看一下一个scheduler受到哪些limits.
资源
对应变量
调度含义
KV cache blocks
_can_allocate_with_state()
能否分配Kv Cache blocks
并发数
max_num_prompts
最多接纳多少个运行请求
总kv 长度预算
prefill_max_sum_seqlen_k
本轮接纳请求的总序列长度限制
batch Token Budget
max_num_batched_tokens
本轮最多执行的token数目
核心设计:Chunked Prefill :
我们可以假设现在有请求 A B C
A 1000 tokens
B 800 tokens
C 2000 tokens
And now the budget is 2048 tokens.
So , we will use the full A and B , while using only 248 tokens in C.
在调度器中
1 2 int available_tokens = max_num_batched_tokens - num_batched_tokens;int seqlen_q = std::min (workload, available_tokens);
在这个地方有一个类似priority_map 中惰性二项堆的设计,就是他们会首先检查所有的调度能否完成,能否模拟,最后再进行调度。
因为回滚的代价非常之大。
_schedule_decode
精简之后的decode调度逻辑如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 std::unique_ptr<SchedulerOutputs> _schedule_decode(BlockManagerMutexAcquire &acquire) { const size_t max_seqs = max_num_seqs; while (!prompted.empty () && running.size () < max_seqs) { auto seq = std::move (prompted.front ().first); prompted.pop_front (); seq->schedule.status = SequenceStatus::RUNNING; allocate_mtp_info_slot (*seq); running.push_back (std::move (seq)); } std::sort ( running.begin (), running.end (), [](const SequencePtr &a, const SequencePtr &b) { return a->get_output_len () > b->get_output_len (); } ); std::vector<SequencePtr> scheduled; std::vector<SequencePtr> ignored_seqs; size_t l = 0 , r = running.size (); while (l < r && scheduled.size () < max_seqs) { auto &seq = running[r - 1 ]; while (!block_manager->can_allocate_single_nolock () && r - l > 1 ) { auto victim = running[l]; _preempt(victim, ignored_seqs); ++l; } if (block_manager->can_allocate_single_nolock ()) { _append_slot(*seq); scheduled.push_back (std::move (seq)); } else { _preempt(seq, ignored_seqs); } --r; } running.clear (); std::sort ( scheduled.begin (), scheduled.end (), [](const SequencePtr &a, const SequencePtr &b) { return a->get_len () > b->get_len (); } ); std::vector<int > seqlens_q; _mtp_varlen.schedule (scheduled, seqlens_q); int num_batched_tokens = 0 ; for (int q : seqlens_q) { num_batched_tokens += q; } return _make_outputs( std::move (scheduled), false , num_batched_tokens, {}, std::move (ignored_seqs), std::move (seqlens_q), 0 ); }
调度的优先逻辑依靠目前已经生成的token数目,并且token数目越少,越优先获得KV Cache。
KV Cache不足的时候,会通过抢占的方式实现资源竞争。
1 2 3 4 5 while ( !block_manager-> can_allocate_single_nolock () && r - l > 1 ){ auto victim = running[i]; _preempt(victim, ignored_seqs); ++l; }
它的含义是
尝试为当前的高优先级分配cache slot
如果没有可用block,抢占低优先级请求
查看资源是否足够
资源足够就执行 _append_slot()
如果最终任然不足,当前请求也抢占。