Here’s a thing that took me longer to internalize than it should have.
You can run an A100 at 98% utilization and be wasting most of it.
Not in a subtle way. In a “you are paying ten times what you need to” way. The GPU is genuinely busy. The metric is not lying about the hardware state — it’s lying about what you care about, which is tokens out the door per dollar. And because utilization is the number that appears on every dashboard and every FinOps review, a lot of teams have optimized their way into a corner using a metric that can’t see the problem.
I want to lay out the cost model that does work, the levers in the order they actually pay, and a break-even calculation for the self-host-vs-API question that most people are answering with vibes.
One scoping note: this is about serving models on GPUs you rent or own. If you buy tokens from a hosted API, your cost problems are real but different — that’s token FinOps, and it’s a metering and attribution discipline. This post is about the unit economics underneath the token.
Why utilization can’t see it
LLM inference has two phases with completely different hardware characteristics, and conflating them is the root of most bad cost intuition.
Prefill processes your prompt. It’s compute-bound — it saturates tensor cores and scales with prompt length. It’s the phase that behaves the way you expect a GPU to behave.
Decode generates tokens one at a time. Each token requires reading the entire model’s weights from high-bandwidth memory to produce a single token. It is memory-bandwidth-bound, and at batch size 1 the tensor cores are largely idle, waiting.
That’s the trap. A decode-heavy server at batch size 1 shows high utilization because the memory subsystem is pinned. Push it to batch size 32 and each weight read now serves 32 sequences instead of one. Utilization barely moves. Throughput goes up roughly an order of magnitude.
Utilization measures busy. You’re paying for productive. For inference those diverge violently, and the divergence is the whole opportunity.
The metric that works:
cost per 1M tokens = (instance $/hour ÷ tokens/hour at your latency SLO) × 1,000,000
The “at your latency SLO” clause is load-bearing. Throughput and latency trade against each other — larger batches raise throughput and raise time-to-first-token. A cost-per-token number without a latency constraint attached is a number you can improve indefinitely by making the product unusable.
Measure it like this, not from a spec sheet:
# Output tokens per second, actually served
sum(rate(vllm:generation_tokens_total[5m]))
# Prefill tokens per second — usually the ignored half of your bill
sum(rate(vllm:prompt_tokens_total[5m]))
# Are you actually batching? If this hovers near 1, stop reading and go fix it.
avg(vllm:num_requests_running)
# The constraint your cost number must respect
histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le))
If num_requests_running sits near 1 under real load, nothing else in this post matters yet. That’s the finding.
The five levers, in payback order
I’ve seen this list attempted in almost exactly reverse order — buy more GPUs first, quantize in a panic later — so the ordering is the actual advice.
1. Continuous batching (config change, 3–4x)
Naive batching gathers a fixed group, runs it, and waits for the slowest sequence before starting the next group. Since request lengths vary enormously, short requests sit finished-but-blocked while one long generation drags on.
Continuous batching works at the iteration level: the moment any sequence completes, its slot is refilled from the queue. No batch boundaries, no waiting for stragglers. The reported delta is typically 3–4x throughput on identical hardware.
Every serious stack supports it — vLLM, SGLang, TensorRT-LLM. Which means the teams not getting this are the teams who deployed with defaults from a tutorial and never revisited it. Check first. It is the cheapest 3x you will ever find.
2. KV cache and prefix reuse (the big one for agents)
Paged attention ships by default in every 2026 production stack, so you probably have the mechanism. The question is whether your architecture lets you use it.
A cache hit on a shared prefix means prefill compute that never happens — the self-hosted analogue of prompt caching, except you own the eviction policy and therefore the eviction problem. Published engineering analyses put combined KV cache techniques at 4–40x cost reduction for long-context inference, and the upper end of that range is specifically workloads with heavy prefix sharing.
Which is to say: agent workloads. An agent loop resends a growing transcript on every single step. Step 12 shares its entire prefix with step 11. If your prompt is constructed so the stable content sits at the front and the variable content at the back, nearly all of that prefill is free. If you interpolate a timestamp or a request ID near the top of your system prompt, you have invalidated the cache for every request in your fleet, forever, and your bill reflects it.
I’ve seen exactly that bug twice. Both times it was one line. Both times it was worth a meaningful fraction of the inference budget.
The other half is routing: a cache hit only helps if the request lands on the replica holding the cache, which is a load balancing problem, not a serving problem. That’s what the Inference Gateway exists to fix, and it’s why cache-aware routing improves latency and cost simultaneously — a rare combination.
Also worth reading with this: context engineering. Cache economics and context discipline are the same subject approached from different ends.
3. Quantization (1.3–2x, mild quality cost)
FP8 on H100-class hardware, natively supported in vLLM, commonly gives 1.3–2x throughput over FP16 at under 2% quality degradation on instruction-tuned models. INT4 and other aggressive schemes go further with correspondingly more risk.
The reason this is third rather than first isn’t that it’s small — it’s that it’s the first lever with a quality cost, and therefore the first one that needs an evaluation harness before you turn it on. Deploy an eval set for your actual task, measure before and after, and treat “under 2%” as a claim to verify on your workload rather than a guarantee. Without that harness you’re not optimizing, you’re gambling with a metric nobody is watching.
4. Right-size the model to the task
This is a bigger topic than a bullet — see the SLM tier — but in cost terms the summary is blunt: serving a 7B model is roughly an order of magnitude cheaper than a 70B+ model, and a large share of production traffic is classification, extraction, routing, and formatting, where the larger model’s advantage is invisible.
Auditing which requests actually need your biggest model is usually the highest-value hour anyone spends on inference cost, and it requires no infrastructure change at all.
5. Infrastructure: fractional GPUs and spot
Now, and only now, touch the hardware layer.
Fractional GPUs — MIG, time-slicing, MPS — matter when you serve small models that can’t fill a card. An A100 running a 3B model at low traffic is mostly billed idle capacity. MIG partitions turn one card into several isolated ones. This is the closest thing inference has to bin-packing, and most clusters aren’t doing it.
Spot capacity for anything interruptible: batch scoring, embedding generation, evals, offline classification. Not for latency-sensitive serving unless you’ve built genuinely robust drain-and-migrate, and be honest about whether you have.
More replicas is the last resort, not the first. Adding a replica to a fleet running at batch size 2 buys you more expensive idle capacity.
The break-even question
“Should we self-host or use an API?” is asked as a rate comparison and is actually a utilization question. A dedicated GPU bills 24 hours a day. An API bills per token. So self-hosting wins above a volume threshold and loses — often catastrophically — below it.
Here’s the calculator. It includes the two inputs people leave out, which are the two that usually decide it:
#!/usr/bin/env python3
"""Break-even analysis: self-hosted inference vs hosted API.
The defaults below are illustrative. Replace every one of them with
numbers measured on your own workload — especially tokens_per_second,
which must be measured at your latency SLO, not from a benchmark blog.
"""
from dataclasses import dataclass
@dataclass
class SelfHosted:
instance_cost_per_hour: float # on-demand or committed rate
replicas: int
tokens_per_second: float # MEASURED, at your p95 TTFT target
duty_cycle: float # fraction of hours actually serving load
engineer_fte: float # fraction of an FTE operating this
engineer_cost_per_year: float
def monthly_cost(self) -> float:
hours = 730
infra = self.instance_cost_per_hour * self.replicas * hours
people = (self.engineer_fte * self.engineer_cost_per_year) / 12
return infra + people
def monthly_tokens(self) -> float:
seconds = 730 * 3600 * self.duty_cycle
return self.tokens_per_second * self.replicas * seconds
def cost_per_million(self) -> float:
tokens = self.monthly_tokens()
return (self.monthly_cost() / tokens) * 1_000_000 if tokens else float("inf")
@dataclass
class HostedAPI:
input_cost_per_million: float
output_cost_per_million: float
input_output_ratio: float = 4.0 # typical: prompts dwarf completions
def cost_per_million(self) -> float:
r = self.input_output_ratio
return (self.input_cost_per_million * r + self.output_cost_per_million) / (r + 1)
def breakeven_tokens_per_month(sh: SelfHosted, api: HostedAPI) -> float:
"""Monthly token volume above which self-hosting is cheaper."""
api_rate = api.cost_per_million() / 1_000_000
return sh.monthly_cost() / api_rate if api_rate else float("inf")
if __name__ == "__main__":
# ILLUSTRATIVE INPUTS — substitute your own
sh = SelfHosted(
instance_cost_per_hour=3.50,
replicas=2,
tokens_per_second=850, # measured at p95 TTFT < 800ms
duty_cycle=0.35, # be honest: nights and weekends count
engineer_fte=0.25,
engineer_cost_per_year=220_000,
)
api = HostedAPI(input_cost_per_million=3.00, output_cost_per_million=15.00)
print(f"Self-hosted monthly cost: ${sh.monthly_cost():>12,.0f}")
print(f"Self-hosted monthly tokens: {sh.monthly_tokens():>13,.0f}")
print(f"Self-hosted $/1M tokens: ${sh.cost_per_million():>12,.2f}")
print(f"API blended $/1M tokens: ${api.cost_per_million():>12,.2f}")
print(f"Break-even volume/month: {breakeven_tokens_per_month(sh, api):>13,.0f} tokens")
verdict = "self-host" if sh.cost_per_million() < api.cost_per_million() else "API"
print(f"\nAt current volume and duty cycle: {verdict}")
print("Now re-run with duty_cycle=0.10 and see what happens.")
Run it with duty_cycle=0.10. That’s the experiment that matters, and it’s the number nobody puts in the business case. A cluster sized for peak and idle overnight is billing you at full rate for every idle hour. I have watched self-hosting decisions justified on peak-hour arithmetic and then quietly lose money for a year, because the model in the spreadsheet assumed a duty cycle nobody ever checked.
The engineer_fte input is the other one. Operating a serving stack is not free — someone patches vLLM, chases OOMs, manages driver upgrades, and gets paged. A quarter FTE is not a pessimistic estimate for a real production deployment; it’s often generous.
None of which means don’t self-host. Self-hosting wins on sustained high volume, data residency, latency floors, and model control, and those are frequently the actual reasons — which is fine, as long as the cost argument isn’t being used to launder a decision made for other reasons. Say the real reason out loud. It’s usually a better argument anyway.
What I’d actually do this quarter
If you inherited an inference platform tomorrow, in order:
- Graph
avg(num_requests_running)under peak load. If it’s near 1, fix batching. Stop here until it’s fixed. - Compute your real cost per million tokens at your p95 latency target. Not utilization. Not $/GPU-hour.
- Audit prompt construction for cache-invalidating content near the front. Look for timestamps, UUIDs, and user names in system prompts.
- Count what fraction of requests genuinely need your largest model.
- Compute duty cycle honestly, then re-run the break-even.
That’s a week of work and it routinely finds several multiples. Meanwhile the thing most orgs do first — negotiating GPU pricing — moves the number by maybe 20%.
The deeper point is one that generalizes past inference. AI infrastructure is new enough that we’re still using metrics inherited from the last era of infrastructure, and some of them measure the wrong thing now. Utilization was a good proxy when work was compute-bound and roughly uniform. Inference is neither. The dashboards haven’t caught up, and the money is sitting in the gap.
Related: Round-robin is malpractice for LLM traffic · Context engineering: the window is a budget, not a bucket · NAS vs SAN for GPU workloads
Sources: KV Cache Optimization for LLMs 2026: Engineering Guide · AI Inference Cost Optimization: FinOps Playbook 2026 · Kubernetes GPU Cost Crisis · Cutting LLM Inference Costs in 2026 (GMI Cloud)
Comments