Aparajith24/nano-vLLM

A mini vLLM in PyTorch

Python

5

16 commits

updated Oct 16, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a tiny LLM inference engine to learn how vLLM works - looking for feedback (r/LLMDevs)

I've been learning how LLM inference engines work, so I built a small one from scratch rather than just reading about the internals. GitHub: [https://github.com/Aparajith24/nano-vLLM](https://github.com/Aparajith24/nano-vLLM) The main things I've implemented so far: * Continuous batching * Paged KV…

5

Oct 16, 2026

README

nano-vLLM

A small LLM inference engine, built to understand how engines like vLLM work by building the core ideas and measuring each one:

  • an OpenAI-compatible POST /v1/completions endpoint that streams tokens over SSE;
  • continuous batching: users join and leave between decode steps;
  • our own SmolLM2 forward pass (RMSNorm, RoPE, GQA, SwiGLU), checked against Hugging Face;
  • a paged KV cache: one shared pool of 16-token blocks, a block table per user, nothing re-padded or copied between steps;
  • prefix caching: users whose prompts start the same share those blocks and skip prefilling them.

Every change is measured against a baseline server (baseline_server.py, kept unchanged so it can be re-run) that keeps a separate KV cache per user and pads all of them to the same length every decode step.

Headline results

Apple M4, 16 GB, Mac GPU (MPS), SmolLM2-135M at fp32, each user asks for 100 tokens, at most 30 users decoding at once.

Baselinenano-vLLMChange
Total throughput, 30 users189 tok/s515 tok/s2.7×
One decode step, ~30 users132 ms48 ms2.8× faster
Gap between tokens (ITL p50), 30 users164 ms46 ms3.6× smaller
Throughput, 1 user28 tok/s38 tok/s1.4×
TTFT p99, 40 users (10 wait for a seat)15.5 s5.9 s2.6× smaller
TTFT p50, 30 users sharing a 500-token prompt2.4 s (caching off)0.70 s (caching on)3.4× smaller

Total throughput against users

TTFT and ITL against users

How it works

 client ──POST /v1/completions──▶ FastAPI handler (event loop)
                                    │ tokenize, check the length
                                    ▼
                               waiting line ──▶ BatchEngine thread, one round at a time:
                                                 1. free the blocks of finished or disconnected users
                                                 2. seat users from the front of the line while a seat
                                                    and enough blocks for prompt + max_tokens are free;
                                                    reuse cached blocks for a known prompt prefix
                                                 3. prefill each new user (only the uncached part)
                                                 4. decode one token for every seated user
 client ◀──SSE, one token each── handler ◀──── tokens through an asyncio.Queue

 inside one decode step, for each of the 30 layers:
   new token ─▶ RMSNorm ─▶ Q, K, V ─▶ RoPE at position n
                                  ├─▶ write K, V into block table[n // 16], slot n % 16
                                  └─▶ gather every user's blocks (one gather per layer, short
                                      tables filled out with dummy block 0) ─▶ masked attention
                                      (query heads grouped onto their shared K/V head) ─▶ o_proj
                ─▶ RMSNorm ─▶ SwiGLU MLP
 shared pool (one K and one V tensor per layer: [blocks, 16, 3 KV heads, 64])
 ┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
 │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │...│   block 0: dummy, never handed out
 └───┴───┴───┴───┴───┴───┴───┴───┴───┘
 user A table: [1, 2, 5]    user B table: [1, 2, 6]    ← both start with the same
                                                          32-token system prompt,
                                                          so blocks 1 and 2 are shared
FileWhat it is
server.pyThe API, the waiting line, and the paged BatchEngine
model.pyOur SmolLM2: plain forward pass, paged prefill, paged batched decode
blocks.pyBlockAllocator (free list, reference counts, prefix cache) and KVPool
baseline_server.pyThe starting point, kept for comparisons
bench.pyLoad test: N users at once, saves every token's arrival time
profile_decode.pyTimes each part of one paged decode step
plot.pyDraws the charts in docs/ from results/
test_*.pyCorrectness checks (see below)

How to run it

python3 server.py

In a second terminal:

curl -N -X POST localhost:8080/v1/completions -H "Content-Type: application/json" \
     -d '{"prompt": "The cat sat on the", "max_tokens": 20}'

python3 baseline_server.py runs the baseline instead, on the same port, so run only one at a time. NANO_PREFIX_CACHE=0 python3 server.py turns prefix caching off.

Load test (the server must be running), then redraw the charts:

nice -n 10 python3 bench.py --label my-run --users 1 10 30 40
nice -n 10 python3 bench.py --label shared --users 1 10 30 --system-repeats 35
python3 plot.py

Tests, each printing what it compares:

python3 test_blocks.py      # allocator: toy pool, sharing, reviving, eviction
python3 test_kv_pool.py     # write path: values read back exactly through tables
python3 test_forward.py     # our forward pass against Hugging Face, piece by piece
python3 test_paged.py       # paged prefill/decode/prefix against Hugging Face
python3 test_engine.py      # the server's engine: greedy answers equal HF generate

Where the time went

Before: half of every step was rearranging the cache

The baseline's stopwatches (waiting for the GPU before each reading) showed where one decode step goes:

Part1 user10 users28 users
pad/concat7.9 (23%)23.0 (30%)65.8 (50%)
forward23.4 (69%)48.3 (63%)58.1 (44%)
sampling2.34.06.0
slice-back0.10.82.4
sending0.010.010.02
whole step33.776.2132.3

At 30 users, every step padded each user's K and V separately in every layer: 1,800 pads and 60 concatenates, all to build a rectangle that was thrown away after one step. Slicing back was almost free because slicing makes a view, not a copy.

After: no cache rearranging, and faster attention

One decode step before and after

  1. Paged KV cache (58 ms per step). K and V are written once into their block and never moved. A step does one gather per layer for K and one for V, whatever the number of users.
  2. Attention by reshape instead of SDPA (48 ms). On MPS, PyTorch's SDPA with a mask and grouped heads took 3.3–3.5× longer than a plain matmul-softmax-matmul where each group of 3 query heads is reshaped onto its shared K/V head (no copy). Same results to 6e-7.

What is left: attention grows with users × context

profile_decode.py with every part timed separately:

UsersContextStepnorm+QKV+RoPEwritegatherattentiono_proj+MLP
16419 ms11.66.06.48.513.6
286439 ms13.86.013.216.818.0
28512180–190 ms13.76.145.4134.116.7

(The parts add up to more than the step because of the extra GPU waits.)

  • With 1 user, the step is mostly fixed overhead. Reading all 540 MB of weights at about 120 GB/s takes about 4.5 ms, but the step takes 19 ms: about 30 layers × ~20 small GPU operations, each with a launch cost. Paging cannot fix that; fusing operations (or CUDA graphs on NVIDIA) would.
  • With long contexts, the gather and attention dominate. A step costs about 13–14 ms per 1,000 user-tokens, the same for 30 × 540, 16 × 1,000 and 8 × 2,000. The gather is still a copy: real vLLM's PagedAttention kernel reads blocks in place, which needs a custom GPU kernel.
  • Shared prompt blocks are gathered once per user. With a 500-token shared system prompt, 30 users gather the same blocks 30 times every step, so prefix caching helps prefill (TTFT) but not decode.

Prefix caching

TTFT with prefix caching off and on

Every user sends the same ~500-token system prompt plus their own question. With caching on, the first user prefills everything; everyone after reuses the cached full blocks and prefills only the last block and their question.

UsersTTFT p50 offTTFT p50 onTTFT p99 offTTFT p99 on
1321 ms205 ms321 ms205 ms
10842 ms400 ms1.56 s764 ms
302.39 s701 ms4.51 s1.22 s

Correctness

Speed means nothing if the answers change, so every step has a test that compares against Hugging Face with sampling turned off:

TestWhat it provesResult
test_forward.pyEach piece of our model (RMSNorm, RoPE, attention, MLP) and the full logits equal HF's0 difference on CPU, ≤ 2.1e-5 on MPS
test_paged.pyPaged prefill and decode, teacher-forced across block boundaries, 1 user and 4 users of different lengths (one joining late), with the pool filled with garbage firstSame tokens as HF everywhere, logits within 3.5e-4
test_paged.py (prefix)A user reusing another user's cached blocks still matches HFSame tokens, logits within 9e-5
test_engine.pyThe server's engine gives exactly HF generate's greedy answers, with a big pool and with a pool so small users must wait7 of 7 prompts identical
test_blocks.py, test_kv_pool.pyTables, free lists and reference counts after every step; values read back exactlyAll asserts pass

Two lessons from testing:

  • Comparing only the chosen token hides bugs. RoPE broken on purpose (theta 10,000 instead of 100,000) moved the logits by 1.49 but kept the same top token. The tests compare logits.
  • The garbage pool proves the mask works. Masked slots get weight 0, so huge garbage values there change nothing (0 × garbage = 0). NaN would not work as garbage: 0 × NaN is still NaN.

Decision log

#QuestionMy decisionWhy
1Where to startThe baseline server, unchangedIt already has the API, SSE and continuous batching; the project is about the engine inside
2What to do firstMeasure a baseline before changing anythingWithout a "before" number, no change can be proven
3Largest load test40 users60 users made the Mac hang; 40 still tests the waiting line (30 seats)
4ModelSmolLM2-135M at fp32 instead of distilgpt2distilgpt2 (2019) is an old design. SmolLM2 has the modern Llama design (RoPE, GQA, RMSNorm, SwiGLU), is barely bigger, and writes far better English
5Context limit1,024 tokens, our own settingSmolLM2 allows 8,192, but 1,024 keeps the comparison with distilgpt2 fair and memory small
6How to find the slow partStopwatches around each part of a decode step, waiting for the GPU before each readingThe GPU runs in the background; without waiting, the forward time would hide inside sampling
7How to remove the pad/concat costWrite our own SmolLM2 forward pass (option B) instead of refilling Hugging Face's cache every step (option A)We control every copy, it is what vLLM does, and it is the only way to attack the 1-user forward overhead later
8How attention reads a paged cacheOne shared pool of 16-token blocks, one block table per user, tables filled out with a reserved dummy block 0, one gather per layer for K and one for V, and a mask that hides dummy and unused slots60 gathers per step whatever the number of users, instead of 1,800 pads and 60 concatenates. Honest limit: the gather is still a copy
9How attention handles GQA (9 query heads, 3 K/V heads) in prefillSDPA with enable_gqa=True instead of copying K/V 3× with repeat_kvOne call and no copy written by us. Measured on MPS: about the same speed as repeat_kv. Later replaced for paged attention (decision 14)
10How big the block pool isA setting, default 1,920 blocks (30 users × 1,024 tokens, 1.32 GiB)The server never runs out at the default, and tests use a tiny pool to force the "pool full" case
11What the allocator does when it can't satisfy a requestAll or nothing: it raises PoolFull and hands out no blocks; a bad free also changes nothingA half-seated user or a half-freed list would leave the pool in a state nobody can reason about
12Shape of the poolOne K and one V tensor per layer, [blocks, block_size, kv_heads, head_dim]; token n lives in block table[n // 16], slot n % 16After a gather, blocks × block_size merge into one token axis for free, and swapping heads and tokens is only a view
13When the pool is fullReserve at the door: a user is seated only if blocks for prompt + max_tokens are free; otherwise they wait at the front of the lineIt can never run out in the middle of a step, so no user has to be evicted and recomputed. It wastes blocks when answers end early; vLLM's preemption is the upgrade
14Attention in paged prefill and decodeMatmul-softmax-matmul, with each group of query heads reshaped onto its K/V head, instead of SDPAMeasured on MPS: 3.3–3.5× faster than SDPA with a mask, same results to 6e-7; took a step at 30 users from 58 ms to 48 ms
15Test tolerance for paged decode5e-4 on CPU, 1e-3 on MPS, plus "same token at every step"Hugging Face's own step-by-step decoding differs from its own full forward by 1e-4; logits reach about 40, so 3e-4 is a relative error of about 1e-5
16What prefix caching sharesFull 16-token prompt blocks, found by a chained hash (each block's fingerprint covers every token before it), with reference counts; the last prompt token is always recomputedA chained hash means a match always covers the whole prefix. The last token must be computed to get its logits. Decode never writes into a full prompt block, so shared blocks are never changed
17What happens to a cached block when its users leaveIt goes back to the free list but keeps its fingerprint, and is revived if someone asks for it before it is handed out againFree memory doubles as a cache at no cost; reuse order is oldest freed first
18When users arrive togetherSeat and prefill them one at a timeOtherwise everyone arriving in the same moment would look up the cache before anyone had filled it, and nobody would share
19SeatsKeep 30Not re-measured for the paged engine; 30 users fit easily in the pool, and keeping the cap makes every before/after comparison fair

Limits

  • A tiny model (135M) on a Mac GPU, fp32, one machine, no tensor parallelism, no quantization.
  • The paged attention is a gather plus plain PyTorch operations, not a fused GPU kernel, so the gather is still a copy and long contexts cost about 13–14 ms per 1,000 user-tokens per step.
  • New users are prefilled one at a time, so TTFT grows with the number of users arriving together (575 ms p50 for 30 at once).
  • Reserving prompt + max_tokens at the door wastes blocks when answers stop early; there is no preemption.
  • Measurements on MPS vary between runs (the same decode step measured 98 ms and 190 ms in different sessions); compare runs from the same session.
  • The load test client runs on the same Mac as the server.
  • No live metrics endpoint: the numbers come from bench.py and the server's stopwatch reports.

Next steps

  1. Batched prefill: prefill everyone who arrives together in one forward pass (with a token budget) to cut TTFT.
  2. Preemption instead of reserving max_tokens up front.
  3. A real paged attention kernel (Metal or Triton on NVIDIA) that reads blocks in place, so shared prefix blocks are read once, not gathered per user.
  4. A /metrics endpoint with TTFT, ITL, batch size and free blocks while the server runs.
fastapi
inference-engine
kv-cache
llm
llm-inference
pytorch
vllm

Aparajith24/nano-vLLM

A mini vLLM in PyTorch

Python

5

16 commits

updated Oct 16, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a tiny LLM inference engine to learn how vLLM works - looking for feedback (r/LLMDevs)

I've been learning how LLM inference engines work, so I built a small one from scratch rather than just reading about the internals. GitHub: [https://github.com/Aparajith24/nano-vLLM](https://github.com/Aparajith24/nano-vLLM) The main things I've implemented so far: * Continuous batching * Paged KV…

5

Oct 16, 2026

README

nano-vLLM

A small LLM inference engine, built to understand how engines like vLLM work by building the core ideas and measuring each one:

  • an OpenAI-compatible POST /v1/completions endpoint that streams tokens over SSE;
  • continuous batching: users join and leave between decode steps;
  • our own SmolLM2 forward pass (RMSNorm, RoPE, GQA, SwiGLU), checked against Hugging Face;
  • a paged KV cache: one shared pool of 16-token blocks, a block table per user, nothing re-padded or copied between steps;
  • prefix caching: users whose prompts start the same share those blocks and skip prefilling them.

Every change is measured against a baseline server (baseline_server.py, kept unchanged so it can be re-run) that keeps a separate KV cache per user and pads all of them to the same length every decode step.

Headline results

Apple M4, 16 GB, Mac GPU (MPS), SmolLM2-135M at fp32, each user asks for 100 tokens, at most 30 users decoding at once.

Baselinenano-vLLMChange
Total throughput, 30 users189 tok/s515 tok/s2.7×
One decode step, ~30 users132 ms48 ms2.8× faster
Gap between tokens (ITL p50), 30 users164 ms46 ms3.6× smaller
Throughput, 1 user28 tok/s38 tok/s1.4×
TTFT p99, 40 users (10 wait for a seat)15.5 s5.9 s2.6× smaller
TTFT p50, 30 users sharing a 500-token prompt2.4 s (caching off)0.70 s (caching on)3.4× smaller

Total throughput against users

TTFT and ITL against users

How it works

 client ──POST /v1/completions──▶ FastAPI handler (event loop)
                                    │ tokenize, check the length
                                    ▼
                               waiting line ──▶ BatchEngine thread, one round at a time:
                                                 1. free the blocks of finished or disconnected users
                                                 2. seat users from the front of the line while a seat
                                                    and enough blocks for prompt + max_tokens are free;
                                                    reuse cached blocks for a known prompt prefix
                                                 3. prefill each new user (only the uncached part)
                                                 4. decode one token for every seated user
 client ◀──SSE, one token each── handler ◀──── tokens through an asyncio.Queue

 inside one decode step, for each of the 30 layers:
   new token ─▶ RMSNorm ─▶ Q, K, V ─▶ RoPE at position n
                                  ├─▶ write K, V into block table[n // 16], slot n % 16
                                  └─▶ gather every user's blocks (one gather per layer, short
                                      tables filled out with dummy block 0) ─▶ masked attention
                                      (query heads grouped onto their shared K/V head) ─▶ o_proj
                ─▶ RMSNorm ─▶ SwiGLU MLP
 shared pool (one K and one V tensor per layer: [blocks, 16, 3 KV heads, 64])
 ┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
 │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │...│   block 0: dummy, never handed out
 └───┴───┴───┴───┴───┴───┴───┴───┴───┘
 user A table: [1, 2, 5]    user B table: [1, 2, 6]    ← both start with the same
                                                          32-token system prompt,
                                                          so blocks 1 and 2 are shared
FileWhat it is
server.pyThe API, the waiting line, and the paged BatchEngine
model.pyOur SmolLM2: plain forward pass, paged prefill, paged batched decode
blocks.pyBlockAllocator (free list, reference counts, prefix cache) and KVPool
baseline_server.pyThe starting point, kept for comparisons
bench.pyLoad test: N users at once, saves every token's arrival time
profile_decode.pyTimes each part of one paged decode step
plot.pyDraws the charts in docs/ from results/
test_*.pyCorrectness checks (see below)

How to run it

python3 server.py

In a second terminal:

curl -N -X POST localhost:8080/v1/completions -H "Content-Type: application/json" \
     -d '{"prompt": "The cat sat on the", "max_tokens": 20}'

python3 baseline_server.py runs the baseline instead, on the same port, so run only one at a time. NANO_PREFIX_CACHE=0 python3 server.py turns prefix caching off.

Load test (the server must be running), then redraw the charts:

nice -n 10 python3 bench.py --label my-run --users 1 10 30 40
nice -n 10 python3 bench.py --label shared --users 1 10 30 --system-repeats 35
python3 plot.py

Tests, each printing what it compares:

python3 test_blocks.py      # allocator: toy pool, sharing, reviving, eviction
python3 test_kv_pool.py     # write path: values read back exactly through tables
python3 test_forward.py     # our forward pass against Hugging Face, piece by piece
python3 test_paged.py       # paged prefill/decode/prefix against Hugging Face
python3 test_engine.py      # the server's engine: greedy answers equal HF generate

Where the time went

Before: half of every step was rearranging the cache

The baseline's stopwatches (waiting for the GPU before each reading) showed where one decode step goes:

Part1 user10 users28 users
pad/concat7.9 (23%)23.0 (30%)65.8 (50%)
forward23.4 (69%)48.3 (63%)58.1 (44%)
sampling2.34.06.0
slice-back0.10.82.4
sending0.010.010.02
whole step33.776.2132.3

At 30 users, every step padded each user's K and V separately in every layer: 1,800 pads and 60 concatenates, all to build a rectangle that was thrown away after one step. Slicing back was almost free because slicing makes a view, not a copy.

After: no cache rearranging, and faster attention

One decode step before and after

  1. Paged KV cache (58 ms per step). K and V are written once into their block and never moved. A step does one gather per layer for K and one for V, whatever the number of users.
  2. Attention by reshape instead of SDPA (48 ms). On MPS, PyTorch's SDPA with a mask and grouped heads took 3.3–3.5× longer than a plain matmul-softmax-matmul where each group of 3 query heads is reshaped onto its shared K/V head (no copy). Same results to 6e-7.

What is left: attention grows with users × context

profile_decode.py with every part timed separately:

UsersContextStepnorm+QKV+RoPEwritegatherattentiono_proj+MLP
16419 ms11.66.06.48.513.6
286439 ms13.86.013.216.818.0
28512180–190 ms13.76.145.4134.116.7

(The parts add up to more than the step because of the extra GPU waits.)

  • With 1 user, the step is mostly fixed overhead. Reading all 540 MB of weights at about 120 GB/s takes about 4.5 ms, but the step takes 19 ms: about 30 layers × ~20 small GPU operations, each with a launch cost. Paging cannot fix that; fusing operations (or CUDA graphs on NVIDIA) would.
  • With long contexts, the gather and attention dominate. A step costs about 13–14 ms per 1,000 user-tokens, the same for 30 × 540, 16 × 1,000 and 8 × 2,000. The gather is still a copy: real vLLM's PagedAttention kernel reads blocks in place, which needs a custom GPU kernel.
  • Shared prompt blocks are gathered once per user. With a 500-token shared system prompt, 30 users gather the same blocks 30 times every step, so prefix caching helps prefill (TTFT) but not decode.

Prefix caching

TTFT with prefix caching off and on

Every user sends the same ~500-token system prompt plus their own question. With caching on, the first user prefills everything; everyone after reuses the cached full blocks and prefills only the last block and their question.

UsersTTFT p50 offTTFT p50 onTTFT p99 offTTFT p99 on
1321 ms205 ms321 ms205 ms
10842 ms400 ms1.56 s764 ms
302.39 s701 ms4.51 s1.22 s

Correctness

Speed means nothing if the answers change, so every step has a test that compares against Hugging Face with sampling turned off:

TestWhat it provesResult
test_forward.pyEach piece of our model (RMSNorm, RoPE, attention, MLP) and the full logits equal HF's0 difference on CPU, ≤ 2.1e-5 on MPS
test_paged.pyPaged prefill and decode, teacher-forced across block boundaries, 1 user and 4 users of different lengths (one joining late), with the pool filled with garbage firstSame tokens as HF everywhere, logits within 3.5e-4
test_paged.py (prefix)A user reusing another user's cached blocks still matches HFSame tokens, logits within 9e-5
test_engine.pyThe server's engine gives exactly HF generate's greedy answers, with a big pool and with a pool so small users must wait7 of 7 prompts identical
test_blocks.py, test_kv_pool.pyTables, free lists and reference counts after every step; values read back exactlyAll asserts pass

Two lessons from testing:

  • Comparing only the chosen token hides bugs. RoPE broken on purpose (theta 10,000 instead of 100,000) moved the logits by 1.49 but kept the same top token. The tests compare logits.
  • The garbage pool proves the mask works. Masked slots get weight 0, so huge garbage values there change nothing (0 × garbage = 0). NaN would not work as garbage: 0 × NaN is still NaN.

Decision log

#QuestionMy decisionWhy
1Where to startThe baseline server, unchangedIt already has the API, SSE and continuous batching; the project is about the engine inside
2What to do firstMeasure a baseline before changing anythingWithout a "before" number, no change can be proven
3Largest load test40 users60 users made the Mac hang; 40 still tests the waiting line (30 seats)
4ModelSmolLM2-135M at fp32 instead of distilgpt2distilgpt2 (2019) is an old design. SmolLM2 has the modern Llama design (RoPE, GQA, RMSNorm, SwiGLU), is barely bigger, and writes far better English
5Context limit1,024 tokens, our own settingSmolLM2 allows 8,192, but 1,024 keeps the comparison with distilgpt2 fair and memory small
6How to find the slow partStopwatches around each part of a decode step, waiting for the GPU before each readingThe GPU runs in the background; without waiting, the forward time would hide inside sampling
7How to remove the pad/concat costWrite our own SmolLM2 forward pass (option B) instead of refilling Hugging Face's cache every step (option A)We control every copy, it is what vLLM does, and it is the only way to attack the 1-user forward overhead later
8How attention reads a paged cacheOne shared pool of 16-token blocks, one block table per user, tables filled out with a reserved dummy block 0, one gather per layer for K and one for V, and a mask that hides dummy and unused slots60 gathers per step whatever the number of users, instead of 1,800 pads and 60 concatenates. Honest limit: the gather is still a copy
9How attention handles GQA (9 query heads, 3 K/V heads) in prefillSDPA with enable_gqa=True instead of copying K/V 3× with repeat_kvOne call and no copy written by us. Measured on MPS: about the same speed as repeat_kv. Later replaced for paged attention (decision 14)
10How big the block pool isA setting, default 1,920 blocks (30 users × 1,024 tokens, 1.32 GiB)The server never runs out at the default, and tests use a tiny pool to force the "pool full" case
11What the allocator does when it can't satisfy a requestAll or nothing: it raises PoolFull and hands out no blocks; a bad free also changes nothingA half-seated user or a half-freed list would leave the pool in a state nobody can reason about
12Shape of the poolOne K and one V tensor per layer, [blocks, block_size, kv_heads, head_dim]; token n lives in block table[n // 16], slot n % 16After a gather, blocks × block_size merge into one token axis for free, and swapping heads and tokens is only a view
13When the pool is fullReserve at the door: a user is seated only if blocks for prompt + max_tokens are free; otherwise they wait at the front of the lineIt can never run out in the middle of a step, so no user has to be evicted and recomputed. It wastes blocks when answers end early; vLLM's preemption is the upgrade
14Attention in paged prefill and decodeMatmul-softmax-matmul, with each group of query heads reshaped onto its K/V head, instead of SDPAMeasured on MPS: 3.3–3.5× faster than SDPA with a mask, same results to 6e-7; took a step at 30 users from 58 ms to 48 ms
15Test tolerance for paged decode5e-4 on CPU, 1e-3 on MPS, plus "same token at every step"Hugging Face's own step-by-step decoding differs from its own full forward by 1e-4; logits reach about 40, so 3e-4 is a relative error of about 1e-5
16What prefix caching sharesFull 16-token prompt blocks, found by a chained hash (each block's fingerprint covers every token before it), with reference counts; the last prompt token is always recomputedA chained hash means a match always covers the whole prefix. The last token must be computed to get its logits. Decode never writes into a full prompt block, so shared blocks are never changed
17What happens to a cached block when its users leaveIt goes back to the free list but keeps its fingerprint, and is revived if someone asks for it before it is handed out againFree memory doubles as a cache at no cost; reuse order is oldest freed first
18When users arrive togetherSeat and prefill them one at a timeOtherwise everyone arriving in the same moment would look up the cache before anyone had filled it, and nobody would share
19SeatsKeep 30Not re-measured for the paged engine; 30 users fit easily in the pool, and keeping the cap makes every before/after comparison fair

Limits

  • A tiny model (135M) on a Mac GPU, fp32, one machine, no tensor parallelism, no quantization.
  • The paged attention is a gather plus plain PyTorch operations, not a fused GPU kernel, so the gather is still a copy and long contexts cost about 13–14 ms per 1,000 user-tokens per step.
  • New users are prefilled one at a time, so TTFT grows with the number of users arriving together (575 ms p50 for 30 at once).
  • Reserving prompt + max_tokens at the door wastes blocks when answers stop early; there is no preemption.
  • Measurements on MPS vary between runs (the same decode step measured 98 ms and 190 ms in different sessions); compare runs from the same session.
  • The load test client runs on the same Mac as the server.
  • No live metrics endpoint: the numbers come from bench.py and the server's stopwatch reports.

Next steps

  1. Batched prefill: prefill everyone who arrives together in one forward pass (with a token budget) to cut TTFT.
  2. Preemption instead of reserving max_tokens up front.
  3. A real paged attention kernel (Metal or Triton on NVIDIA) that reads blocks in place, so shared prefix blocks are read once, not gathered per user.
  4. A /metrics endpoint with TTFT, ITL, batch size and free blocks while the server runs.
fastapi
inference-engine
kv-cache
llm
llm-inference
pytorch
vllm