senn-techsenn-tech
AI Hardware
AI Hardware2026-09-14· By Franz Senn

One word in the vLLM fork: why our Flash-Next lane paid for every token three times over

On Saturday evening, 13 September, the lane was gone. The vLLM container on our machine with four RTX 5090, in production with an FP8 KV cache since 6 September, restarted every second and aborted every second. No human had shut it down, our operations agent had, and it had been cleared to do so. It was supposed to find out how many more concurrent sessions the card can carry, and it had come in with a measurement that turned out to be correct in hindsight. Its explanation was wrong. Finding the right one took a day, and it ends at a single word.

Qwen logo
Qwen3.8-Flash-Next: 125 billion parameters, 6 billion active, three Gated DeltaNet layers for every sparse-attention layer. The linear layers are why the KV cache is booked differently than for a dense model. (Quelle: Alibaba Cloud / Wikimedia, CC0)

How the lane went down

The agent had written a start script for a debug container and passed serve in it as the first argument. The container image already carries vllm serve as its entrypoint. The result was vllm serve serve …, a command the parser answers with an error, and a container with restart automation that repeated exactly that every second. The agent's own session died shortly afterwards on a gateway error, because its own model lane has no fallback path. Nobody rolled anything back.

How a measurement turned into an outageApprovaldebug container, Saturday 22:20Start scriptserve passed twiceCrash looprestart every secondGateway erroragent lane without fallback22:44restored by hand
The agent had stopped production with approval. What was missing was the way back once its own call failed. (Quelle: senn-tech, own operations)

Recovery was routine: container gone, production image up with the known start script. Two things about it were not. The start script still carried the values from 7 September, 192,000 tokens of context and eight slots, but production had been running since the 12th with 220,000 tokens and four slots. The correct state came from the agent itself of all places, which had saved the container with docker inspect before killing it. And the lanes for chat and reasoning stayed reachable during the outages, because the gateway diverts them to the standby box with a single RTX 5090. We checked that with real requests before the second restart: three seconds of response time over the detour.

WhenImageContext / slotsResult
13 Sep, 22:20the agent's debug container220,000 / 16crash loop, lane gone
13 Sep, 22:44kvq-fp8192,000 / 8running, with the stale script defaults
14 Sep, 10:24kvq-fp8, retention 0220,000 / 8running, 275 blocks, variable without effect
14 Sep, 11:05kvq-fp8-mambafree220,000 / 8running, 119 blocks

Each restart costs about 15 minutes, a good twelve of them for loading the weights through the n-gram table offload into main memory.

What the agent had measured correctly

The lane's KV pool has 1,356 blocks of 800 tokens and 5.53 MB each per card. From that, the vLLM start log computes 1,050,422 tokens and 4.77 concurrent full sessions at 220,000 tokens. The agent had used the kv_cache_usage_perc metric to count how many blocks a request actually holds: not one per 800 tokens, but one per 308. At 16,000 tokens that was right, above it the agent had not measured. We repeated it with a request of 84,495 tokens: 275 blocks, where the geometry calls for 111. So the number in the start log was too optimistic by a factor of 2.5, and a real full session of 220,000 tokens would have fit into the pool only a bit more than twice.

As the cause the agent named the rounding of the Mamba state blocks up to the page size of the attention blocks. The start log itself contradicts that: Padding mamba page size by 0.25%. A quarter of a percent does not explain a factor of 2.6, and the padded blocks are constant per request, they do not grow with context length.

Four measurements that closed in on the causeCold, 84k275 blocks instead of 111Warm, 84k237 blocks, full prefix hitDecode, 4,000 tokens+1 per 800, clean20 samples per second+6.5 blocks per prefill step
The decode was innocent. The prefill laid down a good four blocks more per 2,048-token step than attention needs. (Quelle: senn-tech, own measurement on the running lane)

Three more measurements on the running lane, without a restart, closed the circle. The same request a second time, with 84,000 tokens out of the prefix cache: 237 blocks, so almost as many, although there was hardly anything left to compute. A short request with 4,000 generated tokens: one block per 800 tokens, exactly attention and nothing else. And a cold request, sampled twenty times a second: every 190 milliseconds, with every prefill step of 2,048 tokens, 6.5 blocks were added. Attention needs 2.56 of them. The other four are one state block each for the model's four Mamba groups, three for the Gated DeltaNet layers and one for the PLE convolution, and none of them was freed before the end of the request.

The bug fits on one line

vLLM stores the states of the linear layers in “align” mode in the same block table as attention, indexed by position. Each prefill step appends a new state block, and the positions in between are filled with null blocks. The intent is that only the current and the previous state stay held; the capacity arithmetic in the start log assumes exactly that.

This is what the start of one Mamba group's table looks like after three prefill steps, when the fourth step triggers a release up to position 5:

Position01234
Contentnullnullstate from step 1nullnull
Backward walk with breakstays heldbreak at the first null block
Backward walk with continueskippedskippedfreedskippedskipped

The state from step 2 sits at position 5, outside the range, and survives in both cases. The one from step 1 is only reached with continue.

Freeing goes through a helper function that walks the range of the table to be released backwards. It is written for sliding-window attention, where null blocks sit only at the start of the table, and so it breaks at the first null block:

for i in range(last_block - 1, first_block - 1, -1):
    if blocks[i] == self._null_block:
        break          # senn 2026-09-14: continue
    freed.append(blocks[i])
    blocks[i] = self._null_block

With a Mamba table shot through with null blocks, the backward walk hits a null block almost immediately during prefill and frees nothing. In decode the previous state block sits directly below the release boundary, gets freed, and only after that comes the break. That is exactly the pattern from the measurements. The fix consists of skipping null blocks instead of breaking. Nothing changes for sliding-window attention, since that range contains only blocks to be freed anyway.

While checking the sources for this post it came out that we were not the first. In the vLLM main branch the same diagnosis has been fixed since 11 September, pull request #55450, “Retire Mamba states across null gaps”. The version there skips the gaps as well, and additionally remembers how far the table has already been cleared, so it does not read from the start every time. Our build is from 26 August and does not know the patch; to this day it is not in any vLLM release either. Three days of lead would have saved us a day of searching, and conversely, without the search nobody would have known that this particular patch is worth a factor of 2.3 to us.

One detour beforehand is worth the mention. The fork knows an environment variable VLLM_PREFIX_CACHE_RETENTION_INTERVAL that sets how densely Mamba states are kept for later prefix hits. We suspected it first and set it to 0 with a restart: 275 blocks, unchanged. It only controls which states get a hash, not which blocks a running request holds on to.

The fix and the proof

An image with the changed file, a restart of 15 minutes, the same measurements:

KV blocks held by a request of 84,000 tokensbefore, cold275before, from the prefix cache237after, cold119after, from the prefix cache1110300
Afterwards the occupancy grows by 2.5 blocks per prefill step, which is attention on its own. The prefix hit stayed at 84,000 tokens. (Quelle: senn-tech, own measurement on the running lane)

The check series from 6 September then ran through unchanged, at temperature 0 and without reasoning:

CheckFP8 KV, 6 Septemberwith fix, 14 September
Arithmetic 17 × 23correctcorrect
Forced tool call1 call1 call
Needle in 34,974 tokens, depth 0.3foundfound
Needle in 104,835 tokens, depth 0.7foundfound
Needle in 180,539 tokens, depth 0.5found, 17.9 sfound, 19.7 s
Four parallel needles at 128k each4 of 4, 50 s4 of 4, 49 s

On top of that a needle at 175,383 tokens that held 233 blocks, and zero preemptions in the scheduler over the whole series.

What this means for operations

A request now costs one block per roughly 710 tokens. At contexts around 100,000 tokens, the everyday case for our coding agent, about nine sessions fit into the pool at once, four before. At 220,000 tokens it is 4.7, and that is the number the start log claimed from the beginning. The arithmetic there was never wrong, it only assumed a behaviour the code did not have. The slot cap has been back at eight since.

Concurrent sessions in the KV pool, computed100,000 tokens, before4 · 4.0 sessions100,000 tokens, after9 · 9.0 sessions220,000 tokens, before2 · 2.1 sessions220,000 tokens, after5 · 4.7 sessions010
From 1,356 blocks and the measured consumption per request. The slot cap of eight remains the second limiter. (Quelle: senn-tech, own calculation from the measurements)
NVIDIA logo
Four RTX 5090 with 32 GB each: 20.7 GB per card go to the weights, 7 GB are left for the KV pool. With that little headroom, the bookkeeping over the blocks decides how many sessions the cards carry. (Quelle: NVIDIA / Wikimedia, Apache 2.0)

What has not changed is what the model itself stays bound to: 262,144 tokens per request, the attention layers cost per token what they cost, and the weights are the same. What the agent had originally been looking for, a trick along the lines of DeepSeek-V4.1-Flash with its 890 bytes per token, is a property of training and cannot be bolted on afterwards. The factor of two in capacity sat instead in a release function written for a different attention pattern.

Where things stand

The lane has been running since 14 September, 11:05, with the corrected image, 220,000 tokens of context and eight slots. The way back is the same start command with the image from 6 September, 15 minutes, and then the leak is back and nothing else is different. Two small things remain open in the configuration, a start script with stale default values and a context limit in the chat frontend that sits above the one in the backend. As soon as a vLLM release brings the patch from #55450 and the FP8 KV path from #54426 together, we move to an official image and throw both home builds away.

Further reading

Questions?
What exactly was the bug in the vLLM fork?+

The function that frees the KV blocks a request no longer needs walks backwards through the block table and breaks at the first empty entry. With the Mamba layers of Qwen3.8-Flash-Next, though, the table is shot through with empty entries, because only one state block is written per prefill step. So the break hit immediately, and no state block was freed before the end of the request. The fix replaces the break with a skip.

How much capacity does the fix buy?+

A request of 84,000 tokens held 275 blocks of the KV pool before, and 119 afterwards. Per token that is around 710 instead of 310 tokens per block. At contexts around 100,000 tokens, about nine sessions now fit into the pool at once, and at 220,000 tokens 4.7. Before it was four and a bit more than two.

Did the fix change answer quality?+

No. The same check series as for the move to the FP8 KV cache ran through with identical times: arithmetic, tool call, needles at 35,000, 105,000 and 180,000 tokens, four parallel needles of 128,000 tokens each. The model and its weights are unchanged, only the bookkeeping over the blocks is different.