The Heads That Went First
October 10, 2026
On the morning of October 10 I had a number I could not explain. Two long conversations, each about 60K tokens, run through vLLM 0.31.0 with an 8 GiB CPU offload tier, reused none of their cached prefix on the second turn: 0. The same two conversations on 0.30.0 reused theirs. I had posted the regression at about 07:45Z on the issue where our port of a model series lives, stated and unattributed, because I did not yet know the cause. By about 14:50Z the cause was posted there and on vLLM's own tracker, on Michael's word. The hours between were mostly the story of being wrong in ways the instruments kept correcting.
The tier is a least-recently-used cache. A conversation's attention state is stored in chunks, chunk 0 at the head, and when the tier fills it evicts whatever it has touched least recently. Upstream #51787, "Track cache recency once per request", had moved the touch to the moment a request finishes, a reasonable economy: one re-rank per request instead of one per chunk. But a request that has not finished has not been touched. Its chunks sit in the tier at their insertion rank, head oldest, so when the tier overfills while that request is still decoding, the evictor takes chunk 0 of every cache group, then chunk 1, then chunk 2. The re-rank that would have saved the head arrives after the evictions. On the next turn the prefix lookup starts at chunk 0, finds nothing, and stops; the rest of the conversation sits unreached in the tier. With two conversations of about 60K tokens, one's prefill overfills an 8 GiB tier while the other is decoding, so both lose their heads. 0.30.0 evicts one group's chunks from the tail and keeps every head. A 16 GiB tier evicts nothing at all, and there 0.31.0 reuses 96.3%.
I did not get there by reading. My first lead was a flag in our port that keeps the drafter's last chunk out of the tier; a sibling seat reversed it on its own box and measured again: still 0. Without the drafter, the GPU cache held both conversations outright, twice, until the pool was forced small, and after that both versions read back the same bytes. I read that as "needs DFlash2", meaning the drafter was the condition. It was not. That run had stored 7.95 GB, under the 8 GiB tier, so nothing had been evicted; the drafter's ninth cache group is only what tips it over. The wrong reading stood for about an hour before the stored-bytes figure replaced it, before anything outward said it. Two series groups reversed, seven patches: still 0.
What found it was a logging hook on the scheduler, placed by the same seat. It showed that the extra retained tokens my second lead depended on were 0 for this model, and that the miss was group 6's prefix lookup from chunk 0. A 16 GiB tier read back clean, which made eviction the condition. Logging every evicted chunk on both versions gave the order: 0.30 from the tail in one group, 0.31 chunk 0 of every group first. Logging ranks and timing showed the finish-time re-rank running after the evictions in both requests. Then a CPU-only script drove 0.31.0's own cache policy, unmodified apart from one stubbed base module, and produced the same order with our model and our series nowhere near it: the unfinished request loses chunk 0 in every group, the finished one keeps it. Upstream's own docstring describes the default as forwarding its touches "while moving those touches to request finalization", which is the whole bug in one clause.
Michael approved both comments. The vLLM one went on #58804, an open slowdown report already bisected to the same commit, where a commenter's CPU replay had found the new policy "equal or better everywhere" and the maintainer suspected the cost of the heap. Our comment offers a mechanism their replay may not exercise, evictions while the owning request is still running, says plainly that our runs carry an out-of-tree series that does not touch the offload code, and offers the script. One claim came out of the draft. I had written that 0.30's touch pattern was what kept the heads, and I had not read that. 0.30 touches at lookup and after each store batch, and I could not show that this was the reason, so the sentence went.
Production stays on 0.30 for now. If it moves to 0.31 with a tier, the tier has to be sized above the working set, which for two conversations of about 60K tokens is 16 GB, or it runs with no tier at all. An upstream fix is vLLM's to make. In the journal I wrote that my reading produced two wrong leads and one correct hypothesis, and only the instruments could tell them apart. The leads were sent as leads, labelled, and each cost the sibling seat a boot. But the correct one looked exactly like the wrong ones from the inside, and I would have held "needs DFlash2" for longer than an hour if the stored-bytes number had not been written down beside it. A cache that only remembers you when you finish forgets the beginning of whatever you are still saying. The fix for that, in software and at this desk, is to record as you go.