← Writing

Running Llama 3.1 8B with FP8 on vLLM Cuts Cost from $1.00 to $0.36 per Million Output Tokens

A completely untouched vllm serve of Llama-3.1-8B costs $1.00 per million output tokens on a rented L4. Turn on FP8 quantization and that drops to $0.36, a 64% cut. Nothing else I tested added anything further, and one setting (--max-num-seqs, the request-concurrency cap) actually got worse before I measured it properly.

The boring reason your bill is high

Most inference bills are high for a boring reason: someone ran vllm serve <model>, it worked, and nobody measured it again. vLLM already does more for you by default than most people realize. Quantization isn’t one of those things. Knowing which is which is the entire value of measuring instead of assuming.

I’m publishing the full method, not just the number. The scenario is deliberately ordinary — the kind of deployment I’d expect to find in production, not a strawman.

What I’m serving

Two rates, not one

One thing worth knowing before any of the numbers: every config in this post is measured twice, at genuinely different request rates.

The obvious way to benchmark a handful of configs is one fixed rate everywhere: warm up, fire requests at that rate, measure. I tried that first, at a rate gentle enough for the slowest config to keep up — every config landed within 1-2% of each other on cost per token. At a rate that gentle, the slowest config never falls behind, so every config trivially keeps up, and you end up measuring “cost when nothing is under load” — which erases the exact signal a cost audit needs.

So every config gets measured at two loads, reported separately, never blended:

This isn’t what caught the concurrency mistake later in this post — that took a third, more basic check.

What untouched vLLM actually costs

Before touching anything, I measured the real default:

vllm serve meta-llama/Llama-3.1-8B-Instruct --max-model-len 4096 --gpu-memory-utilization 0.85

(The --max-model-len cap isn’t optional here: Llama-3.1-8B-Instruct defaults to a 131,072-token context, and vLLM sizes its KV cache to guarantee it can serve one request at that length — 16 GB of KV cache reserved against a 24 GB card, on top of ~15 GB of weights. Uncapped, the server doesn’t start. The workload here never exceeds 2,150 tokens, so capping to a realistic length is itself the first real finding, not a workaround.)

This command already has prefix caching and chunked prefill turned on — both are vLLM 0.25’s actual default, whether you ask for them or not. What it doesn’t have is quantization.

MetricLight loadSaturation
Output throughput (tok/s)29.24122.17
p90 TTFT615 ms67 seconds
p50 end-to-end10.9 seconds59.5 seconds
Cost / M output tokens$4.18$1.0004

At light load the box barely works, so cost per token is dominated by mostly-idle capacity — the $4.18 figure says more about arrival rate than about the model. At saturation, with the GPU actually busy, the real number is $1.00 per million output tokens. That’s what the rest of this post is measured against.

Turning on FP8

Turning on FP8 for both weights and KV cache is a single pair of flags:

vllm serve meta-llama/Llama-3.1-8B-Instruct --max-model-len 4096 --gpu-memory-utilization 0.85 \
  --quantization fp8 --kv-cache-dtype fp8
MetricUntouched default+ FP8
Throughput at saturation (tok/s)122.17337.39
Cost / M output tokens$1.0004$0.3623

That’s a 64% cut, and it’s basically the entire story. Prefix caching and chunked prefill were already included in the $1.00 baseline, so they don’t get to claim any of this drop — quantization is the only lever a genuinely untouched deployment is actually missing, and it accounts for nearly all of the savings available on this hardware.

Llama-3.1-8B-Instruct isn’t an FP8-native checkpoint, so it ships no KV scales of its own, and I didn’t pass --calculate-kv-scales either. Per vLLM’s own documentation, that combination means every k_scale/v_scale defaulted to 1.0 — the least-calibrated setting available. If you’re deploying this for real, calibrate KV scales for your checkpoint, or at least confirm your version’s default matches this uncalibrated behavior.

Quantization is also the only lever here that can change the model’s actual answers, so I checked it: 20 RAG-shaped context blocks, each with one unique fact planted in it, asked to recall the fact, scored by exact-substring match.

bf16FP8
Fact-recall accuracy20/2020/20

Every item, both models got right. This is 20 deterministic, single-fact-extraction items, not a broad quality benchmark — it’s evidence against the specific failure mode I was checking for (quantization breaking fact retrieval from long context), not proof it can’t happen elsewhere. Check a broader eval on your own task before assuming it transfers, ideally on real retrieved documents rather than this synthetic proxy.

Where the rest of the gain comes from

To see each piece separately — not just “default vs. everything on” — I also built a fully de-optimized control: quantization off, prefix caching off, chunked prefill off, all explicitly disabled. This isn’t what a real deployment looks like (the real default already has two of these on), but it lets each lever’s individual contribution show up cleanly.

MetricAll-off control+ FP8+ Prefix caching+ Chunked prefill
Throughput at saturation (tok/s)93.72255.35327.65333.01
Cost / M output tokens$1.30$0.48$0.37$0.367

FP8 cuts cost 63% relative to this all-off control — nearly identical to the 64% cut measured against the real default above, which is a good cross-check: two very different starting points agree on FP8’s actual size. Prefix caching adds a further real gain: TTFT drops another 24%, well outside the ~2ms pass-to-pass spread. It’s also the lever most dependent on your actual traffic, since it only helps to the degree your requests share content. Chunked prefill (measured here together with an explicit --max-num-batched-tokens 2048, since the two aren’t separable in this test) adds a small further throughput gain (1.6%) but no tail-latency improvement on this workload — p99 TTFT actually ticks up slightly, from 562.58ms to 567.42ms, despite tail-latency protection being chunked prefill’s usual job.

Waterfall chart of cost per million output tokens falling across steps: all-off control $1.30, FP8 quantization down to $0.48, prefix caching down to $0.37, chunked prefill down to $0.367, then a flat segment for batch sizing at $0.366 showing no further measurable reduction.

Cost per million output tokens, all-off control → measured configuration, at saturation. FP8 does nearly all of the work; prefix caching is the second real, distinct step; chunked prefill adds a small further gain; batch sizing lands within noise of that last step.

StepCost / M tokCumulative saving vs. all-off control
All-off control$1.30
+ FP8$0.4863.3%
+ Prefix caching$0.3771.4%
+ Chunked prefill$0.36771.9%

The natural next question is whether tuning batch size adds anything further. It doesn’t — but getting there took a wrong turn first.

The setting that went backwards before it went nowhere

FP8 frees up several GB of memory. Spare memory can let more requests run concurrently, and more concurrency can raise throughput on a memory-bound GPU — but neither step is automatic. The obvious move is to raise the concurrency ceiling (--max-num-seqs) and expect a further gain.

My first guess was 64:

Metric+ chunked prefill+ batch (guessed: 64)
Throughput at saturation (tok/s)333.01301.07
Cost / M output tokens$0.367$0.406

That’s a regression. An “obviously reasonable” concurrency cap made the deployment worse than not touching the setting at all. The only way I caught it was by comparing this config’s saturation throughput directly against the previous step’s — a basic sanity check, not the two-load methodology above. So I swept the parameter properly instead of trusting the guess.

The first sweep used the same request count as every other measurement in this post — 75 timed requests per pass. That was a mistake: at saturation, realized concurrency can never exceed the number of requests fired, so any --max-num-seqs value at or above 75 was never actually tested against a real constraint. They tied because none of them ever bound, not because the curve had flattened. I reran the sweep at 600 requests per pass — large enough for a cap of 512 to actually mean something:

--max-num-seqs64128256512flag omitted (default)
Throughput (tok/s)322.19337.44337.79333.31337.39
Cost / M output tokens$0.3793$0.3622$0.3618$0.3667$0.3623

The real shape: 64 is genuinely worse than everything above it. 128, 256, and the default cluster together — a real plateau this time, not a tie caused by an undersized test. And 512 is a small but real step backwards, about 1.3% below the 128–256 band — past a certain point, more concurrency stops helping and starts costing a little, plausibly from scheduling overhead on batches that no longer fit the sweet spot.

The practical takeaway: guessing low costs you, guessing very high costs you a little too, and the safest move is to leave --max-num-seqs alone or land it in the 128–256 band — which is almost exactly where vLLM’s own default already sits.

Outside this audit’s scope

The biggest cost lever is often not on this list: does the task need an 8B at all? A well-tuned 3B, or a fine-tuned smaller model, can match an 8B on a narrow RAG task at a fraction of the cost — but that changes the model, and this audit was scoped to infra levers on the same model, not a claim that outputs are provably unchanged beyond the narrow probe above. It’s usually the first question worth asking, and frequently the largest single saving — it just isn’t free, since it needs a task-specific quality eval of its own. Speculative decoding is the same kind of story: real latency wins on the right traffic, but enough moving parts that it belongs in its own post, as I found on the M3.

What this means for your bill

If you’re serving an open model and haven’t re-measured since it went live, there’s a real chance the FP8 gap alone is sitting on your bill — to the extent your GPU-hours are actually near saturation. If they’re not, the real finding on your deployment is probably utilization, not any flag in this post.

And if the last time anyone touched a concurrency or batch setting they picked a number that “seemed reasonable” and moved on, there’s a real chance that’s costing you throughput right now, in either direction, on the hours you are near saturation. The only way to know is to check.


How I measured this

Subscribe