Writing

The KV cache is not your bottleneck

Every serving thread I read last year opened the same way: the KV cache is eating your memory, so quantize it, page it, evict it. All true. But when I finally sat down with a profiler in front of a 70B deployment that was missing its latency budget by 40%, the cache was not where the time went. Roughly a third of a request's wall clock was spent in places nobody writes blog posts about.

This is a walk through what the profile actually said, in the order I found it.

The setup

Single node, eight accelerators, tensor parallel across all eight, continuous batching with a target of 64 concurrent sequences. Prompts skew long — median around 6k tokens, a long tail past 30k — and generations are short, usually under 300 tokens. That shape matters, and I'll come back to it.

The symptom was p99 time-to-first-token drifting past two seconds under load, while p50 sat comfortably at 380ms. A tail problem, not a throughput problem. Those get misdiagnosed constantly, because the first instinct is to look at the thing that is largest rather than the thing that is most variable.

What the profile said

Three findings, in descending order of how much they embarrassed me:

The cache quantization work I had queued up would have addressed none of these. It would have bought memory headroom, which I did not need, at the cost of a small accuracy regression, which I did not want.

The largest line in a profile is rarely the one worth fixing. The one with the widest distribution usually is.

The fix that mattered

Splitting prefill and decode into separate scheduling lanes, with decode given strict priority, moved p99 TTFT from 2.1s to 640ms. No model changes, no kernel changes, about ninety lines of scheduler diff.

# Decode always wins. Prefill fills whatever budget is left.
CHUNK_SIZE = 2048

def next_batch(decode_queue, prefill_queue):
    if decode_queue and decode_budget_remaining():
        return build_decode_batch(decode_queue)
    return build_prefill_batch(prefill_queue, token_budget=CHUNK_SIZE)

The chunking matters as much as the split. Admitting a 30k prompt as one unit reintroduces exactly the head-of-line blocking you just removed; capping each prefill step at a fixed token budget keeps the decode lane's worst-case wait bounded by one chunk instead of one prompt.

What I'd check first next time

  1. Plot the distribution, not the mean. A tail problem and a throughput problem have different fixes and identical dashboards.
  2. Profile at production concurrency. The quadratic scheduler cost simply does not exist at the concurrency most benchmarks run at.
  3. Move everything off the request thread before touching the model. Tokenization, validation, template rendering — all of it is free latency to reclaim.

None of this makes the KV cache unimportant. It made my memory ceiling, and eventually I did quantize it. But it bought throughput, months later, for a different reason — and by then I knew which number I was moving.


Wrote something wrong here? Email me or find me on X.

More writing