A small LLM inference engine in the style of vLLM, written from scratch in PyTorch (about 1,600 lines). It serves Qwen3 models through an OpenAI-compatible API and implements the core ideas of a modern serving engine:
- Paged KV cache. K/V live in fixed-size blocks. Each request holds a block table, so memory is allocated per block
as a sequence grows instead of reserving
max_model_lenup front. - Continuous batching. The scheduler rebuilds the batch every step: finished requests leave and new ones join without waiting for the rest of the batch.
- Chunked prefill. Each step has a token budget. Long prompts are split across steps and share them with running decodes, so a 2k-token prompt doesn't stall everyone else's next token.
- Prefix caching. Full blocks are content-addressed by a chained hash (parent hash + tokens). Requests that share a prefix (system prompt, few-shot examples, multi-turn chat) reuse the cached blocks and skip that prefill. Freed blocks stay cached in an LRU pool until the memory is needed.
- Preemption by recompute. When the cache is full, the newest running request gives up its blocks and is requeued at the front. Its prefix is often still cached when it comes back.
- Triton paged-attention decode kernel. On CUDA, every decode reads K/V straight from the block table with an online softmax instead of gathering them into a padded buffer, including in steps that also carry prefill chunks. 14 to 19x faster than the gather path at batch 16 to 64 on an L4.
- Packed batches. All new tokens in a step are packed into one
[T]dimension; only attention needs per-sequence structure. QKV and gate/up projections are fused into one matmul each. - Sampling. Temperature, top-k, top-p with one shared sort, and per-request seeds that give the same tokens no matter what else is in the batch.
- OpenAI-compatible server.
/v1/completionsand/v1/chat/completionswith SSE streaming, prompt-cache usage in the response (prompt_tokens_details.cached_tokens), client-disconnect aborts, and Prometheus/metrics.
Correctness is tested against Hugging Face transformers: greedy decoding produces exactly the same tokens as HF for
every request, under continuous batching, chunked prefill, prefix-cache hits and preemption.
flowchart LR
C[HTTP clients] -->|/v1/chat/completions, SSE| S[FastAPI server]
S -->|token ids + params| AE[AsyncEngine<br/>engine thread]
AE --> E[LLMEngine.step]
E --> SCH[Scheduler<br/>token budget, FCFS,<br/>preemption]
SCH <--> BM[BlockManager<br/>block tables, ref counts,<br/>prefix hash, LRU]
E --> MR[ModelRunner<br/>packed inputs,<br/>attention metadata]
MR --> M[Qwen3 forward]
M <--> KV[(Paged KV cache<br/>layers x 2 x blocks x 16 x H x D)]
M --> SA[Sampler]
SA -->|new tokens| AE
AE -->|per-request asyncio queues| S
One engine step:
- Schedule. Running requests first (1 token per decode, the next chunk for a request still in prefill), then waiting requests while the token budget and free blocks allow. On admission, the block manager matches the longest cached prefix and marks those tokens as already computed.
- Prepare. Flatten every request's new tokens into
input_ids[T]andpositions[T], compute each token's cache slot from its block table, and build the attention metadata once for all layers. - Forward. Each layer writes this step's K/V into the paged cache, then attends over the full context through the block table. Logits are computed only for requests whose prompt is fully processed.
- Sample and update. Append tokens, publish newly full blocks to the prefix cache, finish requests on EOS or
max_tokens, and free their blocks.
| File | What it does |
|---|---|
block_manager.py |
Block allocation, ref counts, chained-hash prefix cache, LRU eviction |
scheduler.py |
Continuous batching with a token budget, chunked prefill, preemption |
model_runner.py |
Weight loading, KV cache sizing, packing a batch into one forward pass |
attention.py |
Paged attention (torch gather + SDPA path for any mix of prefill and decode) |
triton_attention.py |
Triton paged decode kernel with online softmax and GQA |
models/qwen3.py |
Qwen3 decoder for packed, paged inference (fused QKV and gate/up) |
sampler.py |
Batched temperature, top-k, top-p and seeded sampling |
engine.py, async_engine.py |
Step loop, stop conditions, background engine thread |
server.py |
OpenAI-compatible API, streaming, Prometheus metrics |
git clone https://github.com/cipheraxat/nanoserve && cd nanoserve
uv sync
uv run nanoserve --model Qwen/Qwen3-0.6B --port 8000curl -s localhost:8000/v1/chat/completions -H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"What is 17*23?"}],"max_tokens":32,"temperature":0}'Offline use:
from nanoserve import EngineConfig, LLMEngine, SamplingParams
engine = LLMEngine(EngineConfig(model="Qwen/Qwen3-0.6B", max_num_batched_tokens=512))
outputs = engine.generate(["The capital of France is"], SamplingParams(max_tokens=16, temperature=0))
print(engine.tokenizer.decode(outputs[0]))Useful flags: --max-num-seqs, --max-num-batched-tokens (the chunked-prefill budget), --kv-cache-memory-gb,
--block-size, --no-prefix-caching, --attention-backend {auto,torch,triton}.
All runs use Qwen3-0.6B in bf16 with greedy decoding and ignore_eos, so every request generates exactly the tokens
it asked for.
Continuous batching vs static batching. 128 requests with 32 to 512 prompt tokens and 16 to 256 output tokens, at most 64 in flight:
| system | time | output tok/s |
|---|---|---|
| nanoserve (continuous batching, paged KV, Triton decode) | 20.5 s | 915 |
Hugging Face generate, static batches of 64 |
80.7 s | 233 |
3.9x the throughput. Static batching pads every batch to its longest prompt and keeps decoding until its longest request is done; continuous batching refills the slot as soon as a request finishes.
Triton paged decode kernel vs torch gather + SDPA, one attention layer at Qwen3-0.6B's shape (16 query heads, 8 KV heads, head dim 128), bf16:
| batch | context | torch gather | Triton paged | speedup |
|---|---|---|---|---|
| 1 | 2,048 | 0.46 ms | 0.18 ms | 2.5x |
| 16 | 2,048 | 10.86 ms | 0.62 ms | 17.4x |
| 64 | 512 | 10.65 ms | 0.58 ms | 18.5x |
| 64 | 2,048 | 43.76 ms | 2.66 ms | 16.4x |
| 64 | 8,192 | 171.71 ms | 12.50 ms | 13.7x |
Max absolute difference from the reference is within bf16 rounding (at most 5e-4).
The kernel only paid off once it handled every decode. Under continuous batching most steps carry a prefill chunk next to the running decodes, and at first those mixed steps sent every sequence down the gather path. With that design nanoserve reached only 168 tok/s on the benchmark above, slower than Hugging Face. The engine now splits each step: decodes go through the kernel, prefill chunks through the gather path, and the results are scattered back into the packed batch. That took throughput from 168 to 915 tok/s.
Prefix caching. 32 requests share a 1,024-token system prompt, each with a different 32-token suffix and 32 output tokens:
| prefix caching | total time | prompt tokens computed | cache hit rate | TTFT p50 | TTFT p99 |
|---|---|---|---|---|---|
| off | 11.1 s | 34,784 | 0% | 5.22 s | 10.00 s |
| on | 4.9 s | 3,040 | 94% | 2.03 s | 3.82 s |
11x less prefill work, 2.3x faster end to end, 2.6x lower median TTFT.
Chunked prefill. 16 requests are decoding while six 2,048-token prompts arrive. Inter-token latency (ITL) seen by the decoding requests:
| token budget per step | ITL p50 | ITL p99 | ITL max |
|---|---|---|---|
| unlimited (whole prompt in one step) | 35 ms | 473 ms | 474 ms |
| 512 | 35 ms | 130 ms | 130 ms |
| 256 | 35 ms | 65 ms | 65 ms |
A 256-token budget cuts the worst decode stall 7.3x with no change to median ITL.
These were taken before the decode/prefill split, which only matters on CUDA.
Prefix caching. 32 requests share a 1,024-token system prompt, each with a different 32-token suffix and 32 output tokens:
| prefix caching | total time | prompt tokens computed | cache hit rate | TTFT p50 | TTFT p99 |
|---|---|---|---|---|---|
| off | 100.4 s | 34,784 | 0% | 47.1 s | 96.9 s |
| on | 15.1 s | 3,040 | 94% | 7.1 s | 12.2 s |
6.6x faster end to end, with 11x less prefill work.
Chunked prefill. 16 requests are decoding while six 2,048-token prompts arrive. Inter-token latency (ITL) seen by the decoding requests:
| token budget per step | ITL p50 | ITL p99 | ITL max |
|---|---|---|---|
| unlimited (whole prompt in one step) | 81 ms | 21.5 s | 23.5 s |
| 512 | 71 ms | 4.5 s | 4.6 s |
| 256 | 74 ms | 2.2 s | 2.3 s |
A 256-token budget cuts the worst decode stall about 10x, at no cost to median ITL.
Continuous batching vs static batching. 64 requests with 32 to 512 prompt tokens and 16 to 256 output tokens, at
most 32 in flight: nanoserve 60.0 output tok/s vs Hugging Face generate with static batches of 32 at 50.3 tok/s (1.19x).
On this machine MPS is limited by kernel launch overhead and memory pressure: a single 2,048-token prefill takes about 3 s in both nanoserve and Hugging Face. The absolute numbers are low; the comparisons are what these runs show.
Reproduce with the scripts in benchmarks/ (bench_offline.py, bench_prefix.py, bench_chunked.py,
bench_attention.py, and bench_serving.py for an online Poisson load test against a running server). Raw JSON is in
benchmarks/results/. The L4 numbers come from one Colab run that cloned this repo at the
split-attention commit, ran the test suite on the GPU, then ran the four benchmarks;
scripts/colab_gpu_bench.py does the same through the colab CLI.
uv run pytest -qThe tests build a tiny random Qwen3 with Hugging Face transformers, load the same weights into nanoserve, and require
identical greedy tokens across batching, chunking, prefix caching and preemption. They also cover the block manager,
scheduler, sampler (top-k/top-p against a brute-force reference, distribution, seeds), the HTTP server (streaming,
validation, concurrency, metrics), the decode/prefill split (on CPU, with a torch stand-in for the kernel), and on CUDA
the Triton kernel against the torch path plus an end-to-end engine run. Everything except the CUDA tests runs on CPU
without downloading anything; all 40 pass on an L4.
- Only the Qwen3 dense architecture is implemented. Adding another decoder means a model file like
models/qwen3.py. - Single device; no tensor parallelism, CUDA graphs or quantization.
- On Apple Silicon (MPS), two slow paths showed up while profiling: advanced indexing for the K/V gather is about 6x
slower than
index_select, and a[>8, 1024] x [1024, 151936]LM-head matmul falls off the fast path (about 8x slower at 32 rows). The engine usesindex_selecteverywhere and slices the vocabulary projection on MPS.
MIT