# What one H200 actually holds: KV-cache capacity for Qwen 3.6 27B FP8 on vLLM

2026-07-26 · https://tomvaucourt.com/posts/qwen-h200-working-set/

Ask how many users a GPU can serve and every answer you get is denominated in
tokens per second. Benchmarks are tok/s. The vLLM startup log greets you
with throughput numbers. That's the first mistake, and
it hides a second one: expecting the answer to be a number at all.

I spent a few evenings [measuring it on a single
H200](https://github.com/T0mSIlver/working-set) running Qwen 3.6 27B FP8 on
vLLM. The right first question is: how many users' sessions fit warm in KV
cache? The answer is a property of your users, not of the GPU.

## What the GPU is doing between your requests

An inference server does two very different jobs. **Prefill** reads every
token of your prompt in parallel. It's compute-bound and happens before you
see any output. **Decode** writes the answer, one token at a time, each step
reading the model weights plus everything cached so far. Decode is what you
see: tokens streaming, tool calls firing. Prefill is invisible. Nothing
moves, and a long one is indistinguishable from a hang.

The KV cache is the server's memory of text it has already read: every
prefilled token leaves a small block of attention state behind, and keeping
that around means never processing the same token twice. Agent traffic is
shaped to exploit this. Every request your coding agent sends is the previous
request plus the model's answer plus a small new tail, so a byte-identical
prefix grows with every request: same system prompt, same conversation
history, same tool outputs. If that prefix is still resident in cache, the
server prefills only the tail. If it was evicted, it re-processes all of it.

The effect is not subtle. There's a [public Claude Code session
breakdown](https://gist.github.com/huytd/3a1dd7a6a76fac3b19503f57b76dbe65#5-request-by-request-breakdown)
where a two-minute burst bills as 3M input tokens but only 138k of them were
actually processed. The other 95% hit cache. This is inherent to agentic
coding, where an LLM conversation is composed of many short request/response
pairs, tool calls and tool results, each one resending the whole history.

<svg viewBox="0 0 640 210" role="img" aria-label="Timeline comparing a warm request, where only the small new tail is prefilled and the first token arrives in seconds, with a cold request, where the entire context is re-prefilled before an identical decode begins." style="width:100%;height:auto;display:block;margin:2rem auto;">
  <style>
    .f1-lab{font-size:12px;fill:currentColor}
    .f1-row{font-size:12px;font-weight:600;fill:currentColor}
    .f1-mut{font-size:12px;fill:var(--fg-muted)}
    .f1-in{font-size:12px;font-weight:600;fill:var(--bg)}
    .f1-pre{fill:var(--code-bg);stroke:var(--border)}
    .f1-dec{fill:var(--accent)}
    .f1-tt{stroke:var(--fg-muted);stroke-width:1;stroke-dasharray:3 3}
    .f1-ax{stroke:var(--border);stroke-width:1}
  </style>
  <text class="f1-row" x="52" y="53" text-anchor="end">warm</text>
  <text class="f1-mut" x="60" y="30">prefill: just the new tail</text>
  <rect class="f1-pre" x="60" y="38" width="16" height="22"/>
  <rect class="f1-dec" x="76" y="38" width="170" height="22" rx="2"/>
  <text class="f1-in" x="161" y="53" text-anchor="middle">decode</text>
  <line class="f1-tt" x1="76" y1="62" x2="76" y2="82"/>
  <text class="f1-mut" x="80" y="93">first token: seconds</text>
  <text class="f1-row" x="52" y="135" text-anchor="end">cold</text>
  <rect class="f1-pre" x="60" y="120" width="330" height="22"/>
  <text class="f1-lab" x="225" y="135" text-anchor="middle">re-prefill: the entire context (100k+ tokens)</text>
  <rect class="f1-dec" x="390" y="120" width="170" height="22" rx="2"/>
  <text class="f1-in" x="475" y="135" text-anchor="middle">decode</text>
  <line class="f1-tt" x1="390" y1="144" x2="390" y2="164"/>
  <text class="f1-mut" x="390" y="176" text-anchor="middle">first token: after the full re-read</text>
  <line class="f1-ax" x1="60" y1="196" x2="578" y2="196"/>
  <polygon points="578,192 588,196 578,200" fill="var(--border)"/>
  <text class="f1-mut" x="596" y="200">time</text>
</svg>

## Warm session = comfortable user

The proxy I dimension around: **a user is served comfortably when their
session stays warm in cache**, warm meaning their prefix is still resident,
nothing has evicted it. A warm user's next request prefills a few thousand
new tokens, which is seconds, and the GPU's work stays mostly decode. Lose
warmth and users wait through 100k+ token re-prefills that also steal decode
bandwidth from everyone still warm.

Every API provider prices these same mechanics: writing to cache costs a
premium over plain input, but reading from it costs a fraction. Anthropic
bills a cached read at a tenth of a fresh input token. So the bill for
agent traffic is mostly storage and reads rather than compute.

How much of that discount you collect is decided by the harness, the client
program that assembles each request (Claude Code, opencode, and friends).
That's because the server reuses a prefix only if it comes back
byte-identical: ideally append-only history, stable tool-output
serialization, no timestamp or request id near the top of the prompt. vLLM
can reuse part of a cached prefix, but a one-token change invalidates all of
the cache that comes after it.

When people claimed opencode doesn't cache tokens, its founder dax [posted
a week of cache hit rates by user
agent](https://x.com/thdxr/status/2079953961579102612) from their inference
service: 96.2% for the best client down to 67.8% for the worst, on the same
backend. At a 10× cached-read discount that spread is worth roughly 3× on
the input bill, and it is decided entirely client-side.

The hit rate is engineered on both sides of the API.

The [working-set repo](https://github.com/T0mSIlver/working-set) makes
"comfortable" operational: a returning user's request hits the warm prefix,
which keeps time-to-first-token (TTFT) to a few seconds at most, and decode
stays above 40 tok/s. 40 is the hard floor, 50 is comfortable. I wanted to
know which of the two breaks first, and to answer that you need to know how
much cache you actually have.

## vLLM told me 352k tokens. It was off by nearly 4×

vLLM 0.19.0 prints `GPU KV cache size: 352,000 tokens` at startup for this
model, and `Maximum concurrency 5.1x` at 262k max sequence length. Those two
numbers contradict each other: 352k divided by 262k is 1.34, not 5.1. The
size report turns out to be a [known
issue](https://github.com/vllm-project/vllm/issues/37121) on 0.19.0.

So I measured it. The method: harvest real prompts from my own coding-agent
logs, inject a UID at the start of each system prompt so no prompt can reuse
another's cache, send n of them in parallel for three rounds at 140k tokens
each, and flag a request as warm when its TTFT comes in under 0.4× the cold
time. Sweep n from 7 to 10 and watch where warmth collapses.

The true pool lands in [1139k, 1399k] tokens, nearly 4× what the log claims:
enough for the full KV of 4 to 5 max-length sequences. And 5.1 × 262144 =
1337k sits inside the measured interval, so I deduced the concurrency
multiplier was right all along. Only the token count printed next to it was
wrong.

The collapse at n = 10 is the failure mode you'll actually hit. The cache
evicts least-recently-used (LRU) entries: to make room it drops whatever was
touched longest ago. Ten prompts cycling in order through a cache that holds
nine is the textbook worst case for that policy, because each prompt gets
evicted just before its turn comes around again, so the hit rate goes to
zero. One user too many doesn't cost you one user's worth of comfort.

## One measurement is not a capacity plan

So the repo builds a [transparent
model](https://github.com/T0mSIlver/working-set/blob/main/docs/scenarios.md)
on top of it: calibrate the memory budget so it reproduces the measured
pool, then fill that pool Monte-Carlo style, meaning thousands of random
draws of simulated sessions so you get a spread of outcomes rather than a
single estimate. Each resident session is charged its fixed per-session
state, and decode speed is priced against memory bandwidth, since every
decode step has to stream the weights and the cache out of GPU memory. The
sessions are drawn from a log-normal distribution fitted to 1,850 of my own
coding-agent requests, median 31k tokens. Log-normal because session lengths
are inherently skewed: context accretes multiplicatively as an agent works,
so most sessions sit near the median and a few grow enormous. Every number
below regenerates from
[`tables.py`](https://github.com/T0mSIlver/working-set/blob/main/scripts/tables.py).

For the 27B model with FP8 KV cache on one H200, the reference workload
holds 86 warm users on the median draw (p50), and 69 on the p5 draw, the
pessimistic one that 95% of draws beat. The finding I didn't expect: in
every configuration the repo studies, one or two GPUs, dense or
mixture-of-experts, **the cache binds before the bandwidth does**. Even with
every warm session decoding simultaneously, per-user speed stays at or above
41 tok/s; the speed floor alone would allow 118 concurrent decoders, but the
cache runs out at 86 users. That ordering is bought by MTP, a
speculative-decoding feature covered with the knobs below: every decode
figure here includes its 1.7× speedup, and turning it off admits only 60
decoders at the floor, fewer than the 86 warm users, so bandwidth binds
first. With MTP, capacity planning for agentic coding is cache planning.

Why does memory run out before bandwidth? Because memory is consumed by
presence, whereas bandwidth is consumed by activity. A warm session occupies
its slice of the pool around the clock, about a gigabyte for a median
session (31k tokens × 32 KiB per token of FP8 KV on this model), whether the
user is typing or reading a diff. Bandwidth, by contrast, is drawn only
during decode, and the weight read, the big fixed cost of every step, is
amortized across everyone decoding at once.

Agentic coding traffic is long-context and low-duty-cycle: everyone is
resident, few are decoding at any instant, so memory fills long before
bandwidth saturates. Flip the workload to short-context chat with every user
streaming and bandwidth binds first on the exact same GPU.

The H200's 141 GB of memory and 4.8 TB/s of bandwidth don't pick the
bottleneck. The shape of your traffic does.

## There is no single capacity number

The reference distribution has a 31k median prompt. Make the sessions
heavier and the same GPU serves a different number of people:

| median context per request | 31k | 45k | 60k | 80k | 100k | 140k |
|---|---:|---:|---:|---:|---:|---:|
| warm users (p50) | 86 | 56 | 42 | 33 | 28 | 23 |
| warm users (p5) | 69 | 45 | 34 | 27 | 23 | 19 |

Plan on the p5 row. Halfway to doubling the median already costs a third of
the capacity, and harnesses that stuff 30k tokens of system prompt, skills,
and rules before the user says a word are moving themselves left-to-right
across this table. So the only honest answer to "how many users fit on an
H200" is a function, and I put the function online: an [interactive
explorer](https://workingset.tomvaucourt.com/) with the full model behind sliders for the
workload, LLM, KV dtype, and topology. Put your own median in.

## The knobs, in order of leverage

If you serve this yourself, these are the levers, biggest first.

**FP8 KV cache** (`--kv-cache-dtype fp8_e4m3`) stores the cache in 8-bit
floats instead of 16, which doubles the pool. Under FP16 the same GPU holds
45 warm users, and per-user decode at 64 concurrent sequences drops to 39
tok/s, under the hard floor. One flag, [minimal quality
impact](https://vllm.ai/blog/2026-04-22-fp8-kvcache), the difference between
the table above and half of it.

**A shared, byte-stable prefix.** Counterintuitive: a *bigger* shared system
prompt raises warm capacity, because it's stored once and shared by every
session. Sweeping the user prefix 3k → 15k → 30k moves warm capacity
209 → 280 → 399 in the repo's MoE configuration (Qwen 3.6 35B-A3B). But the
win is fragile in both directions: a request that can't match the prefix
re-prefills the whole thing, and per-user drift in a 30k prefix turns the
sharing into 30k × N of duplicated cache. Whether the prefix stays
byte-stable is, again, harness engineering. And users pay for it in context
window: a 30k system prompt they can't trim spends 30k of their window
before they've said a word.

**CPU KV offload.** Memory is scarce because it's consumed by
idle-but-resident sessions, so move precisely those to system RAM. An
offloaded session keeps its warmth as storage while GPU memory serves the
sessions actively prefilling and decoding. The repo's scenario study puts
600 GiB of RAM at ~2,400 warm sessions for the MoE sibling on one H200, an
order of magnitude past what the GPU holds alone. Caveat: the model prices
offload as storage only; it doesn't price the PCIe-bandwidth-bound restore
on a session's next turn, 0.1 to 0.3 seconds per 100k tokens plus
contention. CPU-offloaded sessions are "less warm" than GPU-resident ones.

**`max_seq_len` at 180k** instead of the model's 262k maximum. Caps the
worst-case cold prefill and stops one runaway session from hogging the pool.

**Tensor-parallel over data-parallel** for the second GPU. Data-parallel
(DP2) runs two complete, mutually unaware engines behind a load balancer:
each replica has its own weight copy, its own scheduler, and its own private
KV pool. Your session's cache is a data structure inside whichever engine
served you. Land the next request on the other replica and you re-prefill
100k tokens cold while a perfect copy sits idle on the card next door, and
the session now eats pool in both replicas. So DP's warm capacity is 2 × 86
only if the router pins every user to a home replica, and that router is
yours to build. [vLLM's own
docs](https://docs.vllm.ai/en/latest/serving/data_parallel_deployment/#internal-load-balancing)
are explicit about what the built-in balancer looks at:

> Currently, the internal DP load balancing is done within the API server
> process(es) and is based on the running and waiting queues in each of the
> engines. This could be made more sophisticated in future by incorporating
> KV cache aware logic.

Queue depth, then. The balancer does not know where your cache lives.

Tensor-parallel (TP2) instead runs one engine with the weights sharded
across both GPUs: one pool, one hash table, no routing condition. The second
weight copy DP pays for becomes cache instead, so the pool grows 2.3–2.4×,
not 2×, and holds 202 warm users unconditionally.

**MTP speculative decoding.** Every tok/s figure in this post assumes it:
the tested config decodes two tokens per forward pass, priced as a 1.7×
speedup, roughly 47% per-draft acceptance; real coding traffic runs higher.
Not every model has this. Qwen 3.6 ships a trained MTP (multi-token
prediction) head, a small module that drafts tokens for the main model to
verify in the same forward pass. Turn it off and every decode number above
divides by 1.7, and bandwidth binds before cache size.

The Qwen 3.6 MTP head is half of why this model serves coding agents so
well. Light KV is the other half. Most of its layers replace standard
attention with a linear-attention variant (Gated DeltaNet) that keeps one
small constant-size state per session instead of state that grows with every
token. As a result, only the minority of standard-attention layers pay
per-token KV at all: 32 KiB per token, where a standard-attention stack this
size pays several times more. A few months ago you could not hold this many
sessions on one GPU with a model this size, and decode was slower too,
because every step re-read a heavier cache.

And one non-knob: oversubscription past warm capacity degrades into
cold-TTFT churn, not slow decode. Users don't feel it as "the model got
slower," they feel it as "the tool randomly hangs for a minute." That
symptom is LRU eviction.

## What this model doesn't know

This is a calculation with one measured anchor. The repo [says so
precisely](https://github.com/T0mSIlver/working-set/blob/main/docs/scenarios.md#limitations):
the FP8 pool is projected from the measured FP16 pool, prefill/decode
interference isn't priced, and "warm" is a cache-residency claim, not a
latency SLA. Each is one measurement run away from being checked, and a
re-measured anchor recalibrates everything downstream of it. That's the
standard I'd hold any capacity calculator to: not that it's right, but that
it states exactly what would prove it wrong.

The model tells you which knob moves the number. It can't tell you the
number. Calculation narrows the search, but load testing decides.
