Offline API and the step loop
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:
- It adds every prompt to the engine as a request.
- It calls
step()while a request is not finished. It copies the outputs of each step. - 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#
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- Line 177: the loop asks the engine if any request is unfinished.
- Line 178: if yes, it runs one step. If no, it calls
flush_inflight_reqs()instead. - Lines 179-182: each step returns an array of handles. The loop copies each output with
RequestOutput.clonebefore the next step. The engine gives the original objects back to their pool one step later. - Lines 185-186: the loop ends after the flush.
- 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 unfinishedThe 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_outputsTranslated comments (Chinese → English)
- 944 Multimodal processing failed (usually a damaged image or video); about to abort N requests.
The phases, in order:
- 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.
- Clone or zero (lines 889-905). Only for forked sequences. The offline API never forks.
- Checks (lines 907-928). An empty hook, and a check of the structured-output state at the last prefill chunk.
- 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.
- Execute (line 952). The engine calls the Worker. The Worker runs the device work and calls back into the engine (next step).
- 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_outputsTranslated 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_outputsWith 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 diveOne 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.
| # | Phase | Where | ZOS on | ZOS off |
|---|---|---|---|---|
| 1 | timers, profiler hook | deepinfer/engine/llm_engine.py:872-879 | same | |
| 2 | Scheduler.schedule(pool) | deepinfer/engine/llm_engine.py:885-887 | plans N before N−1 is post-processed; sequences carry in-flight tokens | N−1 is already post-processed |
| 3 | clone / zero states and blocks, if asked | deepinfer/engine/llm_engine.py:889-905 | same (only forked sequences) | |
| 4 | post_schedule hook (empty), SO check at the last prefill chunk | deepinfer/engine/llm_engine.py:907-928 | same | |
| 5 | KV state copy (unused: copy_state is None) | deepinfer/engine/llm_engine.py:930-937 | same | |
| 6 | multimodal fetch(), abort failed requests | deepinfer/engine/llm_engine.py:939-945 | same | |
| 7 | Worker: correct seqlen_q/seqlen_k with the results of N−1 | deepinfer/worker/worker.py:116-118 | on the device, if N and N−1 share sequences | nothing to correct |
| 8 | Worker: forward pass of N | deepinfer/worker/worker.py:121-127 | same; a placeholder sequence if the batch is empty | |
| 9 | Worker: post-process N−1 → LLMEngine.post_process | deepinfer/worker/worker.py:143-144 | the decode step N−1, if kept | nothing kept |
| 10 | Worker: sample N | deepinfer/worker/worker.py:146-159 | same | |
| 11 | pre_sampler_sync: release old buffers, SO pump, free finished sequences | deepinfer/worker/worker.py:162-165, deepinfer/engine/llm_engine.py:830-846 | same | |
| 12 | Worker: post-process N now, or keep it | deepinfer/worker/worker.py:182-187 | prefill: now; decode: keep | always now |
| 13 | keep the output handles for release, return them | deepinfer/engine/llm_engine.py:974-975, deepinfer/engine/llm_engine.py:957-959 | same | |
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 diveWhat 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.
| Call | Scheduled | Post-processed in this call | Returns | Lines |
|---|---|---|---|---|
| step 1 | prefill A, B | step 1 (prefill: at once) | A1, B1 | deepinfer/worker/worker.py:183-185 |
| step 2 | decode A, B | nothing (no kept step) | none | deepinfer/worker/worker.py:259-260 |
| step 3 | decode A, B | step 2 | A2, B2 | deepinfer/worker/worker.py:143-144 |
| step 4 | decode A, B | step 3: A reaches 3 tokens, "length" | A3 (finished), B3 | csrc/engine/scheduler.hpp:1048-1051 |
| step 5 | decode B (A leaves running) | step 4: A is finished, so its token is dropped; B reaches 4 | B4 (finished) | csrc/engine/scheduler.hpp:752-763, csrc/engine/scheduler.hpp:905-915 |
| step 6 | empty decode (B leaves running) | step 5: B is finished, token dropped | none | deepinfer/worker/worker.py:125-127 |
| flush | - | step 6 (placeholder: no sampler output) | none | deepinfer/engine/llm_engine.py:987 |
- 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. - 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.
- Why step 6 happens. Before step 6, B is finished but is still in
running, sohas_unfinished_requests()is true. The decode schedule of step 6 removes B, andpre_sampler_syncfrees its blocks (deepinfer/engine/llm_engine.py:844-846). - 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 diveTermination: what counts as unfinished
| Counted | Where | Note |
|---|---|---|
sequences in waiting | csrc/engine/scheduler.hpp:167 | also those that wait for the image worker, and chunked prompts |
sequences in running | csrc/engine/scheduler.hpp:167 | also finished ones, until the next decode schedule removes them |
sequences in prompted | csrc/engine/scheduler.hpp:167 | only forked sequences use it |
| grammar builds that are not done | deepinfer/engine/llm_engine.py:344-345, deepinfer/engine/llm_engine.py:674 | the 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 diveBuffers 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.
| Object | Pool | Collected at | Given back at |
|---|---|---|---|
output handles (uint64) | RequestOutputPool, 10000 objects (csrc/engine/outputs.hpp:51) | deepinfer/engine/llm_engine.py:974, deepinfer/engine/llm_engine.py:980 | next pre_sampler_sync |
SequenceMetadata list | SequenceMetadataPool | end of post_process (deepinfer/engine/llm_engine.py:1013) | next pre_sampler_sync |
| state and block ids from a clone | block managers | deepinfer/engine/llm_engine.py:905 | same 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 divePlaceholder 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 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 diveSettings that change the loop
| Setting | Default | Effect on the loop |
|---|---|---|
DEEPINFER_ZERO_OVERHEAD_SCHEDULING | on 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 defaults | disable_log_stats=True, stop_preempted_request=False (deepinfer/entrypoints/llm.py:67-68) | No statistics log lines; preempted requests are recomputed, not stopped. |
use_tqdm | True | A progress bar over the number of requests. |
| data-parallel size | from the launcher | Each 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. Usehas_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 setdecode_sequence=Falseto 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_requestreturnsNonefor 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#
- 1What doeseasy
LLMEngine.step()return?Show answer
A NumPy array of
uint64handles. Each handle is the address of aRequestOutputobject in theRequestOutputPool. The objects are valid until the next step (deepinfer/engine/llm_engine.py:860-871). - 2When doeseasy
_run_enginecallflush_inflight_reqs()?Show answer
Once, when
has_unfinished_requests()returns false. Then it copies the outputs and leaves the loop (deepinfer/entrypoints/llm.py:177-186). - 3ZOS 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_statetoNone(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). - 4Two 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). - 5With ZOS on, request A hashard
max_tokens=3and starts in a prefill step alone. No draft is accepted. How many calls ofstep()run, and in which one does A's last output come back?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 ofstep()and one flush; A's last output comes back in call 4. See the deep dive "What each call returns".