DeepInfer Explained

deepinfer/entrypoints/llm.py: the offline LLM class

BeginnerCode mapabout 8 min read + 5 min deep dives

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

Purpose#

LLM is the class that a user script calls. It turns a list of prompts into a list of results, and hides the engine step loop.

Filedeepinfer/entrypoints/llm.py, 198 lines. The header says "Derived from vLLM; modified by DeepSeek." (deepinfer/entrypoints/llm.py:2)
Main classLLM (deepinfer/entrypoints/llm.py:23). It is the only class in the file.
Runs inThe user's process. That process is also the engine process of one data-parallel rank, with the driver worker in it. The step loop runs on the calling thread, and generate() blocks until all requests end.
Called byUser scripts, examples/_config.py (create_llm), eval/run_eval.py.
CallsEngineArgs, LLMEngine, the Hugging Face tokenizer, RequestOutput.clone, tqdm.
Not in this fileBatching, scheduling, sampling (all in the engine). No streaming, no abort, no online server: the engine docstring says that this release has no online-serving wrapper (deepinfer/engine/llm_engine.py:140-141).

Where it sits#

Figure: generate() adds all requests first (panel 1), then runs the step loop and collects the outputs (panel 2). Click to zoom.
Open full size ↗

Panel 1 runs once per prompt and does no device work. Panel 2 is the loop. The flush runs only once, at the end.

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
LLM.__init__deepinfer/entrypoints/llm.py:54Sets three defaults, makes EngineArgs and the engine.
get_tokenizerdeepinfer/entrypoints/llm.py:87Returns the engine's tokenizer.
generatedeepinfer/entrypoints/llm.py:90Checks the inputs, adds all requests, runs the loop.
_add_requestdeepinfer/entrypoints/llm.py:144Copies the settings, makes the id, tokenizes, calls add_request.
_run_enginedeepinfer/entrypoints/llm.py:168The step loop: clone, group, sort, decode.
closedeepinfer/entrypoints/llm.py:197Calls LLMEngine.close().

Walk through the main methods#

__init__: defaults that differ from the engine#

    def __init__(
        self,
        model: str,
        tokenizer: str | None = None,
        trust_remote_code: bool = False,
        expert_parallel_size: int = 1,
        revision: str | None = None,
        tokenizer_revision: str | None = None,
        seed: int = 0,
        gpu_memory_utilization: float = 0.9,
        decode_sequence: bool = True,
        **kwargs: Any,
    ) -> None:
        kwargs.setdefault("disable_log_stats", True)
        kwargs.setdefault("stop_preempted_request", False)
        os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
        engine_args = EngineArgs(
            model=model,
            tokenizer=tokenizer,
            trust_remote_code=trust_remote_code,
            expert_parallel_size=expert_parallel_size,
            revision=revision,
            tokenizer_revision=tokenizer_revision,
            seed=seed,
            gpu_memory_utilization=gpu_memory_utilization,
            offline_inference=True,
            **kwargs,
        )
        self.decode_sequence = decode_sequence

        self.llm_engine = LLMEngine.from_engine_args(engine_args)
        self.request_counter = Counter()
  1. Lines 56-64: nine named arguments. All other arguments go through **kwargs to EngineArgs, for example max_model_len, max_num_seqs or chunked_prefill. An unknown name raises TypeError, because EngineArgs is a dataclass.
  2. Line 67: statistics logging is off, unless you pass disable_log_stats=False.
  3. Line 68: stop_preempted_request=False. The EngineArgs default is True (deepinfer/engine/arg_utils.py:40). So with LLM, a preempted request is computed again, not stopped (see The KV cache).
  4. Line 69: TOKENIZERS_PARALLELISM=false, unless the variable is already set.
  5. Line 79: offline_inference=True. The engine stores it (deepinfer/engine/llm_engine.py:195); no other line in the release reads it.
  6. Line 82: decode_sequence chooses the result type of generate() (text or outputs).

The docstring has a note in Chinese. Translated: "With CUDA graphs on, the error 'CUDA error: an illegal memory access was encountered' can mean that the CUDA graph ran out of memory. Lower gpu_memory_utilization and try again." (deepinfer/entrypoints/llm.py:50-51).

generate: the input rules#

        if prompts is None and prompt_token_ids is None:
            raise ValueError("Either prompts or prompt_token_ids must be provided.")
        if isinstance(prompts, str):
            # Convert a single prompt to a list.
            prompts = [prompts]
            multi_modal_data = [multi_modal_data]
        if prompts is not None and prompt_token_ids is not None and len(prompts) != len(prompt_token_ids):
            raise ValueError("The lengths of prompts and prompt_token_ids must be the same.")
        if sampling_params is None:
            # Use default sampling params.
            sampling_params = SamplingParams()

        # 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)
  1. Lines 117-118: you must give prompts, prompt_token_ids, or both.
  2. Lines 119-122: one string becomes a list of one. multi_modal_data is wrapped too, so pass one dict for one string.
  3. Lines 123-124: if you give both lists, they must have the same length. Then the token ids win; _add_request tokenizes only when the ids are None (deepinfer/entrypoints/llm.py:153-156).
  4. Lines 125-127: no settings means one SamplingParams() for all requests: temperature 1.0, 16 tokens (deepinfer/sampling_params.py:49-53).
  5. Line 138: one object for all, or a list with one object per prompt. The comment says, translated: "when the same SamplingParams is shared, no copy is needed here: _add_request does a deepcopy" (line 137).

_add_request: one request#

        sampling_params = copy.deepcopy(sampling_params)
        request_id = str(next(self.request_counter))
        if prompt_token_ids is None:
            if prompt is None:
                raise ValueError("prompt and prompt_token_ids cannot both be None")
            prompt_token_ids = self.llm_engine.tokenizer.encode(prompt)
        prompt_token_ids = np.asarray(prompt_token_ids, dtype=np.int32)
        if multi_modal_data is None and self.llm_engine.multi_modal_processor is not None:
            multi_modal_data = dict(image_bytes=[], image_sizes=[])
        return self.llm_engine.add_request(
            user_id="0",  # HACK 随便加一个 user_id
            request_id=request_id,
            sampling_params=sampling_params,
            prompt_token_ids=prompt_token_ids,
            multi_modal_data=multi_modal_data,
        )

Translated comments (Chinese → English)

  • 161 HACK: add some user_id.
  1. Line 151: a deep copy, because the engine changes the object (it sets eos_id, see deepinfer/sampling_params.py: SamplingParams).
  2. Line 152: the request id is the next value of a counter that starts at 0, as a string. The counter lives in the LLM object, so a second generate() call goes on from the last id.
  3. Line 156: text goes through tokenizer.encode(prompt). With token ids, DeepInfer uses them as they are and adds no BOS token.
  4. Line 157: the engine needs a 1-D np.int32 array; the C++ Sequence refuses other types (see deepinfer/_C/engine.pyi: the C++ engine API, as seen from Python).
  5. Lines 158-159: if the engine has an image processor and you gave no image data, the request gets an empty image dict. The only registered model is a vision-language model, so the NORMAL engine always has that processor (deepinfer/model_executor/models/__init__.py:15-17, deepinfer/engine/llm_engine.py:209-221).
  6. Line 160: the return value is a Sequence, or None for a dropped request. generate() ignores it.

_run_engine: the loop#

    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

Translated comments (Chinese → English)

  • 180 Clone step_outputs, because step_outputs will be released in the next step.
  1. Line 171: the progress bar counts local unfinished requests at the start.
  2. Line 177: has_unfinished_requests() asks all data-parallel ranks with an all-reduce MAX, when there is more than one rank (deepinfer/engine/llm_engine.py:665-679). So this rank goes on while any rank has work.
  3. Line 178: work left: one engine step. No work left: one flush, which returns the outputs that the Worker still holds from the last ZOS decode step (deepinfer/worker/worker.py:274-283).
  4. Lines 179-182: every handle is cloned at once. The engine releases the pool objects at the next pre_sampler_sync (deepinfer/engine/llm_engine.py:830-831).
  5. Lines 185-186: the loop ends after the flush.
  6. Line 187: the request ids are sorted as integers, so "10" comes after "9".
  7. Lines 189-190: with decode_sequence=True, each request becomes one string: the decode of all its output token ids.

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

Data flow: types at each point

PointTypeSource
promptsstr | list[str] | Nonedeepinfer/entrypoints/llm.py:92
prompt_token_idslist[list[int]] | list[np.ndarray[np.int32]] | Nonedeepinfer/entrypoints/llm.py:94
multi_modal_datadict | list[dict] | None; keys such as image_bytes, image_sizesdeepinfer/entrypoints/llm.py:96, deepinfer/entrypoints/llm.py:159
to the engine1-D np.int32 array, a SamplingParams copy, request_id: strdeepinfer/entrypoints/llm.py:157-166
step() resultnp.ndarray[np.uint64]: handlesdeepinfer/engine/llm_engine.py:860
outputsdefaultdict(list): request_id → list[RequestOutput], one per tokendeepinfer/entrypoints/llm.py:175
result, decode_sequence=Falselist[list[RequestOutput]]deepinfer/entrypoints/llm.py:188
result, decode_sequence=Truelist[str]deepinfer/entrypoints/llm.py:190

The return annotation says list[list[RequestOutput] | str] (line 97); the docstring says "a list of RequestOutput objects" (lines 113-115). The code returns one of the two list types above, never a mix.

One output per token. Each RequestOutput holds one token (RequestOutput). A decode step with accepted drafts gives several outputs for one request (see csrc/engine/outputs.hpp: RequestOutput and its pool). To get the token ids of request i: [o.token_id for o in results[i]], as line 190 does.

Deep dive

Worked example: the calls of one generate()

Two prompts, A and B, each with max_tokens=3. ZOS is on (the default for LLM). To keep the table short, assume that the sampler accepts no draft, so each decode step gives one token per request.

CallWhat the engine runsHandles returned
1 to 70: step()empty decode steps. A and B become schedulable only in the fetch() after the first schedule (deepinfer/engine/llm_engine.py:939-941). The next prefill attempt point is decode_step = 70 (csrc/engine/scheduler.hpp:308-313). See Tour: Life of a request.none
71: step()prefill A and B; a prefill step is post-processed in the same call (deepinfer/worker/worker.py:183-185)A token 1, B token 1
72: step()decode step d1; nothing is in flight yetnone
73: step()decode d2; post-process d1 (deepinfer/worker/worker.py:143-144)A token 2, B token 2
74: step()decode d3 (A and B are still in running); post-process d2: both reach max_tokensA token 3, B token 3 (finished)
75: step()the scheduler moves A and B to finished_seqs; an empty step; post-process d3 gives nothing, because both already ended (csrc/engine/scheduler.hpp:906-915)none
76: flushhas_unfinished_requests() is false; the flush post-processes the empty stepnone
Deep dive

Seeds: the LLM seed and the request seed

There are two seeds, and they do different things.

Deep dive

Processes, threads and data parallelism

  • One process per data-parallel rank. Each rank makes its own LLM. examples/_launch.py starts the processes (examples/_launch.py:35-44).
  • All ranks loop together. has_unfinished_requests() and the scheduler's votes are collective calls (deepinfer/engine/llm_engine.py:675-678). So a rank with no prompts also calls generate(), and runs empty steps until all ranks are done.
  • Context-parallel ranks are follower processes that the engine starts; LLM does not see them (deepinfer/engine/llm_engine.py:377-395).
  • Threads. generate() is not thread-safe: two threads would share one engine and one counter. Tokenization runs on the calling thread; TOKENIZERS_PARALLELISM=false turns off the tokenizer's own thread pool (line 69).
Deep dive

Error paths and surprises

SituationResultWhere
No prompts and no token idsValueErrordeepinfer/entrypoints/llm.py:117-118
Two lists of different lengthValueErrordeepinfer/entrypoints/llm.py:123-124
A list of prompts with one multi_modal_data dictKeyError: 0 (the dict is indexed with i)deepinfer/entrypoints/llm.py:140
Fewer SamplingParams than promptsIndexErrordeepinfer/entrypoints/llm.py:138
An empty promptValueError("prompt must not be empty") from the enginedeepinfer/engine/llm_engine.py:553-554
A token id out of rangelog line; the request has no result (see the warning above)deepinfer/engine/llm_engine.py:582-589
A prompt longer than the limitone output with finish_reason = "prompt_length_exceeded"; its token is the last prompt token, so the decoded text is that tokencsrc/engine/outputs.hpp:297-304
Unknown keyword argumentTypeError from EngineArgsdeepinfer/entrypoints/llm.py:70-81

The EOS token in the text. The last output of a request that stops on EOS holds the EOS token itself. The scheduler appends a token before it checks the stop rule (csrc/engine/scheduler.hpp:983-988). Line 190 calls decode without skip_special_tokens, so the text can end with the EOS token's text.

Deep dive

Environment variables that this file touches

VariableWhat the file doesLine
TOKENIZERS_PARALLELISMsets false if not setdeepinfer/entrypoints/llm.py:69
DEEPINFER_ZERO_OVERHEAD_SCHEDULINGnot read here; EngineArgs sets 1 for the NORMAL engine when the variable is not set, which explains the flushdeepinfer/engine/arg_utils.py:51-55

Check yourself#

  1. 1
    What does generate() return with the default decode_sequence=True, and with False?
    easy
    Show answer

    True: one string per request, the decode of its output token ids. False: one list[RequestOutput] per request, one object per token (deepinfer/entrypoints/llm.py:187-190).

  2. 2
    Which defaults does LLM change compared with EngineArgs, and what is the effect of the second one?
    medium
    Show answer

    disable_log_stats=True and stop_preempted_request=False (deepinfer/entrypoints/llm.py:67-68). With the second one, a preempted request goes back to the waiting queue and is computed again, instead of an early stop with insufficient_system_resource (csrc/engine/scheduler.hpp:771-788).

  3. 3
    Why does _run_engine call flush_inflight_reqs() after the last step()?
    medium
    Show answer

    With ZOS, the Worker post-processes a decode step during the next call. When no work is left, the outputs of the last step are still in the Worker; the flush post-processes them (deepinfer/worker/worker.py:182-187, deepinfer/worker/worker.py:274-283).

  4. 4
    You call llm.generate(["Hi"] * 4) with no sampling parameters. Why can all four texts be the same?
    hard
    Show answer

    generate() makes one SamplingParams(), which draws one random seq_seed in its constructor (deepinfer/entrypoints/llm.py:125-127, deepinfer/sampling_params.py:74). _add_request deep-copies that object for each request, so all four copies have the same seed (line 151). The sampler draws its random numbers from the seed and the position, so all four requests get the same numbers. If their logits are also equal, they get the same tokens. This is the deep dive "Seeds".