Skip to content

Add mods/fix-kv-offload-disk-tier: EAGLE store filter + multi-node promoted-row re-sync - #373

Open
yaro-tal wants to merge 2 commits into
eugr:mainfrom
yaro-tal:fix-kv-offload-disk-tier
Open

Add mods/fix-kv-offload-disk-tier: EAGLE store filter + multi-node promoted-row re-sync#373
yaro-tal wants to merge 2 commits into
eugr:mainfrom
yaro-tal:fix-kv-offload-disk-tier

Conversation

@yaro-tal

@yaro-tal yaro-tal commented Sep 2, 2026

Copy link
Copy Markdown

Two bugs in OffloadingConnector + TieringOffloadingSpec with an fs
secondary tier. They're in one mod because neither fix is useful alone: apply
only the first and a multi-node cluster gets a working cache that corrupts;
apply only the second and there's nothing to correct, because the cache never
hits.

Found while running DeepSeek-V4-Flash-0731 across two DGX Sparks (TP=2, one GPU
per node). Verified against vLLM e2666d9a65f41fc376607531453cbd57c4c71016.

1. EAGLE/MTP groups can never certify a hit — the tier returns zero hits

The store path drops sliding-window chunks no lookup could reach, keeping only
the trailing tail chunks of each alignment segment. But
_sliding_window_lookup finds tail chunks and an unverified EAGLE group
then pops one (num_hit_chunks -= 1), so it needs tail + 1 consecutive
chunks — which the store filter has guaranteed it can never see.

Because _lookup() ANDs the per-group results, that group's permanent zero
vetoes every other group as well. The tier reports a 0% hit rate while happily
writing hundreds of GB. Measured here: 502 GB stored, 0 bytes ever read back.

Fix: also keep the segment-head chunk for eagle groups, so the retained set is
{0} ∪ {acc-tail .. acc-1} — a run of exactly tail + 1 consecutive chunks
across the segment boundary. The pop lands on a chunk at 0 (mod acc), so the
resulting length stays a multiple of the full-attention chunk size.

2. Multi-node TP silently serves wrong KV

SharedOffloadRegion is a per-node /dev/shm mmap. On a single node the
scheduler-side region (rank=None) and every worker region (rank=r) are the
same filerank only selects a slot within a row — so a promotion the tier
manager performs is visible to every worker for free. That assumption is
undocumented, and false as soon as TP spans nodes: each node has its own mmap,
and only the rank co-located with the manager runs a secondary tier at all.

  • GPU→CPU stores stay symmetric — every rank writes its own region
  • disk→CPU promotions land only on the manager's rank
  • the following CPU→GPU load reads each rank's own region

So every other rank feeds its GPU whatever stale bytes occupied that row.
Nothing raises, nothing warns, no checksum fails.

Hashing the same row index in both nodes' mmaps:

rows identical across ranks (before) after
written by GPU→CPU stores 112 / 112 112 / 112
written by disk→CPU promotion 0 / 112 112 / 112

Over a longer run the correspondence was exact: every divergent row was a
promoted row, and every promoted row was divergent.

The symptom depends only on what was in the stale row, which is why it presents
as several unrelated bugs — zeros (fresh mmap) give coherent output with
confabulated later content; a recycled row gives token soup and other
sessions' content bleeding into the response
. Hits served entirely from the
CPU tier are always correct, because no promotion is involved, which is what
makes it hard to catch.

Fix: the manager records the rows a completed promotion filled,
build_connector_meta() drains them into
OffloadingConnectorMetadata.promoted_rows, and every rank re-syncs them from
the manager's rank over the existing TP group before any load is submitted.
Ordering matters: build_connector_meta() runs on_schedule_end()
completed-job processing first, so a promotion that lands in a step ships its
row ids in that same step's metadata. All ranks get an identical list, so they
issue the same collectives in the same order.

Read once and transfer over the link, rather than having every rank read the
tier — a second reader pulls the same bytes over the same link anyway and
hits the disk twice. Consequence worth having: the tier no longer needs to be
shared between nodes
, so it can be node-local with no NFS.

Safety gate

Re-syncing one rank's rows onto another is only correct for a replicated KV
cache. MLA stores one compressed latent per token and is replicated by
construction; a head-sharded cache (GQA/MHA) or per-rank recurrent state
genuinely differs per rank, and copying over it would corrupt it just as
thoroughly as the bug. So the mod does nothing unless every KV group is
known-replicated, and logs which way it decided.
VLLM_OFFLOAD_KV_REPLICATED=0|1 overrides. A one-shot check immediately after
the first broadcast confirms the rows landed identically — race-free, since
equality there holds by construction.

On single-node deployments patch 2 is a no-op, so it's safe to leave applied.

Testing

  • Both patches apply in sequence to a pristine image, compileall clean,
    import vllm OK, and run.sh is idempotent on a second run.
  • End-to-end on the two-node cluster: store a prompt, evict it with five filler
    prompts, load it back from disk. Before: multilingual token soup. After: all
    checkpoints correct, with the row diff going 0/112 → 112/112 identical.
  • Verified the load really came from disk (112 fs reads), not a CPU-tier hit —
    reset_prefix_cache does not drain the CPU tier at blocks_per_chunk > 1,
    which makes naive tests measure memory instead.

Notes

PYTHONHASHSEED must be set to the same fixed value everywhere or NONE_HASH
is reseeded per process and nothing on disk is ever found again. vLLM already
warns about this; the tier makes it expensive. Worth knowing when checking:
/proc/<pid>/environ is unreliable for VLLM::EngineCore, which calls
setproctitle and clobbers that region — it cost us a wrong diagnosis.

Happy to split this into two mods if you'd rather, though they only produce a
working tier together.

Two bugs in OffloadingConnector + TieringOffloadingSpec with an fs secondary
tier. Shipped as one mod because neither fix is useful alone: apply only the
first and a multi-node cluster gets a working cache that corrupts; apply only
the second and there is nothing to correct, because the cache never hits.

1. EAGLE/MTP groups can never certify a hit, so the tier returns ZERO hits.

   The store path keeps only the trailing `tail` chunks of each alignment
   segment. But _sliding_window_lookup finds `tail` chunks and an unverified
   EAGLE group then pops one (num_hit_chunks -= 1), so it needs tail + 1
   CONSECUTIVE chunks and the store filter has guaranteed it can never see
   them. Since _lookup() ANDs across groups, that permanent zero vetoes every
   other group too. Measured here: 502 GB written, 0 bytes ever read back.

   Fix: also keep the segment-head chunk for eagle groups, making the retained
   set {0} u {acc-tail .. acc-1} -- a run of exactly tail + 1 consecutive
   chunks across the segment boundary.

2. Tensor parallelism across nodes silently serves WRONG KV.

   SharedOffloadRegion is a per-node /dev/shm mmap. On one node the
   scheduler-side region (rank=None) and every worker region (rank=r) are the
   same file -- rank only selects a slot within a row -- so a promotion the
   tier manager performs is visible to every worker for free. That assumption
   is undocumented and false across nodes: each node has its own mmap, and only
   the rank co-located with the manager runs a secondary tier.

   GPU->CPU stores stay symmetric, but disk->CPU promotions land only on the
   manager's rank, and the following CPU->GPU load reads each rank's OWN
   region. Every other rank feeds its GPU stale bytes. Nothing raises.

   Hashing the same row index in both nodes' mmaps, before the fix:
     rows written by GPU->CPU stores      112/112 identical
     rows written by disk->CPU promotion    0/112 identical
   After: 112/112. Every divergent row was a promoted row, and vice versa.

   Fix: the manager records rows a completed promotion filled,
   build_connector_meta() drains them into
   OffloadingConnectorMetadata.promoted_rows, and every rank re-syncs them from
   the manager's rank over the existing TP group before any load is submitted.
   Read once and transfer over the link, rather than having every rank read the
   tier: a second reader pulls the same bytes over the same link anyway and
   hits the disk twice. A welcome consequence is that the tier no longer needs
   to be shared between nodes at all.

   Gated on the KV cache being replicated across TP ranks (all groups MLA),
   because copying one rank's rows onto a head-sharded cache would corrupt it
   just as thoroughly. VLLM_OFFLOAD_KV_REPLICATED overrides. A one-shot check
   after the first broadcast confirms the rows really landed identically. On
   single-node deployments the whole thing is a no-op.

Verified against vLLM e2666d9a65f41fc376607531453cbd57c4c71016 on
DeepSeek-V4-Flash-0731 across two DGX Sparks (TP=2, one GPU per node).
@yaro-tal

yaro-tal commented Sep 8, 2026

Copy link
Copy Markdown
Author

Status update: this mod is incomplete for the case it targets, and I know why now

Six days of production data later, I want to be straight about a limitation rather than leave it for someone to discover.

The two patches here make the disk tier correct. They do not make it useful on a prefix larger than your primary tier. If you apply this mod and your prefixes are long, you will most likely see the tier do nothing at all — no error, no warning, just persistent zero hits. That is not this mod failing to apply; it is a third bug underneath it.

The bug

TieringOffloadingManager.lookup ends with:

return LookupResult.MISS if not promoted else LookupResult.RETRY

The matching walk promotes one primary-tier row per queried key, just to confirm the key is there. Once the primary tier is full, every subsequent key — including keys that are sitting on disk — comes back MISS, indistinguishable from "never stored". The cross-group AND in the scheduler (if num_hit_chunks == 0: return 0) then discards the entire external hit.

It is self-reinforcing: the walk eats the same rows prepare_store needs, so the tier stops being written too, and it never recovers on its own.

Why this will hit essentially everyone using this image

The primary tier is sized out of host RAM, and we are all on GB10 boxes with 128 GB total. With a large model resident there is very little left — our cpu_bytes_to_use: 4294967296 buys exactly 498 rows. A long agent conversation queries far more keys than that during a single match. So the ceiling is not an exotic configuration, it is the default situation for this hardware. If you have 128 GB and long prefixes, you are in it.

How to tell in 30 seconds

On a vetoed lookup, sum the per-group RETRY counts. If the sum pins at exactly your primary tier's row count, you are hitting this and your data is on disk, not missing.

You can confirm the row count independently from metric granularity: kv_offload_cpu_cache_usage_perc only ever takes values k/rows (ours reported 0.08032128514056225 = exactly 40/498).

Do not read a high miss count as disk absence without doing this first. That misreading cost us about two weeks.

Work in progress

The fix is separating matching from staging — match with promote=False, then stage the confirmed hit once. It is running in production here now:

before after
summed RETRY per probe 498 0
exit=ZERO 14 0
cannot store chunks 2 0
cold 294,186-token prompt ext=0 ext=290,816 (98.9%)

Across 40 requests: 88.3% of prompt tokens covered, 1,349,632 tokens not re-prefilled.

I would rather land that here as a third patch than leave the mod half-useful, so please treat this PR as not-ready-to-merge for now unless you want the correctness fixes on their own. I will follow up with the patch. Two things are still unverified and I will not claim otherwise: the second rank's GPU-side KV is confirmed only at the CPU-row mirror, and the multi-wave path has never actually executed (see below).

A second-order note for anyone tuning: with the ceiling gone, the remaining pressure is concurrency. One 440k-token prefix needs ~222 of our 498 rows, so two concurrent large requests fit and three do not. An admission gate is written but deliberately switched off until it has been exercised.

Related

Provenance

Written with AI assistance and verified on a live two-node cluster. These patches are a collaboration between Claude Opus 5 and DeepSeek-V4-Flash, each reviewing the other's work and keeping the other honest; every number above is measured from a real deployment rather than asserted by a model. Flagging it so any policy you have on AI-assisted contributions is your call, not my assumption.

🤖 Generated with Claude Code

Patches 01 and 02 make the disk tier CORRECT. They do not make it USABLE on a
prefix larger than the primary tier. This is the patch that does.

TieringOffloadingManager.lookup ends:

    return LookupResult.MISS if not promoted else LookupResult.RETRY

The matching walk promoted one primary-tier row per queried key, purely to
confirm the key existed. Once the tier filled, every further key -- including
keys present on disk -- returned MISS, indistinguishable from absence, and the
cross-group AND discarded the whole external hit. Self-reinforcing: the walk ate
the rows prepare_store needed, so the tier stopped being written too.

This will hit most users of this image. The tier is sized from host RAM on
128 GB boxes, so a few GiB buys a few hundred rows; a 242k-token prefix queries
12,434 keys.

Diagnosis in 30 seconds: sum the per-group RETRY counts on a vetoed lookup. If
they pin at exactly your tier's row count, your data IS on disk. Row count is
confirmable from metric granularity (kv_offload_cpu_cache_usage_perc only ever
takes k/rows values).

Fix: match with promote=False, then stage the confirmed hit in waves. Three new
stdlib-only modules with host-runnable tests, plus the driver in scheduler.py
and the promote flag in manager.py.

Measured on a live 2-node TP=2 DeepSeek-V4-Flash-0731 cluster:
  summed RETRY per probe   498 -> 0
  exit=ZERO                 14 -> 0
  cold 294,186-tok prompt  ext=0 -> 290,816 (98.9%)

Since then: 23 multi-wave loads at num_waves=2/3 with exact slicing and blocks
closing end to end; both ranks confirmed job-for-job; a 1 GiB (124-row) tier
served a cold 348,000-token prompt at 99.5%, which is monolithically impossible
and can only be done by streaming; and next-token distributions from tier-served
KV sit within the engine's own noise floor, equal to vLLM's own GPU prefix cache.

Parking works (16 slot events, all released) but ships OFF by default; treat it
as the least-exercised part.

Scope, stated plainly: this also carries ~70 lines of env-gated diagnostics,
inert unless their env var is set. They are the instruments that found the bug;
hand-removing them would ship code we have not run.

run.sh also gains a whole-set idempotency guard. The per-patch reverse-check
broke once a third overlapping patch existed -- reversing 01 alone fails while
03's edits to the same regions are present, so a second run errored instead of
skipping. Verified: applies cleanly from stock upstream, all files compile, and
re-running now skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yaro-tal

yaro-tal commented Sep 8, 2026

Copy link
Copy Markdown
Author

Patch 03 is in, and this is ready for review now

Following up on my earlier comment asking you to hold this: the missing piece has landed as 03-match-without-staging.patch, and everything I said was unverified is now measured. Please treat the PR as ready.

What 03 does

Matching promoted one primary-tier row per queried key just to confirm the key existed. Once the tier filled, keys that were sitting on disk returned MISS — indistinguishable from absence — and the cross-group AND threw away the whole hit. Self-reinforcing, because the walk ate the rows prepare_store needed.

Fix: match with promote=False, then stage the confirmed hit in waves so a hit larger than the tier can still be served.

Results, all measured on a live 2-node TP=2 cluster

before after
summed RETRY per probe 498 0
exit=ZERO 14 0
cold 294,186-token prompt ext=0 ext=290,816 (98.9%)

Since the earlier comment, four things I would not claim without evidence:

  • Multi-wave genuinely runs — 24 loads at num_waves=2/3. Slicing exact: ext=305152 wave_sizes=[64, 64, 26] is 64+64+21 = 149 g0 chunks = 305152/2048. Blocks close end to end, 512+512+190 = 1214 = 149x8 + 22.
  • Both ranks — identical job ids and src_blocks on each; rank 1 emits the matching cpu_to_gpu transfers. The src_offset/dst_offset asserts survive wave boundaries, which is the pair that used to crash our second rank.
  • Streaming beats tier size, which is the whole point. A 1 GiB / 124-row tier served a cold 348,000-token prompt at 99.5%. That prefix needs ~170 rows to stage, so it is monolithically impossible — only waves can do it.
  • Output is not degraded. Next-token distributions from tier-served KV sit within the engine's own run-to-run noise floor, and match vLLM's own GPU prefix cache (2.95 vs 2.93, floor 3.20). Worth knowing if you ever test this: the engine is not deterministic at temperature=0, so diffing generated text cannot distinguish corruption from noise — measure the noise floor first.

Zero asserts, zero aborts, zero promote_refused throughout.

Things I want to be upfront about

  • Request parking ships OFF (VLLM_OFFLOAD_PARK=0). It works — 20 slot events, every one released — but it is the least-exercised part and I would not default it on for other people.
  • The patch carries ~70 lines of env-gated diagnostics, inert unless their env var is set. They are the instruments that found the bug; hand-removing them would mean shipping a file we have never run.
  • cannot store chunks is pre-existing and you may meet it, because 03 makes small tiers useful enough that people will run small ones. Now quantified on our workload (~250-350k prompts): 1 GiB / 124 rows produced 8,870 warnings from 40 requests (worst one 1,847x, ~218/min); 2 GiB / 249 rows produced 0. Raise cpu_bytes_to_use until it stops. Note this is not what STREAM_WAVE_CHUNKS controls — wave size governs the load path and is not referenced in the store path at all.
  • run.sh also gained a whole-set idempotency guard: the per-patch reverse-check broke once a third overlapping patch existed, so a second run errored instead of skipping. Fixed and verified both ways.

Verified before pushing: applies cleanly to a from-scratch reconstruction of stock vLLM e2666d9a6 + 01 + 02, every touched file compiles, run.sh runs the full stack end to end, and re-running skips.

Provenance

Written with AI assistance and verified on the hardware described. A collaboration between Claude Opus 5 and DeepSeek-V4-Flash, each reviewing the other's work and keeping the other honest. Every number here is measured on a real deployment, not asserted by a model — including the ones that came out inconvenient.

🤖 Generated with Claude Code

@yaro-tal

yaro-tal commented Sep 9, 2026

Copy link
Copy Markdown
Author

11-hour soak update

The numbers in my previous comment came from short windows. Here they are after 11 hours of continuous real workload (agent coding sessions, 250-350k-token prefixes), at cpu_bytes_to_use 2 GiB / 249 rows:

requests 153
prompt tokens 32,626,888
covered 90.1%
tokens not re-prefilled thanks to the tier 3,580,928
cold arrivals (local=0) 32 requests, 6.39M tokens, 54.6% served from disk
multi-wave loads 10 (4x num_waves=2, 6x num_waves=3)
parking slot events 25, all released
cannot store chunks 0
asserts / tracebacks / promote_refused / aborts 0

Two things this firms up specifically:

  • The 2 GiB recommendation in the README was based on a 55-minute window when I wrote it. Eleven hours later it is still zero, so the 1 GiB -> 2 GiB table can be read as a durable result rather than a snapshot.
  • Parking has now taken and released the admission slot 25 times with no request ever failing to un-park, which was the failure mode I was most wary of. It still ships off by default.

Nothing in the patch or the README changes as a result — posting because the earlier evidence was thinner than I would want anyone to merge on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant