nano-vllm code walkthrough
Code study of nano-vllm, a minimal implementation of vLLM
vLLM is a beast with 100K+ lines of code, so it’s really challenging to read and learn. Fortunately, there is a great project, nano-vllm (https://github.com/GeeeekExplorer/nano-vllm), which only has a couple thousand of lines, while still keep the skeleton of the real vLLM. In this blog I will firstly explain the key ideas of vLLM, and see how they’re implemented in the nano-vllm project.
High level ideas
Block-based memory management
At LLM inference time, the straightforward implementation is that, we feed user’s prompt to the model, get next token, append the new token to the end of original prompt and feed the new sequence to the model again. Repeat until we get EOS token or reach the max allowed sequence length.
This is not efficient because we keep computing the prefix tokens’ activations again and again. Instead, we can keep the previous computed Keys and Values of the attention layers in a “KVCache”, and every iteration we only need to compute the one new token’s results.
However, the KVCache produces a challenge about GPU memory management. We have to pre-allocate enough memory to avoid fragmentation, but if we always allocate the max allowed sequence length at the beginning of each request, we’re likely wasting a lot of memory unused.
Inspired by the paged virtual memory in operating system, the authors of vLLM invented PagedAttention, which basically means partitioning the kvcache into smaller, fixed-size chunks (pages). We allocate new chunks on demand as the sequences growing, so as a result each sequence may be corresponding to a list of (non-continuous) chunks.
Prefill and decode
This is not the invention of vLLM but it’s critical to understand all the following discussions. At inference time, a request will go through two stages: prefill and decode.
In the prefill stage, the input is the user’s original prompt, we compute all the tokens in parallel, and get the next token as result. Then in the decode stage, we compute one next token at a time sequentially, until we get EOS token.
These two stages have different characteristics. The prefill stage is compute-intensive, while the decode stage is more memory-bound. In real inference system these two stages are usually disaggregated.
This high level idea is not difficult to understand, but there are lots of details/tricks in terms of implementation. So let’s dive into the code of nano-vllm.
Important Python Classes
Sequence
- Sequence represents a request. It has three possible status: WAITING, RUNNING, FINISHED. At the beginning, it is initialized as the original prompt, and new tokens are appended as processing.
- One of the most important fields is
block_table, which is a list of block ids and each block contains a constant size (256) of tokens at most.
Block and BlockManager
- Each block has a unique block id, in the range of
[0, num_blocks). Here thenum_blocksis computed from the available GPU memory. Each block contains at most 256 token ids. Besidestoken_idsfields, there are two fieldshashandref_countused for prefix caching, which we will talk below. - BlockManager manages the blocks. It maintains
free_block_idsandused_block_ids. When we receive a new sequence, we callallocate(self, seq)to allocate proper number of blocks (i.e.ceil(len(seq) / 256)). And when we finished a sequence the corresponding blocks will be returned tofree_block_ids. - When we allocate blocks for a sequence, we get a list of full blocks (i.e. block contains 256 tokens) and one possible non-full block at the end. For each full block, we compute hash of it’s token ids together with its previous block’s hash. So that each such hash value uniquely defined a sequence prefix. The BlockManager maintains
hash_to_block_id, so that if multiple input sequences share same prefix, the block_id can be reused. - Also, during the decoding, the sequence is growing. Once the tail block becomes full, we also compute its hash and update the
hash_to_block_id, then allocate a new tail block.
See below diagram as an example. We have three sequences [10, 12, 15, 16], [10, 12, 15, 10, 12, 15], [10, 12]. The block size is 3. The block_table of these 3 sequences are [0, 1], [0, 2], [3], respectively. Note that the first block of seq1 and first block of seq2 shares same cache page. But the second block of seq2 doesn’t, because its hash is different.
Scheduler
- The scheduler maintains two queues
waiting(sequences need pre-filling) andrunning(sequences need decoding.) When a new request comes, theadd(self, sequence)is called, and the request goes to waiting queue. - Every time when the
schedule()is called, it will return either a batch of pre-filling task, or a batch of decoding task (but never mixed). The scheduler will try to collect as many sequences in the waiting queue as possible, and return a batch of prefilling tasks. If no prefilling task at all, it will collect decoding tasks. (I skipped preempt logic here for simplicity)
LLMEngine
- For each new prompt, LLMEngine call tokenizer, then call
scheduler.add(seq) - It repeatedly call
scheduler.schedule()to get a batch of prefilling or decoding tasks, then callmodel_runner.call()to run the model, then callpostprocess()to append new tokens to the sequences.
Model Runner in details
For the model runner, I will be focusing on the attention computation, because that’s the part which has most difference for training vs inference (in my opinion).
At training time, the input shape of attention layer is typically [batch, seq_len, num_heads, heads_dim]. Here all the sequences have same length because we can just prepare the training data in that way.
However, at inference time, the sequence length varies. Assuming we collected a batch of sequences for the prefilling, we will see a “rugged” shape of inputs. One straightforward way is padding all sequences to the longest sequence, hence we can still use the classic approach. But a more efficient way is to “squash” the sequences into a single long one, and relying on the mask to dictate which tokens should be attended.
Rugged and Squashed representation
nano-vllm utilize variants of flash attention implementations: flash_attn_varlen_func for prefilling, and flash_attn_with_kvcache for decoding.
Prefill stage
Let’s take a look at the signature of flash_attn_varlen_func :
1
2
3
4
flash_attn_varlen_func(q, k, v,
max_seqlen_q, cu_seqlens_q,
max_seqlen_k, cu_seqlens_k,
softmax_scale, causal, block_table)
This call is in the forward call of Attention layer in attention.py , but the parameters that are same across all attention layers are prepared in perpare_prefill() in model_runner.py, saved as a global context. These includes max_seqlen_q, cu_seqlens_q, max_seqlen_k, cu_seqlens_k, block_table. These parameters scared me at the beginning, but once it started to make sense, all the puzzled resolved automatically.
There are actually two scenarios that I found maybe easier to discuss separately:
Case 1: No prefix found in cache.
For this case, we need compute all the tokens from scratch. Let’s examine the params one by one:
q: As mentioned above, we put all sequences into a single one, so q’s shape is[total_tokens, num_heads, heads_dim].cu_seq_lens_q: has length (num_sequences + 1). It’s the cumulated lengths of each sequence in q. For example, if we have three sequences with length2, 5, 3, then thecu_seq_lens_qwill be[0, 2, 7, 10]. By doing that we know each token inqbelongs to which sequence.kandv: contains squeezedkandv, similar asq. For this no-cache-found case, the logical length of k/v is same as q. And we pass-in the actual physicalkandvtensor, which is continuous in memory so the performance is better than use paged cache blocks.cu_seqlens_k: similar ascu_seq_lens_q. Btw we don’t need anothercu_seqlens_vbecause k and v always have same length.max_seqlen_q, max_seqlen_k: the max sequence len in q and k, used by flash attention internal implementation. We skip the details for now.softmax_scale: for example, use the sqrt(D) as in original transformer paper.causal: set to true for causal language model like here, we don’t see the future tokens.block_table: pass inNonefor this case, indicates we don’t use cache.
Case 2: Some prefix(es) found in cache.
q: For this case, we exclude the prefix tokens that are found in cache. So the q’s lengthq.shape[0]is less than total length of sequences.cu_seq_lens_q: similar accumulated lengths. Notecu_seqlens_q[-1]isq.shape[0]kandv: thoughqis shorter, logically we should still use the full-length k/v, because the tokens in q need to attend all previous tokens in the sequence. So the “logical” length of k/v should be same as case 1. However, the physical data is scattered in the cache pages, so here we pass in the address ofk_cacheandv_cache, and use the lastblock_tableparameter to indicate which cache pages belong to this sequence.k_cacheandv_cachehas shape[num_blocks, block_size(256), num_heads, head_dim]. That means, for examplek_cache[i]is the blockiof k_cache.
cu_seqlens_k, cu_seq_lens_q: same as case 1, notecu_seqlen_k[-1] > cu_seqlens_q[-1]block_table: 2D tensor has shape[num_seqs, max_blocks_per_seq]. Its values are the block ids. (use -1 for padding) Conceptually we can use it to locate the blocks in k_cache/v_cache, and “stitch” the blocks to get the full k/v sequence. (It’s not literally implemented that way in flash attention code, of course)
For both cases, the output shape is same as q.
Decode Stage
Now the decoder part is much clearer, we use another flash attention variant:
1
2
flash_attn_with_kvcache(q, k_cache, v_cache,
cache_seqlens, block_table, softmax_scale, causal)
For this case,
qhas shape[num_seqs, 1, num_heads, head_dim]. Note that the q.shape[1] is 1, because there is always one new token for each decoding iteration.k_cache,v_cache,block_tableis same as the above Prefilling case 2.cache_seqlensis a 1D tensor of lengthnum_seqs, andcache_seqlens[i]means the current number of tokens for sequencei. This is needed because the cache block may be only partially filled.
Tensor Parallelism
nano-vllm also support tensor parallelism, i.e., the tensors are partitioned to multiple GPUs. Let’s examine how this was achieved for each layer.
Embedding Layer (VocabParallelEmbedding)
For the embedding layer, the embedding matrix are partitioned by the token ids axis, i.e. each GPU loaded a range of token ids’ embeddings. For the forward call, it masks the input token ids outside of its own range, only to apply the embeddings belongs to its range. Then use dist.all_reduce() to collect the results.
MLP Layer (Qwen3MLP)
The key parts are two linear layers, up and down. The up matrix shape is [hidden_size, intermediate_size], and the down matrix is [intermediate_size, hidden_size]. The input will be multiply the first matrix, then apply a non-linear transformation, then multiply the second matrix. (I’m a bit simplified here but the idea is same.) Now the problem is how to partition these two matrixes.
Turns out we should partition the up matrix by column (ColumnParallelLinear) and the down matrix by row ( RowParallelLinear ), so that each GPU will hold up matrix [hidden_size, intermediate_size / world_size] and down matrix [intermediate_size / world_size, hidden_size] .
Let’s say the input shape is [tokens, hidden_size] . For the first matmul we don’t need do anything special, then each GPU got a result of size [tokens, intermediate_size / world_size]. Then we do the second matmul, each GPU got a result of size [tokens, hidden_size]. However each GPU has only partially data now, we need sum them together by using dist.all_reduce(). Now all the GPUs have the same correct result.
Attention Layer (Qwen3Attention)
We partitioned the qkv projection matrix by the num_head axis (essentially the ColumnParallelLinear ), so each GPU handles a subset of heads. Obviously the attention computation of each head is independent. The only reduce happens when we do the last O projection. Similar as the MLP layer, we partition the O projection matrix by row, then do a final all_reduce() at the end.
I’m afraid my description about TP may be too simple here. If you don’t quite understand what I’m talking about, there are a lot of great articles about tensor parallelism in LLM on the Internet.
Misc implementation details
Where does k_cache, v_cache in Attention get initialized?
It confused me quite some time, because they’re not initialize in attention.py. The init actually happens in allocate_kv_cache() in model_runner.py , called during ModelRunner.init()
Firstly one big tensor is allocated for the entire KV cache, shaped [2, num_layers, num_blocks, block_size, num_heads, head_dim] - the 2 is for K and V, and the block_size is computed based on available total GPU mem. Then there is a for-loop going through all modules of the model. If one module has k_cache/v_cache attributes, it will be assigned a slice of the big tensor. (I personally don’t quite like this kind of hard-coded name, but it’s simple and working.)
What is the CUDA graph in decode stage, and why not for prefill?
CUDA graph allows us to define a series of kernels (whose dependencies forms a graph) once, then launch the graph multiple times afterward. It can reduce the kernels launch overhead - instead launch multiple kernels from time to time, we just need launch the whole graph once. The limitation is that the input tensor size and address must be static.
In the decode stage, the only different size across different runnings is the number of sequences in a batch, all other shape dimensions are static. So nano-vllm pre-captures multiple graphs for different num_seqs values like [1, 2, 4, 8, 16, .. 512] . At inference time the engine picks the smaller viable one to run.
On the other hand, the sizes of prefill stage is much more variable - different num_seqs, different prompt lengths, etc. So it’s not quite possible to predefine some sizes.
Also, the prefill stage tends to be compute-bound, which means the kernel launch overhead is small comparing with the kernel running time itself. But for the decode stage the kernel launch overhead is significant. (The prefill stage is pretty much matrix-matrix multiplication, while the decoding stage is vector-matrix multiplication.)

