← All Projects
In Progress

mini-inference-server

A continuous-batching LLM inference server I built from scratch. It runs the serving-engine ideas behind vLLM and TGI for real, against a small transformer I hand-wrote in NumPy, because I wanted to actually understand what's inside them instead of just calling an API.

Python NumPy asyncio AI Infrastructure
View on GitHub →

I wanted my next project to actually be about AI, not just an app with an API call bolted onto it. When I looked at how vLLM and TGI actually get their throughput, the thing that stuck with me was that almost none of it is about the model itself. It's the scheduling. How do you keep a GPU busy when a hundred people are asking for wildly different amounts of text at wildly different times? That's a systems problem, which is exactly the kind of thing I got hooked on in my operating systems and distributed systems classes, so it felt like the right project to actually sit down and build.

So that's what this is. It's a real 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 some framework hands me.

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. I wanted the KV-cache logic to be code I actually understand line by line instead of something living inside someone else's library. 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 just 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 of 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 4x faster batched than sequential, and 64 requests at 50 tokens each gets to roughly 5x. 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.

Python (asyncio) NumPy aiohttp pytest