0
stars
10
commits
Python
primary language
Jul 15, 2026
updated
A small but real LLM inference engine, written from scratch in Python and PyTorch.
It serves Qwen2.5-0.5B-Instruct
behind an OpenAI-compatible HTTP API and implements the two ideas that make
vLLM fast: a PagedAttention KV cache
and a continuous batching scheduler. Everything in the serving path is
hand-written: the model forward pass, the weight loading, the cache, the
scheduler, the sampling, and the server. HuggingFace is used only for the
tokenizer, for downloading the checkpoint, and as a correctness oracle in tests.
model.generate() never appears in the engine.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
print(client.completions.create(
model="Qwen/Qwen2.5-0.5B-Instruct",
prompt="The capital of France is",
max_tokens=16,
).choices[0].text)
If you already know what a KV cache is, skip ahead.
A language model generates text one token at a time. Each step feeds the whole sequence so far through the network and produces probabilities for the next token. Inside every attention layer, each token computes a key and a value vector; every new token attends over the keys and values of all tokens before it.
Naively, generating token 500 means recomputing keys and values for tokens 1-499, even though they never change. The fix is the KV cache: store every token's K and V the first time they are computed, and each new step only computes the new token's K/V and reads the rest from cache. Generation then has two phases:
The KV cache is the central data structure of an inference engine, and how you manage its memory decides how many users you can serve at once.
generate() is a library call, not a server. Real serving needs request
admission, scheduling, preemption, streaming, and an API.Manage KV memory the way an operating system manages RAM.
The cache is carved into fixed blocks of 16 tokens (page frames). Each
sequence keeps a block table (its page table) that maps logical token
positions to physical blocks. Token i of a sequence lives at physical slot:
table[i // 16] * 16 + i % 16
Consequences, same as in an OS:
One more saving comes from the model itself: Qwen2.5 uses grouped-query attention (GQA) with 2 KV heads shared by 14 query heads, so the cache stores 2 heads instead of 14. That alone makes it 7x smaller than a standard multi-head cache.
In this repo: engine/block_manager.py is the allocator (free list, block tables, leak and double-free guards), and engine/kv_cache.py owns the tensors. Cache writes are one indexed scatter and cache reads are one gather over block tables; the comments mark exactly where vLLM replaces each with a fused CUDA kernel.
Re-decide who is in the batch on every decode step, instead of running a fixed batch to completion.
Each scheduler step (engine/scheduler.py):
When the block pool runs dry, the scheduler preempts the youngest sequence: frees its blocks and re-queues it, recomputing its context when capacity returns. Youngest-first means the oldest request always makes progress, which is the no-starvation guarantee. Greedy decoding makes recompute-on-resume land on the identical continuation, and the tests prove it.
The result: a long request never blocks short ones, freed capacity is reused within one step, and time-to-first-token stays flat as load grows.
flowchart TD
C[openai client] -->|POST /v1/completions| API[server/api.py FastAPI + SSE]
API -->|submit / token queues| W[EngineWorker thread]
W --> SCHED[engine/scheduler.py admit / decode / retire each step]
SCHED --> BM[engine/block_manager.py free list + block tables]
SCHED --> MODEL[engine/model.py Qwen2 forward]
MODEL <-->|slot writes, block-table gathers| KV[(paged KV cache)]
LOADER[engine/weights.py safetensors] --> MODEL
Life of a request:
POST /v1/completions arrives; the prompt is tokenized and submitted to the
engine worker, a dedicated thread that owns the scheduler (torch forward
passes are synchronous and must not block the event loop).data: {...} SSE chunks, ending with data: [DONE]. Non-streaming
responses collect the same stream into one JSON body.max_tokens or the EOS token; its
blocks return to the pool that same step. If the client disconnects early,
the server aborts the request and the blocks are freed immediately.Layered oracles, each one pinning the layer above it:
reference_generate (engine/inference.py) is a
deliberately naive greedy decoder: full recompute every step, no cache. It
is verified token-for-token against HF generate(do_sample=False) in fp32,
plus a prefill logits comparison against the HF forward pass.reference_generate exactly: across
block boundaries, in ragged batches, and after blocks are freed and reused
(tiny random model for speed, real Qwen gated behind an env flag).openai client, streaming
and non-streaming, over a live uvicorn instance.So "is the math right", "is the cache right", "is the scheduling right", and "is the serving right" are separate questions with separate tests. The fast suite (34 tests) uses a tiny random model and runs in seconds with no downloads; the slow suite checks the real checkpoint against HF.
The CLI server enables Prometheus metrics at /metrics, JSON request lifecycle
logs, and separate /health/live and /health/ready probes. Metrics cover queue
delay, TTFT, inter-token latency, prefill/decode/scheduler duration, batch size,
token throughput, preemption, and KV-block utilization. The engine depends only
on a small telemetry interface and defaults to a no-op sink, so direct library
and benchmark use stays monitoring-backend neutral. Metric definitions, privacy
rules, and the optimization feedback loop are in
docs/OBSERVABILITY.md.
Qwen2.5-0.5B-Instruct, fp16 on Apple MPS (8 GB machine). Workload per
concurrency level: 2x concurrency requests with uneven prompts and uneven
max_tokens (8-64), all submitted at t=0. The transformers baseline is the naive
serving pattern: fixed batches of size concurrency through model.generate,
where a request's latency is the time until its whole batch returns. Both
engines produce identical greedy tokens, so token counts match exactly.
| concurrency | engine | requests | wall (s) | output tokens | tokens/s | p50 TTFT (s) | p50 latency (s) | p99 latency (s) |
|---|---|---|---|---|---|---|---|---|
| 1 | mini-vllm | 2 | 3.3 | 94 | 28.5 | 1.2 | 2.7 | 4.4 |
| 1 | transformers | 2 | 4.3 | 94 | 21.7 | 3.8 | 3.8 | 5.3 |
| 8 | mini-vllm | 16 | 8.8 | 653 | 74.0 | 1.1 | 6.2 | 9.0 |
| 8 | transformers | 16 | 13.5 | 653 | 48.2 | 10.7 | 10.7 | 13.5 |
| 32 | mini-vllm | 64 | 10.2 | 2440 | 239.0 | 1.6 | 5.4 | 10.2 |
| 32 | transformers | 64 | 8.2 | 2440 | 297.2 | 6.7 | 6.7 | 8.2 |
Reading it honestly:
generate and holds requests behind whole batches (10.7s p50 TTFT at
concurrency 8).engine/kv_cache.py.An optimization pass (one shared precomputed RoPE table instead of 24 per-step recomputations, fused QKV projection) raised engine throughput 18-40% across levels and cut the CPU parity suite from 93s to 52s; details in docs/decisions.md.
git clone https://github.com/pavanbobba09/mini-vllm.git
cd mini-vllm
python3 -m venv .venv
.venv/bin/python -m pip install -e ".[server,dev]"
# fast tests: tiny random model, no downloads, a few seconds
.venv/bin/python -m pytest tests -q
# full parity vs HuggingFace (downloads Qwen2.5-0.5B-Instruct, slow on CPU)
MINI_VLLM_RUN_PARITY=1 MINI_VLLM_TEST_DEVICE=cpu MINI_VLLM_TEST_DTYPE=float32 \
.venv/bin/python -m pytest tests -q
# start the server (first run downloads the model)
.venv/bin/python -m server.api --port 8000
# inspect Prometheus metrics and readiness
curl -L http://localhost:8000/metrics
curl http://localhost:8000/health/ready
# benchmark against transformers (writes bench/results.md)
.venv/bin/python -m bench.benchmark --concurrency 1 8 32
The engine picks CUDA, then Apple MPS, then CPU automatically; override with
--device and --dtype on the server and benchmark.
The repo ships a Dockerfile sized for free CPU hosting. Set the
MINI_VLLM_API_KEY environment variable to require an API key on /v1/*
(GET /health stays open for platform probes):
docker build -t mini-vllm .
docker run -e MINI_VLLM_API_KEY=sk-demo -p 7860:7860 mini-vllm
Step-by-step instructions for a free public deployment on Hugging Face Spaces are in docs/DEPLOY.md.
| path | what it does |
|---|---|
engine/config.py | ModelConfig parsed from the HF config.json, EngineConfig serving knobs |
engine/model.py | the model: embeddings, decoder stack, shared RoPE table, tied lm_head |
engine/layers.py | RMSNorm, GQA attention (fused QKV, paged and non-paged paths), SwiGLU MLP |
engine/rope.py | rotate_half rotary embeddings with precomputed cos/sin tables |
engine/weights.py | safetensors loading and the explicit HF-to-ours weight remap |
engine/tokenizer.py | thin HF tokenizer wrapper (encode/decode only) |
engine/inference.py | reference_generate (the oracle) and paged prefill/decode steps |
engine/block_manager.py | block allocator: free list, block tables, slot math |
engine/kv_cache.py | paged K/V tensors: slot scatter writes, block-table gather reads |
engine/scheduler.py | continuous batching: admit, batched decode, retire, preempt, abort |
engine/telemetry.py | backend-neutral lifecycle and scheduler measurement contract |
server/api.py | FastAPI /v1/completions: SSE streaming, sampling, disconnect handling |
server/observability.py | Prometheus registry, bounded metrics, and JSON lifecycle logs |
bench/benchmark.py | throughput/latency/TTFT comparison vs vanilla transformers |
tests/ | fast tiny-model suite plus slow HF-parity suite |
docs/decisions.md | per-milestone design decisions and what changes at 10x scale |
docs/OVERVIEW.md | build plan, milestone status, ground rules |
docs/OBSERVABILITY.md | metrics, privacy rules, health probes, alerts, evaluation loop |
These are the details that break naive Llama-style reimplementations:
lm_head.weight; the output head
reuses embed_tokens.weight.Deliberate scope cuts, documented in docs/decisions.md:
10 commits
Python
99.6%
0
stars
10
commits
Python
primary language
Jul 15, 2026
updated
A small but real LLM inference engine, written from scratch in Python and PyTorch.
It serves Qwen2.5-0.5B-Instruct
behind an OpenAI-compatible HTTP API and implements the two ideas that make
vLLM fast: a PagedAttention KV cache
and a continuous batching scheduler. Everything in the serving path is
hand-written: the model forward pass, the weight loading, the cache, the
scheduler, the sampling, and the server. HuggingFace is used only for the
tokenizer, for downloading the checkpoint, and as a correctness oracle in tests.
model.generate() never appears in the engine.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
print(client.completions.create(
model="Qwen/Qwen2.5-0.5B-Instruct",
prompt="The capital of France is",
max_tokens=16,
).choices[0].text)
If you already know what a KV cache is, skip ahead.
A language model generates text one token at a time. Each step feeds the whole sequence so far through the network and produces probabilities for the next token. Inside every attention layer, each token computes a key and a value vector; every new token attends over the keys and values of all tokens before it.
Naively, generating token 500 means recomputing keys and values for tokens 1-499, even though they never change. The fix is the KV cache: store every token's K and V the first time they are computed, and each new step only computes the new token's K/V and reads the rest from cache. Generation then has two phases:
The KV cache is the central data structure of an inference engine, and how you manage its memory decides how many users you can serve at once.
generate() is a library call, not a server. Real serving needs request
admission, scheduling, preemption, streaming, and an API.Manage KV memory the way an operating system manages RAM.
The cache is carved into fixed blocks of 16 tokens (page frames). Each
sequence keeps a block table (its page table) that maps logical token
positions to physical blocks. Token i of a sequence lives at physical slot:
table[i // 16] * 16 + i % 16
Consequences, same as in an OS:
One more saving comes from the model itself: Qwen2.5 uses grouped-query attention (GQA) with 2 KV heads shared by 14 query heads, so the cache stores 2 heads instead of 14. That alone makes it 7x smaller than a standard multi-head cache.
In this repo: engine/block_manager.py is the allocator (free list, block tables, leak and double-free guards), and engine/kv_cache.py owns the tensors. Cache writes are one indexed scatter and cache reads are one gather over block tables; the comments mark exactly where vLLM replaces each with a fused CUDA kernel.
Re-decide who is in the batch on every decode step, instead of running a fixed batch to completion.
Each scheduler step (engine/scheduler.py):
When the block pool runs dry, the scheduler preempts the youngest sequence: frees its blocks and re-queues it, recomputing its context when capacity returns. Youngest-first means the oldest request always makes progress, which is the no-starvation guarantee. Greedy decoding makes recompute-on-resume land on the identical continuation, and the tests prove it.
The result: a long request never blocks short ones, freed capacity is reused within one step, and time-to-first-token stays flat as load grows.
flowchart TD
C[openai client] -->|POST /v1/completions| API[server/api.py FastAPI + SSE]
API -->|submit / token queues| W[EngineWorker thread]
W --> SCHED[engine/scheduler.py admit / decode / retire each step]
SCHED --> BM[engine/block_manager.py free list + block tables]
SCHED --> MODEL[engine/model.py Qwen2 forward]
MODEL <-->|slot writes, block-table gathers| KV[(paged KV cache)]
LOADER[engine/weights.py safetensors] --> MODEL
Life of a request:
POST /v1/completions arrives; the prompt is tokenized and submitted to the
engine worker, a dedicated thread that owns the scheduler (torch forward
passes are synchronous and must not block the event loop).data: {...} SSE chunks, ending with data: [DONE]. Non-streaming
responses collect the same stream into one JSON body.max_tokens or the EOS token; its
blocks return to the pool that same step. If the client disconnects early,
the server aborts the request and the blocks are freed immediately.Layered oracles, each one pinning the layer above it:
reference_generate (engine/inference.py) is a
deliberately naive greedy decoder: full recompute every step, no cache. It
is verified token-for-token against HF generate(do_sample=False) in fp32,
plus a prefill logits comparison against the HF forward pass.reference_generate exactly: across
block boundaries, in ragged batches, and after blocks are freed and reused
(tiny random model for speed, real Qwen gated behind an env flag).openai client, streaming
and non-streaming, over a live uvicorn instance.So "is the math right", "is the cache right", "is the scheduling right", and "is the serving right" are separate questions with separate tests. The fast suite (34 tests) uses a tiny random model and runs in seconds with no downloads; the slow suite checks the real checkpoint against HF.
The CLI server enables Prometheus metrics at /metrics, JSON request lifecycle
logs, and separate /health/live and /health/ready probes. Metrics cover queue
delay, TTFT, inter-token latency, prefill/decode/scheduler duration, batch size,
token throughput, preemption, and KV-block utilization. The engine depends only
on a small telemetry interface and defaults to a no-op sink, so direct library
and benchmark use stays monitoring-backend neutral. Metric definitions, privacy
rules, and the optimization feedback loop are in
docs/OBSERVABILITY.md.
Qwen2.5-0.5B-Instruct, fp16 on Apple MPS (8 GB machine). Workload per
concurrency level: 2x concurrency requests with uneven prompts and uneven
max_tokens (8-64), all submitted at t=0. The transformers baseline is the naive
serving pattern: fixed batches of size concurrency through model.generate,
where a request's latency is the time until its whole batch returns. Both
engines produce identical greedy tokens, so token counts match exactly.
| concurrency | engine | requests | wall (s) | output tokens | tokens/s | p50 TTFT (s) | p50 latency (s) | p99 latency (s) |
|---|---|---|---|---|---|---|---|---|
| 1 | mini-vllm | 2 | 3.3 | 94 | 28.5 | 1.2 | 2.7 | 4.4 |
| 1 | transformers | 2 | 4.3 | 94 | 21.7 | 3.8 | 3.8 | 5.3 |
| 8 | mini-vllm | 16 | 8.8 | 653 | 74.0 | 1.1 | 6.2 | 9.0 |
| 8 | transformers | 16 | 13.5 | 653 | 48.2 | 10.7 | 10.7 | 13.5 |
| 32 | mini-vllm | 64 | 10.2 | 2440 | 239.0 | 1.6 | 5.4 | 10.2 |
| 32 | transformers | 64 | 8.2 | 2440 | 297.2 | 6.7 | 6.7 | 8.2 |
Reading it honestly:
generate and holds requests behind whole batches (10.7s p50 TTFT at
concurrency 8).engine/kv_cache.py.An optimization pass (one shared precomputed RoPE table instead of 24 per-step recomputations, fused QKV projection) raised engine throughput 18-40% across levels and cut the CPU parity suite from 93s to 52s; details in docs/decisions.md.
git clone https://github.com/pavanbobba09/mini-vllm.git
cd mini-vllm
python3 -m venv .venv
.venv/bin/python -m pip install -e ".[server,dev]"
# fast tests: tiny random model, no downloads, a few seconds
.venv/bin/python -m pytest tests -q
# full parity vs HuggingFace (downloads Qwen2.5-0.5B-Instruct, slow on CPU)
MINI_VLLM_RUN_PARITY=1 MINI_VLLM_TEST_DEVICE=cpu MINI_VLLM_TEST_DTYPE=float32 \
.venv/bin/python -m pytest tests -q
# start the server (first run downloads the model)
.venv/bin/python -m server.api --port 8000
# inspect Prometheus metrics and readiness
curl -L http://localhost:8000/metrics
curl http://localhost:8000/health/ready
# benchmark against transformers (writes bench/results.md)
.venv/bin/python -m bench.benchmark --concurrency 1 8 32
The engine picks CUDA, then Apple MPS, then CPU automatically; override with
--device and --dtype on the server and benchmark.
The repo ships a Dockerfile sized for free CPU hosting. Set the
MINI_VLLM_API_KEY environment variable to require an API key on /v1/*
(GET /health stays open for platform probes):
docker build -t mini-vllm .
docker run -e MINI_VLLM_API_KEY=sk-demo -p 7860:7860 mini-vllm
Step-by-step instructions for a free public deployment on Hugging Face Spaces are in docs/DEPLOY.md.
| path | what it does |
|---|---|
engine/config.py | ModelConfig parsed from the HF config.json, EngineConfig serving knobs |
engine/model.py | the model: embeddings, decoder stack, shared RoPE table, tied lm_head |
engine/layers.py | RMSNorm, GQA attention (fused QKV, paged and non-paged paths), SwiGLU MLP |
engine/rope.py | rotate_half rotary embeddings with precomputed cos/sin tables |
engine/weights.py | safetensors loading and the explicit HF-to-ours weight remap |
engine/tokenizer.py | thin HF tokenizer wrapper (encode/decode only) |
engine/inference.py | reference_generate (the oracle) and paged prefill/decode steps |
engine/block_manager.py | block allocator: free list, block tables, slot math |
engine/kv_cache.py | paged K/V tensors: slot scatter writes, block-table gather reads |
engine/scheduler.py | continuous batching: admit, batched decode, retire, preempt, abort |
engine/telemetry.py | backend-neutral lifecycle and scheduler measurement contract |
server/api.py | FastAPI /v1/completions: SSE streaming, sampling, disconnect handling |
server/observability.py | Prometheus registry, bounded metrics, and JSON lifecycle logs |
bench/benchmark.py | throughput/latency/TTFT comparison vs vanilla transformers |
tests/ | fast tiny-model suite plus slow HF-parity suite |
docs/decisions.md | per-milestone design decisions and what changes at 10x scale |
docs/OVERVIEW.md | build plan, milestone status, ground rules |
docs/OBSERVABILITY.md | metrics, privacy rules, health probes, alerts, evaluation loop |
These are the details that break naive Llama-style reimplementations:
lm_head.weight; the output head
reuses embed_tokens.weight.Deliberate scope cuts, documented in docs/decisions.md:
10 commits
Python
99.6%