Inside nano-vLLM: Anatomy of a LLM Inference System
nano-vLLM is based on vLLM, which is known by paged attention, it is a simple implementation but contained the core theory of paged attention and flash attention.
1. Lifecycle of Request
1.1 Public API
The external entrypoint is LLM.generate(), it receives prompts and sampling parameters, then calls add_request() for each prompt, which will do two things:
- Converts string prompt into token ids through HF tokenizer.(BPE)
- Wraps token ids into a
Sequenceand pushes it into the scheduler.
At this point, we will no longer see the prompt as an identity of a request but a runtime object called Sequence instead.
1.2 Sequence
Sequence is the central request state in nano-vLLM. It stores tokens and kv-cache metadata.
Fields:
token_ids: prompt tokens plus generated tokens.num_prompt_tokens: length of the original prompt.num_cached_tokens: how many tokens have already been computed and written into KV cache.num_scheduled_tokens: how many tokens will be executed in the current step.block_table: mapping from logical blocks to physical KV-cache blocks.status:WAITING,RUNNING, orFINISHED.
This design supports cyclic decoding, enabling fair scheduling during multi-user requests, the core idea is round-robin-like decode scheduling.
1.3 Scheduler
Scheduler owns two queues:
waiting: sequences that still need prefill work, prefill queue.running: sequences that are ready for decode, decode queue.
schedule() first tries to schedule prefill work from waiting. If there is no prefill work, it schedules decode work from running.
Note: modern vLLM schedulers can mix prefill and decode, often prioritizing decode to reduce inter-token latency.
1.4 One Engine Step
One LLMEngine.step() is the basic execution unit.
- Scheduler selects a batch.
- ModelRunner executes the batch and returns sampled token ids.
- Scheduler postprocesses the result.
1.5 Postprocess
After model execution, postprocess() updates each scheduled sequence.
For both prefill and decode:
- Hash completed KV blocks for prefix cache.
- Increase
num_cached_tokens. - Clear
num_scheduled_tokens.
For prefill:
- If prefill is complete, the sampled token becomes the first generated token, otherwise do nothing.
For decode:
- Append the sampled token to
token_ids. - If EOS or max_tokens is reached, mark the sequence as finished.
- Release KV block through BlockManager.
2. KV Block Management
Instead of storing each sequence in one contiguous kv tensor region, paged attention splits kv cache into fixed-size physical blocks. Each sequence owns a block_table, which maps its logical blocks to physical blocks.
2.1 Blocks
A Sequence is a logical token stream, it is split into logical blocks by block_size, e.g. block_size = 4
token_ids = [t0, t1, t2, ..., t9]
->
logical block 0: [t0, t1, t2, t3]
logical block 1: [t4, t5, t6, t7]
logical block 2: [t8, t9]
The Sequence.block_table maps these logical blocks to physical KV blocks:
seq.block_table = [7, 3, 12]
logical block 0 -> physical block 7
logical block 1 -> physical block 3
logical block 2 -> physical block 12
2.2 Block Metadata
class Block:
block_id # physical block id.
ref_count # how many sequences are currently using this block.
hash # prefix-cache hash for this block.
token_ids # original token ids used to verify hash correctness.
hash is not enough by itself because hash collision is theoretically possible.
2.3 BlockManager State
It maintains the four core data structures:
blocks: list[Block] # all physical block metadata.
hash_to_block_id: dict[int, int] # prefix-cache index from block hash to physical block id.
free_block_ids: deque[int] # blocks that can be allocated.
used_block_ids: set[int] # blocks currently referenced by running sequences.
2.4 Allocation
When a new sequence enters prefill, scheduler calls:
num_cached_blocks = block_manager.can_allocate(seq)
block_manager.allocate(seq, num_cached_blocks)
can_allocate() is a dry-run check. It scans complete logical blocks of the sequence and tries to find cached prefix blocks.
If a cached block is found, it increases num_cached_blocks.
If the cached block is still used by another sequence, it does not consume a new free block, because it can be shared through ref_count.
If the cached block is free, it still needs to be removed from free_block_ids when reused, so it still counts as consuming one free block.
allocate() then mutates state:
- cached used block:
ref_count += 1 - cached free block: move from free to used, set
ref_count = 1 - uncached block: allocate a new physical block
- append all physical block ids to
seq.block_table
After allocation:
seq.num_cached_tokens = num_cached_blocks * block_size
This tells ModelRunner how many prefix tokens can be skipped.
2.5 Prefix cache hash
block manager only hash completed blocks, the hash is chained:
hash(block_0)
hash(hash(block_0), block_1)
hash(hash(block_1), block_2)
After ModelRunner finishes executing scheduled tokens, postprocess() calls block_manager.hash_blocks(seq) to save completed blocks.
2.6 Decode
Most decode steps write into the current last block, but when the new token crosses a block boundary, BlockManager must allocate a new physical block.
can_append(seq)
may_append(seq)
judge condition:
len(seq) % block_size == 1
2.7 Deallocation
When a sequence finishes or is preempted, BlockManager deallocates its blocks:
for block_id in reversed(seq.block_table):
block.ref_count -= 1
if block.ref_count == 0:
move block from used to free
Deallocation does not clear hash and token_ids
At this point, BlockManager only decides which physical blocks a sequence owns.
It does not compute GPU tensor offsets directly. ModelRunner later converts block_table into slot_mapping and block_tables, which are consumed by the attention kernel.
3. Model Runner
ModelRunner converts scheduled sequences into model inputs, runs the model, samples the next token, and returns token ids.
3.1 Initialization
load model weights
-> warmup_model()
-> allocate_kv_cache()
-> bind KV cache to each attention layer
warmup_model() estimates temporary peak memory, and allocate_kv_cache() uses that estimate to decide how many KV blocks can be allocated.
After the KV cache tensor is allocated, ModelRunner injects each layer’s KV cache slice into the corresponding attention module:
for module in self.model.modules():
if hasattr(module, "k_cache") and hasattr(module, "v_cache"):
module.k_cache = self.kv_cache[0, layer_id]
module.v_cache = self.kv_cache[1, layer_id]
layer_id += 1
3.2 Run
It receives two parameters: seqs and is_prefill
The important idea is that prefill and decode use different input construction strategies.
Prefill may execute many prompt tokens for each sequence. Decode executes only one token per sequence.
3.3 Prefill Preparation
For each scheduled sequence, prepare_prefill() uses:
start = seq.num_cached_tokens
end = start + seq.num_scheduled_tokens
input_ids = seq[start:end]
positions = range(start, end)
Cached token are skiped and do not appera in input_ids.
attention metadata:
- input_ids: token ids to execute in this batch.
- positions: position ids for RoPE.
- cu_seqlens_q: query sequence boundaries in the flattened batch.
- cu_seqlens_k: full context boundaries, including cached prefix.
- max_seqlen_q: maximum number of query tokens in the batch.
- max_seqlen_k: maximum context length in the batch.
- slot_mapping: physical KV slots where current tokens should write K/V.
- block_tables: physical block tables used when historical KV needs to be read.
3.4 Decode Preparation
Each running sequence only contributes one input token: its last token.
input_ids = [seq.last_token for seq in seqs]
positions = [len(seq) - 1 for seq in seqs]
Even though decode only computes one new token per sequence, the new token must attend to all previous tokens. Therefore decode always needs:
context_lens: current full length of each sequence.
block_tables: where historical KV blocks are located.
slot_mapping: where the new token's K/V should be written.
current token -> compute Q/K/V
historical tokens -> read K/V from KV cache through block_tables
new K/V -> write into KV cache through slot_mapping
3.5 Runtime Context
Instead of passing all metadata through every model layer manually, ModelRunner sets a global context before model forward. The attention layer then reads this context during its forward pass.
ModelRunner.set_context(...)
-> Qwen model forward
-> Attention.forward()
-> get_context()
-> use parameters
this keeps the model forward signature simple while still allowing attention kernels to access runtime metadata.
3.6 Attention and KV Cache
The attention layer has two responsibilities related to KV cache:
- store current token K/V into KV cache.
- read historical K/V when needed.
Current K/V is written by
slot_mapping.
token index in current batch
-> slot_mapping
-> physical KV cache slot
Historical K/V is read through block_tables.
sequence logical block
-> block_table
-> physical KV block
-> KV cache tensor
Sequence.block_table
-> ModelRunner.block_tables
-> Attention kernel reads historical KV
Sequence.block_table + token position
-> ModelRunner.slot_mapping
-> Attention kernel writes current KV
3.7 Sampling
The sampler applies temperature and samples the next token from the probability distribution.
The returned token_ids have the same order as the scheduled seqs.
seqs = [seq0, seq1, seq2]
token_ids = [tok0, tok1, tok2]
Then scheduler update each sequence.
for seq, token_id in zip(seqs, token_ids):
...
In general, scheduler decides which sequences and tokens should run, BlockManager decides which physical KV blocks each sequence owns, ModelRunner converts this decision into GPU-facing tensors, and Attention uses those tensors to read/write KV cache.