This article was originally published at sivaro.in
gpu node autoscaling vs queue admission control cost
Last month a team I work with burned $71,400 in idle GPU time over eleven days. Eleven days. Nobody noticed because the dashboards were green and the Grafana panels showed "utilization at 47%." What the panels didn't show was that they were paying for H100s that sat at 3% utilization for fourteen hours a day because their autoscaler scaled up on a Monday morning spike and refused to scale down cleanly for the rest of the week.
They called me to look at their admission controller. I looked at their autoscaler instead. That's the mistake nearly everyone makes when they ask me about gpu node autoscaling vs queue admission control cost — they assume these are two tools for the same job. They're not. They're two different jobs that happen to share a bill.
This article is a buying guide for the decision. I'll walk you through what each approach actually controls, where the costs hide, how the two interact when you deploy them together, and how to pick the one that matches the shape of your traffic. I've built this stuff at SIVARO for companies running LLM inference at production scale, and I've watched both approaches succeed and fail in ways that surprised me.
If you're provisioning H100s or B200s in 2026, the arithmetic matters. Spot prices move weekly. Reserved capacity contracts lock you in. And every idle A100 you're holding onto has an opportunity cost measured in inference requests you didn't serve.
The two knobs people confuse
Let me define terms because the industry is sloppy about them.
GPU node autoscaling is a control loop. It watches demand signals — pending pods, queue depth, GPU utilization — and adds or removes nodes from your pool. The output is capacity. When it works, you own exactly as many GPUs as your workload needs right now, plus a buffer for warmup. When it fails, you own too many or too few, and the failure mode on the "too many" side is silent and expensive.
Queue admission control is a gate. It sits in front of your inference servers and decides which requests get to consume GPU time, in what order, and at what priority. The output is allocation. It doesn't add or remove hardware. It rations what you have.
Most people I talk to conflate these because Kubernetes made them look similar. A HorizontalPodAutoscaler that scales on queue depth feels like admission control. A priority class that preempts low-priority pods feels like autoscaling. They're adjacent, but the cost mechanics are completely different.
Autoscaling cost is dominated by idle time and cold-start waste. Admission control cost is dominated by rejected work, SLA misses, and the human cost of deciding who gets throttled. You pay for these in different currencies. One is a line item on your cloud bill. The other shows up as churn, escalations, and a VP asking why the paid tier is getting 503s.
What autoscaling actually costs you
I used to think autoscaling was a solved problem. Then I ran the numbers on a real workload in March 2026 — a 70B parameter model serving chat completions for a mid-market SaaS company, about 4.2M requests a day with a heavy diurnal curve.
Here's what I found.
The cold-start tax
GPU nodes don't appear instantly. On a hyperscaler in 2026, you're looking at 90 seconds to provision a node, plus 20-45 seconds to pull a container image if it's not cached, plus model load time. For a 70B model in vLLM with tensor parallelism across 4 GPUs, that's another 40-70 seconds. Call it 3 minutes from "we need capacity" to "capacity is serving traffic."
During those 3 minutes, your backlog grows. When the node finally comes online, it processes a burst. The autoscaler sees the backlog shrink, decides it over-provisioned, and tears the node down. Then the next spike hits and you do it again.
This oscillation is where the money goes. I've measured scale-up/scale-down churn adding 18-22% to monthly GPU spend on workloads with spiky traffic. The nodes aren't idle in the utilization sense — they're busy warming up and cooling down.
# A naive HPA that causes oscillation
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: inference-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-server
minReplicas: 2
maxReplicas: 40
metrics:
- type: Pods
pods:
metric:
name: vllm_queue_depth
target:
type: AverageValue
averageValue: "5"
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # instant scale-up
scaleDown:
stabilizationWindowSeconds: 30 # aggressive scale-down — this is the bug
The fix isn't hard. Longer scale-down stabilization (300-600 seconds), a cooldown window tied to your model's cold-start time, and — this is the one people miss — a warm pool of pre-loaded nodes that aren't counted as "active capacity" but are ready to serve within 30 seconds. Google's own GKE documentation discusses this pattern under "overprovisioning with pause pods," and it's the single highest-ROI change I've made to autoscaling setups in the past two years.
Warm pools cost money. That's the trade. You're paying a ~10% premium in idle nodes to avoid a ~20% premium in churn and SLA misses. For most production LLM workloads I've sized, warm pools win.
The bin-packing problem
A single H100 node in 2026 is typically 8 GPUs. A 70B model in FP8 across tensor-parallel degree 4 fits in half a node. So you're paying for 8 GPUs and using 4. That's a 50% waste, and it's why "gpu oversubscription admission control risks and mitigation" is a phrase that keeps coming up in Slack channels.
You can oversubscribe. Put two workloads on the same node, each getting 4 GPUs. But now a memory leak in one, or a long-context request in the other, or a CUDA OOM in the second model during cold start, and you've taken down both tenants.
The GPU oversubscription admission control risks are real: memory contention, KV-cache thrash, benchmark degradation that only shows up under load, and the nasty failure mode where latency P99 triples but throughput looks fine. I've written about mitigation before — it's mostly about hard memory limits, per-tenant device-memory budgets, and refusing admission when the node's free memory drops below a safety margin. Kubernetes' device plugin doesn't do this well out of the box. You need something like NVIDIA MPS with explicit memory limits or a runtime like HAMi that coordinates sharing.
The reserved-vs-spot gamble
Spot H100s in 2026 run roughly 60-70% below on-demand. Tempting. The catch is eviction. AWS publishes a two-minute warning for spot evictions. Two minutes is not enough to drain an in-flight inference request if that request is streaming a 2,000-token completion.
I've seen teams try to run spot for everything. It works until it doesn't — one region, one instance type, one afternoon, and the whole thing falls over. The mitigation is a mixed pool: reserved capacity for baseline, spot for burst, with the autoscaler favoring spot when available and falling back to on-demand when the spot pool is thin. That's more autoscaler logic. More knobs. More chances to get it wrong.
The cost math: if you can tolerate ~5% request loss during evictions, spot saves real money. If you can't, you're paying for reserved capacity whether you like it or not.
What admission control actually costs you
Admission control is the discipline of saying no. That's it. That's the whole product.
You don't want to say no. Nobody wants to say no. But if you don't, your GPU fleet melts during peak and everyone gets terrible latency. So you build a queue, assign priorities, and start rejecting work when the queue is too long.
The cost of admission control is not the GPU bill. It's the rejected requests and the latency added to accepted ones.
Queue theory, quickly, because you need it
LLM inference at the server level is an M/G/c-style queue. Arrivals are roughly Poisson at high request rates. Service time is heavy-tailed — a 50-token completion is fast, a 2,000-token completion with reasoning is slow, and the variance is brutal. You have c GPU workers serving in parallel.
The thing about M/G/c queues is that latency explodes near saturation. Not linearly. Non-linearly. At 70% utilization everything is fine. At 85% you notice. At 95% your P99 goes to hell and your P50 starts climbing too.
This is why queue theory for GPU scheduling of LLM inference has become its own subfield. The standard result — Little's Law, queue_length = arrival_rate × wait_time — is your friend here. If you measure average queue depth and average arrival rate, you can back out expected wait time, and you can set a max queue depth admission threshold that corresponds to your target P99.
Here's the practical version. Suppose your SLO is P99 latency ≤ 8 seconds for chat completions. Measure your service-time distribution. Compute the wait time as a function of queue depth. Pick the queue depth where P99 crosses 8s. That's your admission threshold. Above it, you reject with a 503 and a Retry-After header.
Most teams never do this calculation. They pick a number that "feels right" — 100 pending requests, or 1000 — and then tune it by vibes. I've replaced "vibes" with this calculation on about a dozen deployments and the results are consistent: 20-40% fewer SLA breaches at the same rejection rate.
Priority tiers and the fairness tax
Once you have admission control, you have to decide who gets admitted when you're near the threshold. This is where things get political.
Tier-1 customers (paying $X per month) get priority 100. Tier-2 get priority 50. Internal batch jobs get priority 10. Free tier gets priority 1.
This sounds clean. It isn't. The 10-priority batch jobs will starve indefinitely if tier-1 traffic is sustained. You end up implementing weighted fair queuing, and now you have a scheduler inside your scheduler, and now your latency SLOs are attached to a system whose behavior depends on traffic mix in ways that are hard to reason about.
I've seen teams spend three engineer-months building a priority system, then quietly disable it six months later because it kept causing edge-case outages. The lesson: start with two tiers, not five. Add tiers only when a specific customer complaint forces it.
# A minimal admission controller with a queue-depth threshold
# and a two-tier priority (this is enough for 90% of teams)
import asyncio
from dataclasses import dataclass, field
from typing import Optional
@dataclass(order=True)
class Request:
priority: int
seq: int = field(compare=False)
payload: dict = field(compare=False)
class AdmissionController:
def __init__(self, max_queue_depth: int, workers: int):
self.max_queue_depth = max_queue_depth
self.workers = workers
self.queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
self.in_flight = 0
self._seq = 0
def try_admit(self, payload: dict, priority: int) -> Optional[Request]:
backlog = self.queue.qsize() + self.in_flight
if backlog >= self.max_queue_depth:
return None # caller returns 503 + Retry-After
self._seq += 1
req = Request(priority=-priority, seq=self._seq, payload=payload)
self.queue.put_nowait(req)
return req
async def worker_loop(self, process_fn):
while True:
req = await self.queue.get()
self.in_flight += 1
try:
await process_fn(req.payload)
finally:
self.in_flight -= 1
self.queue.task_done()
This is 40 lines of code and it's the correct starting point for most teams. The max_queue_depth is the number you tune with Little's Law. The priority is either 0 (normal) or 1 (paid). Everything else is premature.
Where the two approaches actually diverge on cost
Now the comparison you came for.
| Cost dimension | GPU node autoscaling | Queue admission control |
|---|---|---|
| Idle GPU time | High — cold pools + churn | Low — you size to peak-ish, no churn |
| Cold-start waste | Present, ~15-25% of spend | Not applicable |
| Rejected work | Low — you add capacity to meet demand | High — rejection rate scales with load |
| Latency to accepted requests | Usually fine | Grows non-linearly near saturation |
| Operations complexity | Moderate (autoscaler tuning) | Low (a threshold and a queue) |
| Failure mode | Silent overspend, or under-provisioning outage | Visible rejection spike, SLA miss |
| Best fit | Spiky, unpredictable, or burst-y traffic | Predictable sustained load with tiered priorities |
| Worst fit | Steady load (you're just leaving nodes idle) | Massive spikes (you reject your way out of a launch) |
The pattern should be obvious: autoscaling buys you capacity insurance at the cost of idle time. Admission control buys you stability at the cost of rejected work. They are complements, not substitutes.
But here's the contrarian take — and I've made it to enough CTOs that I'm confident in it: most teams dramatically overspend on autoscaling, and dramatically underspend on admission control.
The typical LLM inference team in 2026 enables autoscaling because it's the default in Kubernetes, sets aggressive thresholds, watches their bill creep up as the pool churns, and never once measures the marginal cost of the last 10% of their capacity. Meanwhile they have a queue with no admission threshold and no priority — first-come-first-served until the GPU OOMs.
The fix is almost always to add admission control first, then tune autoscaling second. Admission control is free to implement (it's a middleware layer), it forces you to articulate your SLOs, and it stops the bleeding of "one runaway tenant takes down the whole fleet." Autoscaling costs real money to get right, and you should do it after you've established the load characteristics admission control surfaces.
I learned this the hard way. In 2024 I built an autoscaler for a customer that scaled on a composite of GPU util and queue depth. Clever. It worked. But we never measured their queue depth threshold — we just scaled until the queue wasn't growing. Which meant we paid for H100s to keep the queue at zero. Classic. Should have been a 200-request backlog tolerated at P99 4.2s, not zero.
Deploying both together without creating a monster
The combined system looks like this:
# Pseudocode for the interplay
# Autoscaler scales on *sustained* arrival rate, not instantaneous
# Admission control handles *transient* overload within the current capacity
async def autoscaler_loop(metrics, k8s_client):
while True:
# Average arrival rate over a 5-minute window, not 30 seconds
rate = metrics.arrival_rate(window_seconds=300)
# Size for target utilization of 75%, which is below the
# knee in the M/G/c latency curve
target_workers = math.ceil(rate * metrics.mean_service_time() / 0.75)
k8s_client.set_replicas("vllm-server", clamp(target_workers, 2, 64))
await asyncio.sleep(30)
async def admission_gate(request):
backlog = metrics.current_backlog()
# Threshold is *higher* than what autoscaler tries to maintain
# so admission control handles bursts, not autoscaler latency
if backlog > SLO_DERIVED_MAX_QUEUE:
return Response(503, headers={"Retry-After": "2"})
return await enqueue(request)
The critical detail: the admission threshold and the autoscaler's target utilization should not both be trying to keep the queue small. The autoscaler should aim for ~75% utilization, which is below the knee. The admission controller should only kick in above that — say, when queue depth would push you into the >90% utilization regime. If both systems try to eliminate the queue, you'll over-provision.
I've also found that the autoscaler should be based on arrival rate and mean service time, not on GPU utilization. Utilization is the output of the system; it can be high or low for reasons unrelated to needed capacity. Arrival rate × service time is the actual demand, and it's what you should scale on. This is the one piece of autoscaler advice I give that people argue with. They argue less after they see their bill.
The 2026 reality check
A few things have changed recently that affect this decision.
KV-cache offload to CPU and SSD has gotten good. vLLM's prefix caching and NVIDIA's Dynamo both push the effective serving capacity of a single node up by 30-50% on typical workloads. This pushes the entire cost calculation in favor of admission control because your queue can absorb more before you need new nodes.
Spot instance stability has improved on some providers as capacity has caught up to demand post the 2024-2025 crunch. Not everywhere, but the calculus on mixed pools is more favorable than it was.
Fine-grained GPU partitioning (MIG, MPS, HAMi) has matured. If you're not partitioning your nodes, you're probably leaving 30-50% of your spend on the table. Not the same as admission control, but adjacent — it changes what "one worker" means.
Inference providers with per-token pricing (Together, Fireworks, Anyscale) make the make-vs-buy decision sharper. If your utilization is below ~40% sustained, you might be better off with a hosted provider and no autoscaler at all. That's not what teams want to hear, but I've recommended it twice this year and both teams are happier.
FAQ
Which one should I implement first?
Admission control. It's cheaper to build, it fails more visibly, and it teaches you your workload's shape. Autoscaling without admission control is how you get surprise bills.
Can I skip autoscaling entirely?
If your traffic has a P99/median arrival rate ratio below about 1.5, yes. Steady workloads don't need autoscalers. They need right-sized reservations and good bin-packing.
How do I measure the SLO-derived max queue depth?
Run load tests at increasing concurrency. Plot P99 latency against queue depth. Pick the depth where P99 crosses your SLO. That's your threshold. Re-measure quarterly.
What's the risk of GPU oversubscription with admission control?
Memory contention and KV-cache thrash are the main ones. Mitigate with hard device-memory limits per tenant, and have the admission controller check node free memory before admitting a request, not just queue depth.
Does queue theory still apply with continuous batching?
Yes, but the service-time distribution changes. Continuous batching compresses the tail — a 2,000-token completion and a 50-token completion served in the same batch finish closer together than they would serially. Your effective service time becomes less variable, which flattens the latency curve near saturation. Good news for admission control thresholds.
How do I handle priority without starving batch workloads?
Aging. Bump a low-priority request's priority by one step for every N seconds it's been in the queue. It's five lines of code and it eliminates the starvation problem.
What about multi-region autoscaling?
Different beast. You're now trading off data egress, cross-region latency, and per-region capacity minimums. I'd write a separate article, but the short version is: reserve in two regions, autoscale within each, route at the edge based on load and latency.
Is there a tool that does both well?
Kubernetes with KEDA for autoscaling and a custom admission middleware works. Ray Serve handles both natively if you're in Ray. NVIDIA Dynamo is the newest entrant and handles the interplay well for large-model serving. None of them are a substitute for knowing your arrival rate distribution.
Closing — the decision, plainly
If your workload is steady, spend your engineering budget on bin-packing and partitioning. Autoscaling will cost you more than it saves.
If your workload is spiky, you need both — but do admission control first. The gpu node autoscaling vs queue admission control cost trade is not one of alternatives; it's one of sequencing. Admission control defines your SLOs and reveals your load shape. Autoscaling then buys you the right amount of capacity to sit under that shape at 75% utilization, with a warm pool to absorb the ramp.
And if you take nothing else from this article: measure your arrival rate. Not your utilization. Not your queue depth. Your arrival rate. In requests per second, over a 5-minute window, by hour of day. That single measurement will tell you more about whether to invest in autoscaling or admission control than any benchmark, any vendor pitch, or any article — including this one.
Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.
Top comments (0)