Skip to main content
The engine is the runtime layer that sits between user-facing API calls and the raw model forward pass. It orchestrates scheduling, memory, batching, and (optionally) multiple GPU processes.

Component overview


Request lifecycle

1

add_prompt

The caller passes a prompt string. LLMEngine.add_prompt tokenizes it and wraps the token IDs in a Sequence object, then hands it to the scheduler.
The sequence enters the waiting deque with status WAITING.
2

schedule

Scheduler.schedule() is called at the start of each step. It returns a list of sequences to run and a boolean indicating whether this is a prefill step.Prefill is always tried first. If any waiting sequence can fit within the token budget and KV cache budget, it is moved to running and included in the batch. Only if no prefill is possible does the scheduler schedule decode steps for already-running sequences.
3

model_runner.run

ModelRunner.run prepares the input tensors and executes the model:
Worker processes (rank > 0) participate in the model forward pass via collective operations but do not sample.
4

postprocess

Scheduler.postprocess appends the new token to each sequence and checks stopping conditions.

The generate() method

generate is the top-level entry point for batch inference.
The loop calls step() until scheduler.is_finished() returns True (both waiting and running queues are empty). Results are collected as sequences finish and returned in the original prompt order.

The step() method

model_runner.call("run", ...) dispatches to the correct method on both rank 0 and worker ranks via shared memory (see Multi-GPU Inference for details).

ModelRunner responsibilities

prepare_prefill

Builds a flat 1-D input_ids tensor by concatenating all token IDs from all sequences (excluding already-cached prefix tokens). Also computes:
  • cu_seqlens_q — cumulative query sequence lengths, e.g. [0, 5, 8, 12]
  • slot_mapping — physical cache slot for each new token to write into
  • block_tables — mapping from logical block index to physical block ID (for cross-sequence prefix reuse)
pin_memory=True and cuda(non_blocking=True) are used together so the CPU→GPU transfer happens asynchronously via DMA, overlapping with other CPU work.

prepare_decode

For decode, each sequence contributes exactly one token (its most recently generated token). The method produces:
  • input_ids of shape (batch_size,)
  • context_lens — total tokens processed so far per sequence
  • slot_mapping — the single new cache slot for each sequence
  • block_tables — full block table for reading KV history

run_model


CUDA graph optimization

CUDA graphs eliminate per-step kernel launch overhead by recording the entire sequence of CUDA operations once and replaying the recording on subsequent steps. Why only for decode? Prefill has variable input lengths — every batch is a different shape, so the graph would need to be re-captured for each request. Decode always processes exactly one token per sequence, giving a fixed input shape for each batch size. Capture strategy. Graphs are captured for batch sizes [1, 2, 4, 8, 16, 32, ...]. Capture happens in descending order so the memory pool created for the largest graph is reused by smaller ones.
At inference time, run_model finds the smallest captured graph that is at least as large as the current batch, copies inputs into the pre-allocated buffers, and calls graph.replay().
torch.compile and CUDA graphs are complementary: @torch.compile fuses multiple operations into fewer CUDA kernels, while CUDA graphs eliminate the CPU overhead of launching those kernels on every decode step.

allocate_kv_cache

Before capturing graphs, the model runner allocates the entire KV cache as one large tensor and distributes slices to each Attention module.
When world_size > 1, each rank computes its own num_blocks from local free memory. A dist.all_reduce(MIN) ensures all ranks agree on the most conservative limit, preventing OOM on the most memory-constrained GPU.

Initialization order

The scheduler must be created after ModelRunner.__init__ because allocate_kv_cache writes the final max_cached_blocks value into the config dict that the scheduler reads.