← All Projects
Completed, Aug 2026

Continuous Batching Engine

A continuous-batching LLM inference server I built from scratch. It runs the serving-engine ideas behind vLLM and TGI, against a small transformer I wrote in NumPy.

Python NumPy asyncio AI Infrastructure
View on GitHub →

The project idea came out of a conversation with Claude about what actually makes engines like vLLM and TGI fast, which is what pointed me at scheduling over model quality as the interesting problem. Claude was also involved with the design too, for things like the KV-cache block manager and the admission control policy, it laid out a few viable approaches with their tradeoffs each time, but the choice of which to build, and every line of the implementation, was mine.

Benchmark output comparing continuous batching against sequential serving

I wanted this project to be about AI itself. When I looked at how vLLM and TGI actually get their throughput, the thing that stuck with me was that almost none of it comes from the model. It's the scheduling. How do you keep a GPU busy when a hundred people are asking for different amounts of text at different times?

That question is what got me building this. It's a continuous-batching engine, with a request queue, admission control, and a scheduler that packs many in-flight generations into one batched step, running on top of a transformer I wrote myself in plain NumPy so I'd actually understand what the KV-cache underneath it is doing, instead of treating the model as a black box.

The engine has a request queue with admission control, a scheduler that packs many in-flight generations into one batched step, and a KV-cache block manager, all running on top of a transformer I wrote myself in plain NumPy rather than a pretrained model. Weights are randomly initialized by default, since the point of the project is serving mechanics and throughput, not text quality, and ModelWeights.load() is there if I ever want to drop in trained weights later.

A request flows from the waiting queue into the scheduler, which batches it with other running sequences against the KV-cache block pool and the NumPy transformer, then streams tokens back

A KV-cache is only worth anything if decoding with it gives you the exact same result as recomputing the whole sequence from scratch, so the first real test I wrote was to test that. I ran a prompt through prefill(), separately decoded the last token using a cache from the first N-1, and asserted the logits matched. Extending that to a ragged batch, two sequences of different lengths decoded together in one call, is what actually caught bugs in my padding and masking logic.

The hard part I found for continuous batching is that sequences sharing a decode step almost never have the same length, someone's three tokens in while someone else just joined. I pad each sequence's cached K and V up to the batch's current max length and mask out the padding in the attention scores, so it's still one batched matmul instead of a loop per sequence, the same technique real batched-decode implementations use before something like PagedAttention avoids the padding waste entirely.

Two sequences of different lengths padded to the batch's shared max length, with the padding positions masked out so they get negative infinity in the attention scores and zero weight after softmax

My first version of step() admitted waiting requests and then immediately ran a decode step on the whole batch, including the ones that had just joined. That meant a brand new request got two tokens in its first tick while everyone else got one, which quietly broke the guarantee the whole design rests on, that every sequence advances by exactly one token per tick. My scheduler tests caught it immediately by asserting exact token counts after a single step(), and the fix was to decode only the sequences that were already running.

step() is a plain synchronous method on purpose, since it's just NumPy matmuls with nothing to await, but that meant I couldn't use asyncio.Queue for streaming tokens back to a client the way I first tried. On Python 3.9 it binds to whatever event loop is running the moment it's constructed, and submit() has no guarantee one exists yet. I ran into that as a very confusing test failure before I understood what was happening. The fix ended up simpler than the original design, RequestHandle just polls the request's growing token list instead.

None of this matters if batching doesn't measurably beat serving requests one at a time, so bench.py runs the exact same model and weights both ways. On my machine, 24 concurrent requests generating 40 tokens each finish about 2.3x faster batched than sequential, and 64 requests at 50 tokens each lands around 2.2x. The peak batch size the benchmark reports is capped below the request count when the cache pool is smaller than demand, which is the admission control actually doing its job.

The benchmark runs two passes over the same model with the same randomly initialized weights: one that serves each request in isolation and waits for it to finish before starting the next, and one that feeds them all through the continuous-batching engine. The only variable is the serving strategy. Below are the outputs for a small and a larger run, showing how the throughput gap holds as the request count scales up.

bench.py output: 24 requests × 40 tokens, 2.25x speedup
bench.py output: 64 requests × 50 tokens, 2.20x speedup

The peak concurrent batch size stays at 21 regardless of how many total requests are submitted because it's set by the KV cache block pool, not the request count. With the default 64 blocks at size 16, the pool saturates at around 21 in-flight sequences given the prompt and generation length in use. Once it's full, the scheduler queues new requests and admits them one by one as running sequences finish and free their blocks. To raise the ceiling you'd pass a larger --num-blocks value; to see every request admitted at once you'd keep the request count below the pool's capacity.

Python (asyncio) NumPy aiohttp pytest