Home/Projects/Inference Gateway

Inference Gateway

The model is the easy part. Everything around it is the actual job: concurrency, batching, tail latency, and knowing when to say no.

When
May → Jul 2026
Stack
Python, FastAPI, asyncio, Docker, Prometheus, GitHub Actions
Model
DistilBERT sentiment (swappable behind one interface)
Code
github.com/ahsiwt101/inference-gateway ↗
Play with it · toy simulation, slowed down POST /predict
Served / s (sim)0
Queue depth0
Last batch size0
Clean 503s0
Server
Max batch size
Extras

Try naive, then batched. Then hit "Overload it!" and watch the 503s bounce off instead of the server dying.

Why build it

Calling a model is one line. Serving one is not.

I wanted to properly understand what happens between "here's a model" and "a thousand people are hitting it at once." So I built it up one stage at a time, and measured each stage against the last: a plain script, then a naive endpoint, then a threadpool, then dynamic batching, then caching and backpressure, then metrics.

Every earlier stage is still in the repo, so the comparisons below stay reproducible.

A request's life
  1. Validate + check the cache. Seen this exact text before? Return instantly. No queue, no model.
  2. Join a bounded queue. If it's full, you get a clean 503 right away instead of waiting forever.
  3. Get batched. The batch loop grabs up to MAX_BATCH_SIZE requests or waits MAX_WAIT_MS, whichever comes first.
  4. One model call for the whole batch, off the event loop, so the server stays responsive.
  5. Resolve each future, cache it, return it. Latency, queue depth, batch size, and cache hits all flow out to /metrics.
Measured, not simulated

The numbers go up as the batch grows.

Throughput · req/s · higher is better
naive65.5
threadpool68.9
batch = 163.4
batch = 494.7
batch = 8158.7
batch = 16174.5
cache-warm537.2
p99 latency · ms · lower is better
naive792
threadpool411
batch = 1419
batch = 4528
batch = 8172
batch = 16190
cache-warm111

Batch size 1 performs the same as naive, and that's expected: a "batch" of one has nothing to amortize. From 4 → 8 → 16, the fixed cost of each model call gets spread over more requests. 2.7× the throughput and 4× lower p99 than the naive server. Latency drops too, because at this load the server is bottlenecked on the queue, so clearing it faster helps everyone.

Under overload (queue capped at 5, 300 requests at concurrency 200), 276 requests got a clean 503 and the p99 stayed bounded at about 1.2 s. No crash, no unbounded pile-up.

Sandbox run: synthetic same-architecture model, 150 requests at concurrency 24, 4 vCPUs. Raw JSON is in the repo.

The bug that wouldn't quit
Field notes

PyTorch + asyncio = SIGBUS (on my Mac).

Calling the model from inside FastAPI's event loop crashed every single time with a SIGBUS. Even one forward pass. The same code ran fine as a plain script. I ruled out, one by one:

two model architecturesPython 3.12 & 3.14PyTorch 2.5.1 & 2.12.1single-threaded BLAScache corruption

The crash followed the execution context (event loop or background thread), not the model or the version. The fix was a Linux container, which was on the roadmap anyway. Later, the same code ran clean in a normal Linux environment, which confirmed it was this machine's native threading stack.

Next project
CostPerThought →