Sign in to keep reading
“Batch Inference System” is free with an account. Your progress and bookmarks follow you across devices.
Sign in to continueNEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
Design a Batch Inference System - This system design isn't just about grouping requests — it's about understanding how GPUs work, handling concurrency correctly, and keeping users from waiting too long.
Written by a Staff+ Engineer and an Engineering Manager
12+ years of experience in distributed systems and real-time messaging. Tech lead for large-scale chat applications serving billions of users. Over 12 years of industry experience with 6 years in engineering leadership, building and scaling tech infrastructure teams that deliver end-to-end large-scale distributed systems.
“Batch Inference System” is free with an account. Your progress and bookmarks follow you across devices.
Sign in to continueWhat to Design?
Could you answer these today? Tick what you already know.
FR1 — Clients can submit inference requests and receive responses synchronously
A client sends a single HTTP POST request with a text input and waits on the same connection for the result. From the client's perspective, this is a normal synchronous call — they have no visibility into the batching happening internally.
Why this matters: The synchronous contract is non-negotiable. The system must hold the connection open internally and resolve it when the batch completes.
FR2 — The system batches requests internally for GPU efficiency
Individual requests must be aggregated into batches before being dispatched to GPU workers. This is completely invisible to the client. The batching logic is the core of the entire system.
Why this matters: The GPU function has fixed latency regardless of batch size. Sending one request at a time wastes 99% of the GPU's parallel capacity. The entire economic argument for this system is: pay the same fixed cost, process as many inputs as possible.
FR3 — Requests are prioritized by user tier
The system must support at least 3 tiers — for example, Free, Paid, and Enterprise — and ensure higher-tier users experience lower queue wait times, especially under load.
Why this matters: Under normal conditions, all users feel fast. Under high load, you shed Free traffic first to protect the Paid experience. This is where business requirements directly shape infrastructure design.
NFR1 — Low Latency: P95 under 500ms end-to-end
Clients should receive responses in under 500ms at the 95th percentile. This is the "near real-time" constraint from the problem statement.
(We suggest you think and read about the next and come back to this answer!)
One of the sharpest interview signals is breaking down a latency budget rather than just saying "it should be fast."
For this system, a well-reasoned budget looks like:
Total: ~180ms — well within 500ms, with headroom for spikes.
Staff+ signal: candidates who derive this budget, identify the GPU inference as the dominant term, and then ask "what changes if the inference time doubles?" are showing they understand the system's load-bearing constraints.
NFR2 — High Throughput: 1,000 RPS baseline, designed for 10,000 RPS
The system must handle 1,000 requests per second at launch with the architecture ready to scale to 10x without fundamental redesign.
NFR3 — High Availability: 99.9% uptime
GPU worker failures must not cascade into full system outages. The system must degrade gracefully — shedding load, returning clean errors — rather than crashing silently.
NFR4 — GPU Utilization: Target 70-80%
GPUs are expensive. The goal is not 100% utilization — that leaves no headroom for traffic spikes during the 5-minute GPU cold start window. 70-80% is the sweet spot.
This is a common interview trap. Running GPUs at 100% means any spike in traffic immediately creates a queue. Since GPU cold start takes 5 minutes, you have no capacity buffer during that window. Users experience dramatically degraded latency until the new GPU is warmed up.
The 70% target gives you 30% headroom to absorb spikes while new capacity comes online. It costs slightly more in GPU spend but buys you predictable latency under bursty traffic — which is almost always worth it.
NFR5 — Backpressure: The system must degrade gracefully, never silently
When queues fill up, the system must actively reject new requests with meaningful errors (HTTP 429) rather than accepting them and letting latency balloon indefinitely.
Requirement Summary
| Functional Requirements (FRs) | |
|---|---|
| Name | Description |
| FR1: Synchronous requests | HTTP POST, client waits on same connection, response returned inline |
| FR2: Internal batching | Requests aggregated invisibly to client; GPU called with batch, not individual inputs |
| FR3: Tier-based priority | Paid users served before Free users, especially under load |
| Non-Functional Requirements (NFRs) | |
|---|---|
| Name | Description |
| NFR1: Low latency | P95 end-to-end under 500ms |
| NFR2: High throughput | 1,000 RPS launch, 10,000 RPS architecture |
| NFR3: High availability | 99.9% uptime; graceful degradation on GPU failure |
| NFR4: GPU utilization | 70-80% target; never run at 100% |
| NFR5: Backpressure | Explicit 429s when queue full; no silent latency degradation |
| Core Entities | |
|---|---|
| Name | Description |
| Request | An inbound inference job: user ID, tier, input string, timestamp, timeout deadline |
| Batch | A grouped set of requests dispatched to one GPU as a single unit |
| GPUWorker | A single GPU instance with status (healthy/busy/offline) and current utilization |
| Response | The output string mapped back to its originating request and waiting connection |
Authentication and identity management happen upstream at the API Gateway layer. By the time a request reaches the batching system, all the system needs to know is the tier (Free, Paid, Enterprise) — not who the user is. Keeping the inference system tier-aware but identity-agnostic makes it simpler, faster, and easier to reason about.
This is a deliberate scope boundary. In a real interview, calling it out shows product thinking — you're not over-designing the inference layer with concerns that belong elsewhere.
Client sends:
POST /v1/inference
Authorization: Bearer <api_key>
Request body:
{
"model": "llm-v1",
"input": "What is the capital of France?",
"parameters": {
"max_tokens": 100,
"temperature": 0.7
}
}
Response (200 OK):
{
"request_id": "req_abc123",
"output": "The capital of France is Paris.",
"metadata": {
"latency_ms": 312,
"tokens_used": 14,
"queue_wait_ms": 28
}
}
POST /internal/batch
Request body:
{
"batch_id": "batch_xyz789",
"requests": [
{"request_id": "req_1", "input": "..."},
{"request_id": "req_2", "input": "..."},
...
]
}
Response:
{
"batch_id": "batch_xyz789",
"results": [
{"request_id": "req_1", "output": "...", "status": "success"},
{"request_id": "req_2", "output": "...", "status": "success"}
]
}
Unlike a chat or online chess game application, inference is a classic request-response interaction — one input, one output, done. WebSocket adds connection management overhead and bi-directional complexity that adds no value here.
HTTP connection (holding the connection open while the batch processes) is the right primitive. It's simple, well-understood, and fits the synchronous contract clients expect. WebSocket would only make sense if we were streaming tokens back word-by-word — which is a valid follow-up scope extension but out of scope for the core design.
Most engineers are comfortable designing around databases and web servers. GPUs behave differently in ways that directly shape every decision in this system. This section gives you just enough context to make the design feel inevitable rather than arbitrary.
Three Facts That Drive the Entire System Design
Fact 1 — One batch at a time per GPU instance
A single GPU instance can only process one batch at a time. While it's running batchstring(), nothing else can enter that GPU. The next batch waits outside in a queue. This is the fundamental constraint that makes a batcher necessary at all — without it, you'd be firing individual requests one-by-one and wasting massive GPU capacity.
Fact 2 — Inside a batch, everything is parallel
GPUs have thousands of small cores (vs a CPU's handful of large ones). When a batch of 100 strings arrives, all 100 are processed simultaneously — one core per input item. Sending a batch of 1 wastes 99% of that parallel capacity. Sending a batch of 100 uses it fully, for the same fixed time cost. This is the entire economic argument for batching.
Fact 3 — Order is preserved by memory address, not logic
Let's take NVIDIA's A100 as example: CUDA (NVIDIA's GPU programming platform) pins each input to a fixed memory slot when a batch loads. inputs[0] goes to address 0x00, inputs[1] to 0x01, and so on. When compute finishes, results are written back to those same slots and read out in order. This means always maps back to — not because of any routing logic, but because of how GPU memory is laid out physically.
CUDA stands for Compute Unified Device Architecture — it's NVIDIA's programming platform that lets code run directly on GPU cores.
When batchstring([s1, s2, s3]) executes:
s1, thread 1 processes s2, all simultaneouslyThe PCIe bus is the physical highway between CPU and GPU on the motherboard. At PCIe 4.0 speeds (~32 GB/s), this transfer costs roughly 1-5ms per batch — regardless of batch size. This is the fixed transfer overhead in our latency budget. You pay it whether you send 1 string or 100.
This is exactly why the problem statement says "fixed latency regardless of batch size" — the PCIe transfer + kernel launch overhead dominates, and it's constant.
Logically yes, physically no. There are three levels people often conflate, and understanding the difference matters for reasoning about GPU performance.
CUDA Core is the physical arithmetic unit — it executes one floating point or integer operation per clock cycle. An A100 has 6,912 of them.
Thread is the logical unit of work. For our purposes, 1 request = 1 thread: thread i reads inputs[i], runs inference, writes outputs[i]. But a thread is a software abstraction — it does not map one-to-one to a physical core.
Warp is the real physical execution unit. CUDA groups 32 threads into a warp and runs all 32 simultaneously in lockstep — same instruction, different data slots. This is called SIMT (Single Instruction, Multiple Threads). It is why positional memory access works so well: all 32 threads compute addr = base + i * stride in a single clock cycle, reading sequential addresses in parallel.
SM (Streaming Multiprocessor) is the physical chip that executes warps. An A100 has 108 SMs, each with 64 cores. The CUDA scheduler assigns warps to SMs automatically — you have no control over this. When a warp stalls waiting on memory, the SM instantly switches to another ready warp at zero cost. This is called , and it is the core reason GPUs maintain high throughput despite memory access delays.
Most people expect that each GPU output must be "tagged" with an ID to know which request it belongs to. CUDA doesn't work that way, and the reason is a fundamental hardware optimization called coalesced memory access.
GPU threads execute in groups of 32 called warps. A warp is most efficient when all 32 threads read or write sequential memory addresses simultaneously — one clock cycle per thread. The address formula is pure arithmetic:
thread i reads: base_address + i * stride
thread i writes: out_base + i * stride
This is what CUDA does. Each thread knows its slot by its own index (threadIdx.x = i), reads from that slot, computes, and writes the result back to the same slot in the output array. No parsing. No lookup. No coordination between threads.
Why ID-based routing would break this:
If each input carried a tag like {id: "req_abc", data: "..."}, the GPU would need to parse the struct, hash or scan to find the right output slot, and write to a non-sequential address. Different threads would jump to different memory locations — called random (non-coalesced) access — which can be 10-20x slower. You would destroy the parallelism advantage entirely.
Hence the API Data Flow could be illustrated below:
outputs[i]inputs[i]What this means for batchstring() in practice:
| GPU Execution Model | |
|---|---|
| What | Who controls it |
| Which index each request occupies | You (the batcher) |
| Which thread processes index i | CUDA runtime |
| Which SM runs that thread | CUDA scheduler |
| That outputs[i] = result for inputs[i] | Memory layout — guaranteed |
You only ever need to reason at the index level. Everything below that is CUDA's job — and it is very good at it.
The elegant division of responsibility:
index_map = {0: f_UserA, 1: f_UserB, ...}). Resolves futures after the GPU returns.This is why batchstring(inputs)[i] is guaranteed to be the result for inputs[i] — not by coincidence, but by intentional hardware design. Position is identity because that is what makes GPUs fast.