Queue Theory GPU Scheduling LLM Inference: The Practitioner's Guide
A client called me in July 2026, furious. They'd burned $180K in GPU spend in six weeks running a 70B model on 12 H100 nodes, and their p99 latency was still 11 seconds. Their first instinct was that the model was slow. It wasn't. Their scheduler was dumb. They were running a FIFO queue with a fixed concurrency cap of 64 requests per node, and the GPUs were sitting at 28% utilization because half the slots were blocked on long-context requests that should've been preempted twenty minutes earlier.
That's when queue theory GPU scheduling LLM inference stops being an academic topic and becomes the difference between a profitable AI product and a dead one.
If you're running LLMs in production, you're already running a queueing system. The question is whether you designed it on purpose or inherited it by accident. This piece is about the design part — Little's Law, admission control, autoscaling tradeoffs, and the specific things that break when you treat GPU scheduling like CPU scheduling.
What queue theory GPU scheduling LLM inference actually means
Queue theory is the math of waiting. M/M/1, M/M/c, Little's Law, utilization curves. It was built for telephone switches and factory floors. GPU scheduling is the practice of deciding which request gets which GPU, when, and for how long. LLM inference is the workload — autoregressive decode, massive KV cache, wildly variable request sizes.
Put them together and you get: the discipline of applying queueing math to the problem of routing inference requests onto a finite pool of GPUs, so you hit latency SLOs without overpaying for capacity.
The reason this is hard — and I mean genuinely hard, not "read a textbook" hard — is that LLM inference breaks three assumptions baked into classic queue theory:
First, service time isn't i.i.d. A 4K-token request and a 128K-token request both occupy the same GPU slot, but one takes 40x longer. Your arrival distribution and service distribution are correlated through the workload mix.
Second, GPUs don't time-share gracefully. You can't preempt mid-token without storing and restoring the entire KV cache. That's hundreds of MB to tens of GB per request. Context switching is not free like it is on a CPU.
Third, batching changes the shape of service. Continuous batching (vLLM, TensorRT-LLM, SGLang) means a new request joining the batch affects the latency of every request already in it. Your "server" is not a fixed-capacity server. Its throughput is a function of who's inside it.
Once you internalize those three things, everything else — admission control, autoscaling, queue discipline — falls out more naturally.
Little's Law and why your capacity math is probably wrong
Little's Law says L = λW. Number of requests in the system equals arrival rate times average time in system. It's trivially true and enormously useful.
Here's how most teams use it — badly. They measure average request latency, say 800ms. They measure traffic, say 50 requests per second. They multiply: L = 40 concurrent requests. Then they tune concurrency limits around 40. Then latency explodes under load and they blame the model.
The mistake is that Little's Law applies to steady state, and LLM workloads are rarely steady. When a big batch job from an internal team hits your endpoint at 2 PM, arrivals jump 10x for 90 seconds. Your average latency over the last hour said 800ms. Your instantaneous latency is now 20 seconds because the queue is unbounded in the short term.
What I've found works better: instrument the tail. Track p50, p95, p99, and p99.9 separately. Then compute Little's Law at each percentile. You'll often find that your p50 latency is stable but your p99 is doubling every 15 minutes — which means your queue is not in equilibrium and will keep growing until something breaks.
Here's the scheduler health check I run in production:
python
import time
from dataclasses import dataclass, field
from collections import deque
@dataclass
class QueueMetrics:
window: deque = field(default_factory=lambda: deque(maxlen=600))
def observe(self, queue_depth: int, arrival_rate: float, completion_rate: float):
self.window.append({
"t": time.time(),
"L": queue_depth,
"lambda": arrival_rate,
"mu": completion_rate,
})
def stability_ratio(self) -> float:
# rho = lambda / mu. If > 0.85 sustained, you're heading for trouble.
recent = list(self.window)[-60:]
if not recent:
return 0.0
return sum(r["lambda"] for r in recent) / max(sum(r["mu"] for r in recent), 1e-6)
If stability_ratio() sits above 0.85 for more than a couple minutes, you're either admitting too much work or you don't have enough capacity. That's not a subtle insight. It's just that almost nobody measures it.
Why GPU oversubscription admission control risks and mitigation matter more than autoscaling
Most people think the answer to "queue too long" is "add more GPUs." They're wrong, or at least they're solving the expensive half of the problem first.
I ran this experiment at SIVARO in March 2026 for a customer running a customer-support RAG pipeline. They were on 24 H100s and wanted to go to 48. We asked to try admission control first. Two weeks later, same 24 GPUs, same traffic, p99 down from 8.4s to 2.1s. They never bought the extra nodes.
The reason is that autoscaling reacts slowly (2–6 minutes for a cold GPU node in most clouds, longer if you need to load a 140GB model from object storage) while admission control reacts in milliseconds. By the time new nodes come online, the spike is over and you're paying for idle GPUs.
GPU oversubscription — running more concurrent requests than you have "slots" for — has real risks you need to name explicitly:
- KV cache exhaustion. Oversubscribe past VRAM and the runtime either swaps to host memory (10–100x slowdown) or OOMs the process. vLLM will start preempting and recomputing sequences, which is worse than just rejecting the request.
- Head-of-line blocking. One 128K-token request at the front of your queue stalls 200 short chat requests behind it. Classic M/M/1 with non-preemptive priority. The fix is priority queues plus chunked prefill (which SGLang and vLLM both support now).
- Fairness collapse. Without per-tenant quotas, one heavy tenant starves everyone else. We saw this with a customer in April 2026 — one internal team was submitting eval batches during business hours and pushing external API latency from 400ms to 12s.
- Cascading retries. When you reject at the edge without a proper 429 + Retry-After, clients retry aggressively and you amplify load. This is the classic retry storm, just with GPUs.
Mitigations that actually work:
Admission control at the token level, not the request level. Estimate tokens-in + tokens-out for each incoming request, compare against a KV-cache budget, and reject or queue accordingly.
python
class TokenAdmissionController:
def __init__(self, kv_cache_budget_tokens: int, target_utilization: float = 0.85):
self.budget = kv_cache_budget_tokens
self.target = target_utilization
self.in_flight_tokens = 0
def admit(self, prompt_tokens: int, max_new_tokens: int) -> bool:
est = prompt_tokens + max_new_tokens
projected = self.in_flight_tokens + est
if projected > self.budget * self.target:
return False
self.in_flight_tokens += est
return True
def release(self, prompt_tokens: int, actual_new_tokens: int):
self.in_flight_tokens -= (prompt_tokens + actual_new_tokens)
Pair that with a short bounded wait queue — 200ms to 2s max — and a proper 429 with jitter guidance. Your clients will handle it. Most retry libraries respect Retry-After.
Priority tiers help too, but keep them few. Three tiers (interactive, batch, best-effort) with strict weight ratios (70/25/5) beats a 12-tier scheme nobody understands.
GPU node autoscaling vs queue admission control cost: a real number comparison
Let's do the math I wish more teams did before they buy nodes.
Suppose you're serving a 70B model on H100s. Rough numbers from a mid-2026 benchmark we ran internally:
- One H100 node (8x H100 80GB, NVLink) on-demand in us-east-1: roughly $28/hr
- Throughput of a 70B FP8 model with continuous batching at 4K context: ~2,400 tokens/sec steady state
- Average request: 500 input + 300 output tokens = 800 tokens
- So ~3 req/sec per node at saturation
If your peak is 30 req/sec and your average is 12 req/sec, you have two strategies.
Autoscaling strategy: provision for peak. Pay for 10 nodes 24/7 to be safe. That's $28 × 10 × 720 = $201,600/month.
Admission control strategy: provision for p90, not p100. Run 5 nodes steady ($100,800/month), and admit-control the top 10% of traffic with a bounded queue. Pure savings: $100,800/month.
But wait — you can't just reject 10% of requests. So you add a small burst pool: 2 nodes on spot, autoscaled with a 3-minute warm window. Those cost maybe $6/hr × 2 × 720 = $8,640 if they're always on, less if they actually only spin up during peaks. Let's say $5K/month realistically.
Total: $105,800/month vs $201,600/month. Same SLOs, roughly. Roughly $1.15M/year difference.
The catch — and there is always a catch — is that this only works if your traffic has a reasonable peak-to-average ratio and your clients tolerate occasional 429s. If you're serving something where rejection is unacceptable (payments, medical decisions), you can't play this game and you should pay for peak capacity. Honest tradeoff.
But for most chat, RAG, and internal-tool workloads, admission control wins on cost by a wide margin. I've seen this hold across four different customers in 2026.
Choosing a queueing discipline for LLM inference
FIFO is the default and it's usually wrong. Here's the ranking I've converged on, based on what actually reduces p99 without hurting throughput:
Shortest-job-first (SJF) with aging. Prioritize requests with smaller estimated output length. The catch is you don't know output length in advance — but you can estimate from prompt length and a per-endpoint historical distribution. Priority queue by estimated tokens, with a "wait time added to score" term so nothing starves. This alone cut p99 by 40% at one customer.
Chunked prefill. Don't let a 100K-token prefill block the decode of 50 small requests. Break the prefill into chunks and interleave. vLLM does this with --enable-chunked-prefill. SGLang does it as part of its RadixAttention scheduler. If you're on TensorRT-LLM, look at the in-flight batching docs.
Continuous batching with in-flight addition. Sounds obvious in 2026 but I still see teams running static batch sizes. If you're running inference with a fixed batch size that fills up and drains, you're leaving 30–50% throughput on the table.
Fair-share across tenants. Weighted fair queueing, per-tenant token budgets. If you have one tenant driving 60% of traffic, they get a larger share — but they don't get to starve everyone else during their spikes.
What doesn't work: LIFO (latency distribution gets weird), random (throughput tanks), and "priority by request ID" (why?).
The scheduler config I'd start with
Here's a config I've deployed to production for a customer in August 2026, running vLLM 0.8.x behind a Rust admission layer:
bash
vllm serve meta-llama/Llama-3.3-70B-Instruct \
--tensor-parallel-size 8 \
--max-model-len 32768 \
--gpu-memory-utilization 0.90 \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--max-num-seqs 256 \
--scheduling-policy fcfs \
--swap-space 16
The --max-num-batched-tokens 8192 is the key knob. Higher = more throughput, lower = better tail latency. If you're chasing p99, cut it to 4096. If you're chasing $/token, push it to 16384. Don't leave it at default.
And put a real queue in front. Something like:
python
import asyncio
from collections import deque
import time
class BoundedPriorityQueue:
def __init__(self, max_wait_ms: int = 1500):
self.q = []
self.max_wait = max_wait_ms / 1000.0
async def enqueue(self, request, priority_estimate: int):
entry = (priority_estimate, time.time(), request)
self.q.append(entry)
self.q.sort(key=lambda x: (x[0], x[1]))
return entry
def dequeue_batch(self, max_batch: int):
now = time.time()
out = []
while self.q and len(out) < max_batch:
prio, enq_t, req = self.q[0]
if now - enq_t > self.max_wait:
# force admit; aging override
self.q.pop(0)
out.append(req)
else:
self.q.pop(0)
out.append(req)
return out
That max_wait aging term is the difference between SJF that works and SJF that starves your batch requests forever.
What changes when you go multi-node
Single-node scheduling is a local problem. Multi-node is where queue theory GPU scheduling LLM inference gets genuinely tricky, because you now have a routing decision on top of a queueing decision.
You can route by:
Latency-based (least-loaded). Send new requests to the node with the shortest queue. Good default. Breaks down when queue depth is a poor proxy for actual load — which is common with heterogenous prompt lengths.
Cost-based. Prefer cheaper nodes (spot, older GPUs) for batch traffic; route latency-sensitive to guaranteed-capacity nodes. This is what we do at SIVARO for most customers.
Token-budget-aware. Estimate tokens, route to the node whose KV cache has room. This is increasingly what I recommend, because KV cache is usually the real bottleneck, not compute.
The failure mode to watch for: sticky routing that sends every new connection to the same hot node. Round-robin will do this if your load balancer doesn't consider node state. Use least_conn with health checks, or write a small router that queries node-level queue depth every 500ms.
What I tell teams starting from zero
If you're building this in September 2026, you have an advantage my 2023 clients didn't: vLLM, SGLang, and TensorRT-LLM all ship good schedulers. Your job isn't to build the scheduler. It's to build the admission layer above it.
Do these things in this order:
First, instrument. Measure arrival rate, completion rate, queue depth, KV cache utilization, and tail latency, all sampled at 10-second intervals. Without this you're flying blind.
Second, add token-level admission control. Not request-count. Tokens. That's the real resource.
Third, set a bounded queue with a max wait time (I use 1.5s for interactive, 30s for batch) and return proper 429s with Retry-After. Clients will handle it if you tell them how.
Fourth, before you buy another node, look at your utilization curve. If p50 utilization is under 40%, you don't have a capacity problem. You have a scheduling problem dressed up in capacity clothing.
Fifth, only then think about autoscaling. And when you do, spot instances with a warm pool beat on-demand cold starts almost every time.
FAQ
Q: What is queue theory GPU scheduling LLM inference in one sentence?
It's the practice of applying queueing math — arrival rates, service distributions, utilization targets — to route LLM inference requests across GPUs so you meet latency SLOs at minimum cost.
Q: Do I really need to learn queue theory to run LLM inference?
Not formally. But you need to internalize three things: utilization above ~85% creates nonlinear latency, tail latency is what users feel, and rejection is often cheaper than overprovisioning. Those three cover 80% of what queue theory would teach you here.
Q: What's the difference between admission control and rate limiting?
Rate limiting caps arrival rate per client. Admission control decides whether each request fits the current system state (KV cache, queue depth, compute budget). You need both. Rate limiting protects against one abusive client; admission control protects the whole system.
Q: Why is GPU oversubscription risky?
Because KV cache is finite and preemption is expensive. Oversubscribe by 20% and you might be fine. Oversubscribe by 100% and vLLM will start recomputing sequences, tanking throughput for everyone. Estimate admission in tokens, not requests.
Q: Is autoscaling ever the right answer?
Yes, but as a second-order tool. Autoscale for predictable daily/weekly patterns (spin up at 8 AM, down at 8 PM). Handle second-scale spikes with admission control. Don't autoscale to chase p99 — you'll always be too slow and too expensive.
Q: What queue discipline should I use by default?
FIFO with chunked prefill is fine as a baseline. Move to shortest-job-first with aging once you have latency-sensitive traffic mixed with long-context batch work. Never use LIFO.
Q: How do I estimate request cost before running it?
Prompt tokens are exact. Output tokens are unknown — use a per-endpoint historical distribution, p50 for planning and p95 for budget reservation. Reserve the p95 and release the difference when the request completes. That's the pattern we use in production.
Q: What's the single biggest mistake teams make?
Treating every request as equivalent. A 500-token chat turn and a 128K-token document summarization are different workloads. If they share a queue without differentiation, the small requests die. Split them, or priority-tag them, or route them to different pools. Any of those works. Mixing them blindly does not.
Where this is heading
We're entering a phase where the interesting work isn't in the model — it's in the orchestration layer. The models have converged faster than most people expected. What separates a good AI product from a bad one in 2026 is mostly the scheduling: how you admit, queue, batch, and route. That's a queueing problem wearing an ML costume.
The teams I've watched win this year treat their inference stack the way a database team treats a query planner. They instrument, they measure, they tune the discipline before they buy hardware. And they've figured out that queue theory GPU scheduling LLM inference isn't a fancy research topic — it's the difference between a $100K/month inference bill and a $200K/month one, for the same product.
Start with instrumentation. Add token-level admission. Bound your queues. Then, and only then, buy more GPUs.
Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.