DeepInfer Explained

Offline API and the step loop

IntermediateDeepInfer conceptsabout 9 min read + 7 min deep dives

Code version: DeepInfer @ 9bd9cf2 · 2026-10-21

In one sentence

LLM.generate() adds all requests, then calls LLMEngine.step() again and again. Each step runs one batch and returns the outputs that are ready.

The idea#

An LLM makes one token at a time for each request. DeepInfer serves many requests together, so it works in rounds. One round is an engine step. In each step, the engine selects a batch of requests and runs the model once for the whole batch. Then it adds new tokens to each request in the batch.

The offline API is the class LLM in deepinfer/entrypoints/llm.py. It has one main method, generate(), which does three things:

  1. It adds every prompt to the engine as a request.
  2. It calls step() while a request is not finished. It copies the outputs of each step.
  3. When all requests are finished, it calls flush_inflight_reqs() once, sorts the results, and returns them.

One step has fixed phases. The C++ scheduler selects the batch. The Worker runs the model on the device and samples the next tokens. The engine then updates each sequence and makes one RequestOutput for each new token. With zero-overhead scheduling (ZOS), the engine updates the sequences of a decode step during the next step. So the CPU work overlaps with the device work.

An everyday comparison. Think of a ferry that crosses a river again and again. On each trip it takes all cars that are ready. A car must make many trips to reach its goal, because each trip moves it only a short distance (one or a few tokens). The comparison stops being true in two places. A real ferry unloads at the end of the same trip. With ZOS, DeepInfer reports the result of a decode trip during the next trip. Also, all ferries of a fleet (the data-parallel ranks) must make the same number of trips, also when one of them is empty.

The picture#

Figure: the offline step loop (panel 1), the parts of one step (panel 2), and what each call returns for two short requests with ZOS on (panel 3). Click to zoom.
Open full size ↗

Read panel 1 as a loop: test, step, copy, test again. Panel 2 shows that the Worker calls back into the engine twice in one step. Panel 3 is the most important one. A prefill step returns its tokens at once, but a decode step returns the tokens of the step before it.

In the code#

Step 1: generate() adds all requests first#

        # Add requests to the engine.
        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,
                # 共享同一个 SamplingParams 时无需在此复制:_add_request 内部会 deepcopy
                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)

Each _add_request call tokenizes the prompt (if the caller gave text), gives the request the next counter value as its id, and calls LLMEngine.add_request (deepinfer/entrypoints/llm.py:151-166). The engine makes a C++ sequence and puts it into the waiting queue of the scheduler (deepinfer/engine/llm_engine.py:620-630). No model work happens yet. The page Request and sequence lifecycle follows one sequence from here to its end.

Step 2: the loop in _run_engine()#

    def _run_engine(self, use_tqdm: bool) -> list[list[RequestOutput] | str]:
        # Initialize tqdm.
        if use_tqdm:
            num_requests = self.llm_engine.get_num_unfinished_requests()
            pbar = tqdm(total=num_requests, desc="Processed prompts")

        # Run the engine.
        outputs: dict[str, list[RequestOutput]] = defaultdict(list)
        while True:
            unfinished = self.llm_engine.has_unfinished_requests()
            step_outputs = self.llm_engine.step() if unfinished else self.llm_engine.flush_inflight_reqs()
            for raw_output in step_outputs:
                # clone step_outputs since step_outputs will be released in the next step
                output = RequestOutput.clone(raw_output)
                outputs[output.request_id].append(output)
                if output.finished and use_tqdm:
                    pbar.update(1)
            if not unfinished:
                break
        request_ids = sorted(outputs, key=int)
        results: list[list[RequestOutput] | str] = [outputs[i] for i in request_ids]
        if self.decode_sequence:
            results = [self.llm_engine.tokenizer.decode([x.token_id for x in output]) for output in results]

        if use_tqdm:
            pbar.close()

        return results
  1. Line 177: the loop asks the engine if any request is unfinished.
  2. Line 178: if yes, it runs one step. If no, it calls flush_inflight_reqs() instead.
  3. Lines 179-182: each step returns an array of handles. The loop copies each output with RequestOutput.clone before the next step. The engine gives the original objects back to their pool one step later.
  4. Lines 185-186: the loop ends after the flush.
  5. Lines 187-190: the results are sorted by request id as an integer, so they come back in prompt order. With decode_sequence=True (the default), each list of outputs becomes a string (deepinfer/entrypoints/llm.py:64).

Step 3: is any request unfinished?#

    def has_unfinished_requests(self) -> bool:
        """Returns True if there are unfinished requests."""
        if envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING:
            dp_group = PG.cpu_data_parallel_group()
            device = "cpu"
        else:
            dp_group = PG.data_parallel_group()
            device = "cuda"
        unfinished = self.scheduler.has_unfinished_seqs()
        unfinished = unfinished or self.so_builder_has_pending()
        if dp_group.size() > 1:
            unfinished = torch.full((1,), unfinished, dtype=torch.int64, device=device)
            dist.all_reduce(unfinished, op=dist.ReduceOp.MAX, group=dp_group)
            unfinished = unfinished.item() > 0
        return unfinished

The local answer is true if a scheduler queue (waiting, running or prompted) holds a sequence (csrc/engine/scheduler.hpp:165-167). It is also true if a structured output grammar is still in the build queue. With more than one data-parallel rank, the engine takes the maximum over all ranks. So all ranks keep calling step() until the last rank is done. A rank with no work then runs a placeholder step.

Step 4: one call of step()#

LLMEngine.step() is short. It schedules, does some checks, and hands the batch to the Worker:

        with cpu_timer.record("schedule"):
            seq_metadata_list: list[SequenceMetadata]
            scheduler_outputs: SchedulerOutputs
            with cpu_timer.record("schedule:cpp"):
                seq_metadata_list, scheduler_outputs = self.scheduler.schedule(
                    self.sequence_metadata_pool
                )  # Will also free the finished sequence groups.
        with cpu_timer.record("multi_modal_fetch"):
            if self.multi_modal_processor is not None:
                self.multi_modal_processor.fetch()
                abort_requests = self.multi_modal_processor.fetch_to_abort_requests()
                if abort_requests:
                    logger.error(f"多模态处理失败(通常是损坏的图片/视频),即将 abort {len(abort_requests)} 个请求: {abort_requests}。")
                    self.abort_request(abort_requests)

        if not scheduler_outputs.is_empty() and self.engine_type == EngineType.PROMPT:
            assert scheduler_outputs.prompt_run, "Prompt engines should not perform decoding"
            assert seq_metadata_list[0].seqlen_q > 0, "seqlen_q should be > 0"

        # model execution
        request_outputs = self._execute_model_async_step(seq_metadata_list, scheduler_outputs)

        if self.log_stats:
            self.stats_logger.log_nccl_memory()

        self.generate_step += 1

        return request_outputs

Translated comments (Chinese → English)

  • 944 Multimodal processing failed (usually a damaged image or video); about to abort N requests.

The phases, in order:

  1. Schedule (lines 885-887). The C++ scheduler picks a prefill batch or a decode batch. It returns one SequenceMetadata per sequence and a SchedulerOutputs object. The page The scheduler explains the rules.
  2. Clone or zero (lines 889-905). Only for forked sequences. The offline API never forks.
  3. Checks (lines 907-928). An empty hook, and a check of the structured-output state at the last prefill chunk.
  4. Multimodal fetch (lines 939-945). The engine collects the results of the image worker. In the engine, a request becomes schedulable only here. A failed request is aborted.
  5. Execute (line 952). The engine calls the Worker. The Worker runs the device work and calls back into the engine (next step).
  6. Return (line 959). The handles of the outputs that are ready now.

Step 5: the Worker part of the step#

The Worker method execute_model_step has four parts. Its docstring lists them:

        """The unified entry for model execution: every step (prompt/decode, ZOS on/off,
        placeholder) goes through this Forward-Post_process-Sample round.
          1. Forward current step (main model)
          2. Post-process previous step
          3. Sample current step
          4. Post-process current step: in place for prompt / non-ZOS steps;
             ZOS decode steps defer it to the next step to overlap with forward
        # 4. prompt 步或配置 non-ZOS 时当场 post process;ZOS decode 步留到下一步(与 forward 重叠)
        if is_prompt or not envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING:
            request_outputs = np.concatenate([request_outputs, self._post_process_step(curr_step_state)])
            self.prev_step_state = None
        else:
            self.prev_step_state = curr_step_state

        return request_outputs

Translated comments (Chinese → English)

  • 182 Prompt steps, or a configuration with ZOS off: post-process now. A ZOS decode step waits until the next step (to overlap with the forward pass).

The post-process calls back into the engine. LLMEngine.post_process runs three C++ functions (deepinfer/engine/llm_engine.py:717-761). process_MTPInfo stores the new draft tokens. scheduler.process_outputs appends the tokens to each sequence and checks the stop conditions. build_request_outputs makes the output objects. The second callback, pre_sampler_sync, runs while the device samples. It gives back the buffers of the previous step and frees finished sequences (deepinfer/engine/llm_engine.py:830-846).

Step 6: what step() returns#

step() returns a NumPy array of uint64 values. Each value is the address of a RequestOutput object in the RequestOutputPool. The docstring gives the rule:

    def step(self) -> np.ndarray[np.uint64]:
        """Run one inference step and return the generated outputs.

        Returns:
            np.ndarray[np.uint64]: entrypoint -> a list of RequestOutput objects.

        Note:
            The returned RequestOutput objects are managed by RequestOutputPool.
            RequestOutput will be automatically released in the next step() call.
            If you need to retain them beyond the current step, you must call pool.clone(list[RequestOutput]) to obtain copies,
            and release them via pool.release(list[RequestOutput]).
        """

One request can get several outputs in one step, one per accepted token. A step can also return no output at all. Panel 3 of the figure shows steps with no output. Its footnote mentions the case with accepted drafts.

Step 7: the end of the loop and the flush#

    def flush_inflight_reqs(self) -> np.ndarray[np.uint64]:
        """flush 并 post process ZOS 中所有在途请求。"""
        request_outputs = self._run_workers("flush_inflight_reqs")
        self.deferred_release.collect_request_outputs(request_outputs)
        self.process_output(request_outputs)
        return request_outputs
    def flush_inflight_reqs(self) -> np.ndarray[np.uint64]:
        """
        Flush inflight requests running on GPU, useful for ZOS. It may cause CPU-GPU synchronization.

        Returns:
            An array of request outputs for the flushed requests.
        """
        request_outputs = self._post_process_step(self.prev_step_state)
        self.prev_step_state = None
        return request_outputs

With ZOS, the Worker keeps the last decode step in prev_step_state. No later step post-processes it, so the flush does it. In the offline loop, the kept step at that time has no real sequence, so the flush returns no output (deep dive below). A custom loop must still call it: if the loop stops earlier, the flush returns the last tokens.

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

One step, phase by phase (ZOS on and off)

The table lists everything that one call of step() does, in order. "N" is this step, "N−1" the step before it.

#PhaseWhereZOS onZOS off
1timers, profiler hookdeepinfer/engine/llm_engine.py:872-879same
2Scheduler.schedule(pool)deepinfer/engine/llm_engine.py:885-887plans N before N−1 is post-processed; sequences carry in-flight tokensN−1 is already post-processed
3clone / zero states and blocks, if askeddeepinfer/engine/llm_engine.py:889-905same (only forked sequences)
4post_schedule hook (empty), SO check at the last prefill chunkdeepinfer/engine/llm_engine.py:907-928same
5KV state copy (unused: copy_state is None)deepinfer/engine/llm_engine.py:930-937same
6multimodal fetch(), abort failed requestsdeepinfer/engine/llm_engine.py:939-945same
7Worker: correct seqlen_q/seqlen_k with the results of N−1deepinfer/worker/worker.py:116-118on the device, if N and N−1 share sequencesnothing to correct
8Worker: forward pass of Ndeepinfer/worker/worker.py:121-127same; a placeholder sequence if the batch is empty
9Worker: post-process N−1 → LLMEngine.post_processdeepinfer/worker/worker.py:143-144the decode step N−1, if keptnothing kept
10Worker: sample Ndeepinfer/worker/worker.py:146-159same
11pre_sampler_sync: release old buffers, SO pump, free finished sequencesdeepinfer/worker/worker.py:162-165, deepinfer/engine/llm_engine.py:830-846same
12Worker: post-process N now, or keep itdeepinfer/worker/worker.py:182-187prefill: now; decode: keepalways now
13keep the output handles for release, return themdeepinfer/engine/llm_engine.py:974-975, deepinfer/engine/llm_engine.py:957-959same

The default. DEEPINFER_ZERO_OVERHEAD_SCHEDULING is 0 in environ.py, but EngineArgs sets it to 1 for NORMAL and COMPLETION engines, if you did not set it (deepinfer/environ.py:179, deepinfer/engine/arg_utils.py:51-55). So the offline API runs with ZOS on. The page Zero-overhead scheduling (ZOS) explains the overlap.

Deep dive

What each call returns: the worked example, line by line

Two requests, A with max_tokens=3 and B with max_tokens=4, are schedulable and start in the same prefill step. ZOS is on. To keep the numbers simple, every decode step accepts no draft, so each step adds one token per request. This site derived the table from the code and did not run it.

CallScheduledPost-processed in this callReturnsLines
step 1prefill A, Bstep 1 (prefill: at once)A1, B1deepinfer/worker/worker.py:183-185
step 2decode A, Bnothing (no kept step)nonedeepinfer/worker/worker.py:259-260
step 3decode A, Bstep 2A2, B2deepinfer/worker/worker.py:143-144
step 4decode A, Bstep 3: A reaches 3 tokens, "length"A3 (finished), B3csrc/engine/scheduler.hpp:1048-1051
step 5decode B (A leaves running)step 4: A is finished, so its token is dropped; B reaches 4B4 (finished)csrc/engine/scheduler.hpp:752-763, csrc/engine/scheduler.hpp:905-915
step 6empty decode (B leaves running)step 5: B is finished, token droppednonedeepinfer/worker/worker.py:125-127
flush-step 6 (placeholder: no sampler output)nonedeepinfer/engine/llm_engine.py:987
  1. Why step 2 returns nothing. A prefill step sets prev_step_state = None (deepinfer/worker/worker.py:185). The first decode step after it has nothing to post-process.
  2. Why A still runs in step 4. The scheduler of step 4 runs before the post-process of step 3. At that moment A has 2 output tokens.
  3. Why step 6 happens. Before step 6, B is finished but is still in running, so has_unfinished_requests() is true. The decode schedule of step 6 removes B, and pre_sampler_sync frees its blocks (deepinfer/engine/llm_engine.py:844-846).
  4. Cost. With ZOS, each request does one forward pass whose token is dropped: the step that ran while its last token was still in flight.

With ZOS off, each step post-processes itself. Then step 2 returns A2, B2, step 3 returns A3 and B3, and A leaves running one step earlier.

With accepted drafts, one decode step appends several tokens. build_request_outputs makes one object for each accepted draft and one for the sampled token (csrc/engine/outputs.hpp:219-285).

Deep dive

Termination: what counts as unfinished

CountedWhereNote
sequences in waitingcsrc/engine/scheduler.hpp:167also those that wait for the image worker, and chunked prompts
sequences in runningcsrc/engine/scheduler.hpp:167also finished ones, until the next decode schedule removes them
sequences in promptedcsrc/engine/scheduler.hpp:167only forked sequences use it
grammar builds that are not donedeepinfer/engine/llm_engine.py:344-345, deepinfer/engine/llm_engine.py:674the sequence is not in a queue yet

The all-reduce. With dp_group.size() > 1, the answer becomes an int64 tensor, and an all-reduce MAX combines all ranks (deepinfer/engine/llm_engine.py:675-678). With ZOS the group is the CPU (Gloo) group, else the device group (lines 667-672). Every rank calls this function once per loop, so every rank calls step() the same number of times. This matters because the scheduler of each rank votes with all-reduce calls inside schedule() (csrc/engine/scheduler.hpp:315-332).

The progress bar. With use_tqdm=True, the total is get_num_unfinished_requests() at the start (deepinfer/entrypoints/llm.py:170-172). It is a local count: the sum of the three queues plus pending grammar builds (deepinfer/engine/llm_engine.py:658-663). The bar moves when an output has finished=True.

Deep dive

Buffers that live for one step

Three kinds of objects that a step makes are valid only until the next step. DeferredRelease collects them and gives them back in the next pre_sampler_sync, while the device samples (deepinfer/engine/llm_engine.py:67-121).

class DeferredRelease:
    """延后一轮释放 step 产出的资源,保证调用方读完。

    回收(collect)要释放的资源;在下一次的 pre_sampler_sync 时释放(release),与 GPU 计算 overlap。
    """

Translated comments (Chinese → English)

  • 68 Release the resources that a step made one step later, so that the caller has finished reading them.
  • 70 Collect the resources to release. Release them at the next pre_sampler_sync, overlapped with GPU computation.
ObjectPoolCollected atGiven back at
output handles (uint64)RequestOutputPool, 10000 objects (csrc/engine/outputs.hpp:51)deepinfer/engine/llm_engine.py:974, deepinfer/engine/llm_engine.py:980next pre_sampler_sync
SequenceMetadata listSequenceMetadataPoolend of post_process (deepinfer/engine/llm_engine.py:1013)next pre_sampler_sync
state and block ids from a cloneblock managersdeepinfer/engine/llm_engine.py:905same step's pre_sampler_sync, after a CUDA event

Copying an output. RequestOutput.clone(ptr) copies the C++ object into a new object that Python owns (csrc/engine/pybind_output.cpp:17-18). The fields include request_id, token_id, finished, finish_reason, token_logprobs, top_logprobs, num_output_tokens, draft_tok and accept_tok (csrc/engine/outputs.hpp:18-43).

Pool size. The pool has 10000 objects. If no object is free, allocate fails with "RequestOutputPool exhausted" (csrc/engine/outputs.hpp:64-70). One decode step makes at most max_num_seqs × (1 + nextn) outputs, for example 256 × 6 = 1536.

Deep dive

Placeholder steps: steps with no sequence

A step can have no scheduled sequence. This happens when the decode branch finds no running sequence, or when another data-parallel rank runs a prefill step and this rank has no prompt. step() still calls the Worker, with is_placeholder = scheduler_outputs.is_empty() (deepinfer/engine/llm_engine.py:972).

    def _placeholder_seq_metadata(self, is_prompt: bool) -> SequenceMetadata:
        nextn_pad_token_id = get_nextn_pad_token_ids(self.vocab_size)
        # mtp_info_write_slot defaults to the padding/dummy slot 0, which the store kernel skips.
        return SequenceMetadata(
            request_id=PLACEHOLDER_REQUEST_ID,
            seq_id=0,
            is_prompt=is_prompt,
            seq_data=SequenceData([0]),
            compressed_seq_data=SequenceData([0]),
            multi_modal_data=None,
            sampling_params=SamplingParams(temperature=0.0),
            block_table=[0],
            seqlen_q=1,
            seqlen_k=1,
            last_output_token=0,
            last_accepted_token=[],
            last_next_n_token=[] if is_prompt else nextn_pad_token_id,
        )
The placeholder: one token (id 0), block table [0], greedy sampling. Block 0 is never given to a real sequence.

The Worker runs the forward pass and the sampler on this one sequence (deepinfer/worker/worker.py:125-127). The post-process gets no sampler output (deepinfer/engine/llm_engine.py:987), so it makes no RequestOutput. The forward pass still runs the collective operations of the model, such as the MoE dispatch and combine. That keeps every rank in the same order of collective calls.

Empty steps at the start. In this release, every request of a NORMAL engine waits for the multimodal worker before its first prefill. The first prefill then waits for an attempt point, so the first steps of a run are placeholder steps (Tour: Life of a request, stop 5).

Deep dive

Settings that change the loop

SettingDefaultEffect on the loop
DEEPINFER_ZERO_OVERHEAD_SCHEDULINGon for NORMAL engines (deepinfer/engine/arg_utils.py:51-55)Decode outputs come one step late; the flush gets the last ones; the DP tests run on the CPU group.
LLM(decode_sequence=...)True (deepinfer/entrypoints/llm.py:64)generate() returns strings, not RequestOutput lists (deepinfer/entrypoints/llm.py:189-190).
LLM keyword defaultsdisable_log_stats=True, stop_preempted_request=False (deepinfer/entrypoints/llm.py:67-68)No statistics log lines; preempted requests are recomputed, not stopped.
use_tqdmTrueA progress bar over the number of requests.
data-parallel sizefrom the launcherEach rank is a separate process with its own LLM and loop (examples/_launch.py:35-44).

What can go wrong#

  • A custom loop without the flush. With ZOS, the outputs of the last kept decode step come only from flush_inflight_reqs() (deepinfer/worker/worker.py:274-283).
  • A loop on one data-parallel rank only. The scheduler votes with all-reduce calls in every step. If one rank stops calling step(), the other ranks wait in the vote. Use has_unfinished_requests(), which combines all ranks.
  • Lost token ids. With decode_sequence=True (the default), generate() returns only text (deepinfer/entrypoints/llm.py:189-190). The examples set decode_sequence=False to keep the objects (examples/_config.py:121).
  • A slow start. The first steps of a run can be empty, because requests wait for the multimodal worker and for an attempt point.
  • A request that is never added. add_request returns None for a token id outside the vocabulary and writes only an info log line (deepinfer/engine/llm_engine.py:582-589). generate() then has no output for that prompt, and the result list is shorter than the prompt list.

Why it matters. The step loop is the heartbeat of the engine. A driver of your own (a server or a benchmark) must keep three rules. Copy the outputs before the next step. Flush at the end. Keep all data-parallel ranks in the same number of steps.

Check yourself#

  1. 1
    What does LLMEngine.step() return?
    easy
    Show answer

    A NumPy array of uint64 handles. Each handle is the address of a RequestOutput object in the RequestOutputPool. The objects are valid until the next step (deepinfer/engine/llm_engine.py:860-871).

  2. 2
    When does _run_engine call flush_inflight_reqs()?
    easy
    Show answer

    Once, when has_unfinished_requests() returns false. Then it copies the outputs and leaves the loop (deepinfer/entrypoints/llm.py:177-186).

  3. 3
    ZOS is on. A prefill step is followed by a decode step. What does the decode step return, and why?
    medium
    Show answer

    Nothing. A prefill step post-processes at once and sets prev_step_state to None (deepinfer/worker/worker.py:183-185). The decode step keeps its own state for the next call, and has no kept step to post-process (deepinfer/worker/worker.py:186-187, deepinfer/worker/worker.py:259-260).

  4. 4
    Two data-parallel ranks run. Rank 0 has no request left, rank 1 has one. What does rank 0 do in the next loop?
    medium
    Show answer

    It calls step(). has_unfinished_requests() takes the MAX over the DP group, so it is true on both ranks (deepinfer/engine/llm_engine.py:675-678). Rank 0 schedules an empty batch and runs a placeholder step (deepinfer/worker/worker.py:125-127).

  5. 5
    With ZOS on, request A has max_tokens=3 and starts in a prefill step alone. No draft is accepted. How many calls of step() run, and in which one does A's last output come back?
    hard
    Show answer

    Prefill (returns A1), decode (returns nothing), decode (returns A2), decode (returns A3, finished). In the fourth call the post-process of the third step finds the 3rd token. A is still in running, so a fifth call runs. Its schedule removes A, and its post-process drops A's 4th token, so it returns nothing. Then the loop flushes. So 5 calls of step() and one flush; A's last output comes back in call 4. See the deep dive "What each call returns".