Skip to content

fix(scheduler): hybrid radix hit corruption — GDN snapshot copy-on-donate + admission barriers - #287

Closed
Cerynitius wants to merge 1 commit into
FlashML-org:mainfrom
Cerynitius:fix/hybrid-radix-cow-donate
Closed

fix(scheduler): hybrid radix hit corruption — GDN snapshot copy-on-donate + admission barriers#287
Cerynitius wants to merge 1 commit into
FlashML-org:mainfrom
Cerynitius:fix/hybrid-radix-cow-donate

Conversation

@Cerynitius

Copy link
Copy Markdown

Problem

Under the default --cache-type radix, hybrid (GDN) models intermittently serve corrupted answers on cache-HIT requests that are co-batched with in-flight decode. Measured with deterministic ground-truth probes on a production deployment: ~10% of hit requests wrong under concurrent load — empty or garbled output, special-token leaks into content (<|assistant|> mid-text), and with a multimodal tower, answers describing a blank image. Cold prefill is always clean. Failures cluster in sticky per-branch windows (a poisoned branch keeps failing for a while, then self-heals when re-donated).

Every hybrid GDN model on the hybrid_radix / linear_state pool path is exposed; nothing about the trigger is model-specific.

Root structure

_cache_req_hybrid donates the request's snapshot slot into the radix tree by ownership transfer (chunk commit donates the frozen ping-pong slot; finish donates the live slot "zero-copy"). That leaves one linear-state slot id shared between the tree and the request's still-in-flight pipeline. A late write through any retained alias poisons every subsequent hit of that branch — which is exactly the observed sticky-window signature.

Stream-order alone does not close the window: adding full-device syncs at the donate and restore points shrank the failure rate from ~10% to ~3% but not to zero. Severing the alias does.

Fix

  • copy-on-donate: the tree receives a private clone of the snapshot slot (_clone_slot_for_tree), written once behind the donate barrier and read-only for its tree lifetime. The request keeps its own slots — no replacement alloc at chunk commit; both ping-pong slots and the live slot are freed at finish.
  • Barriers before hit-admission restores (_restore_linear_states) and donation bookkeeping (_cache_req_hybrid): request-level events, sub-millisecond, and they guarantee the clone's source is quiescent.

Cost: one ~MB slot copy per donation plus one device sync per prefill commit / hit admission. Decode throughput unchanged in before/after measurement.

Validation

On a v0.1.2-based deployment serving GLM-5.3-Flash-NVFP4 (34 KDA layers riding the same hybrid_radix/linear_state path as qwen3.5 GDN; support PR #270) on an RTX PRO 6000 Blackwell 96 GB:

  • A deterministic hammer alternating identical prompts with abandoned/saturating concurrent streams reproduced the corruption at 3/30 (image probes) and 2/24 (pure text) before the fix; after the fix: 0/60 image, 48/48 text rounds with zero corrupted answers.
  • An overnight ground-truth soak (~2,200 probes: exact-match arithmetic, needle retrieval, multi-turn recall, media A/B alternation with per-request content forensics) measured the pre-fix hit corruption at ~10% and confirmed request payloads and cache keys correct on every failure — isolating the fault to the hybrid hit path. Cold-path controls: 0/30.
  • Post-fix regression battery: prefix-hit latencies unchanged (repeat/turn-2/long-document hits 0.8–1.1 s), LRU eviction under pool-overflow pressure clean, page-accounting integrity checks clean throughout.

The hammer scripts are small and self-contained; happy to attach them or port them into tests/ if useful (they pair naturally with the regression-harness proposal in #281).

…onate + admission barriers

Under the default radix prefix cache, hybrid (GDN) models intermittently serve
corrupted answers on cache-HIT requests that are co-batched with in-flight
decode: ~10% wrong under concurrent load in ground-truth probing (empty or
garbled output, special-token leaks into content; with a multimodal tower,
blank visual grounding). Cold prefill is always clean, and failures cluster in
sticky per-branch windows that self-heal when the branch is re-donated.

Root structure: chunk-commit and finish donate the request's snapshot slot into
the radix tree by OWNERSHIP TRANSFER, so one linear-state slot id is shared
between the tree and the request's still-in-flight pipeline. A late write
through that alias poisons every subsequent hit of the branch. Stream-level
wait_stream ordering does not close the window (full-device syncs at the
donate/restore points shrink the failure rate from ~10% to ~3% but not to
zero); severing the alias does.

Fix:
- copy-on-donate: the tree receives a private clone of the snapshot slot
  (written once behind the donate barrier, read-only for its tree lifetime);
  the request keeps its own slots -- no replacement alloc at chunk commit,
  both ping-pong slots and the live slot freed at finish.
- full-device barriers before hit-admission restores and donation bookkeeping
  (request-level events, sub-millisecond; they also guarantee the clone's
  source is quiescent).

Cost: one ~MB slot copy per donation and one device sync per prefill
commit / hit admission; decode throughput is unchanged.

Validated on a v0.1.2-based deployment serving GLM-5.3-Flash-NVFP4 (34 KDA
layers on the same hybrid_radix/linear_state pool path as qwen3.5 GDN) on an
RTX PRO 6000 Blackwell: a deterministic hammer that alternates identical
prompts with abandoned or saturating concurrent streams reproduced the
corruption at 3/30 (images) and 2/24 (pure text) before the fix, and runs
0/60 and 48/48-clean after; an overnight ground-truth soak (~2,200 probes:
exact-match arithmetic, needle retrieval, multi-turn recall, media A/B
alternation with per-request content forensics) measured the pre-fix hit
corruption at ~10% and confirmed payloads and cache keys correct on every
failure, isolating the fault to the hybrid hit path.
@Cerynitius

Copy link
Copy Markdown
Author

Correction after deeper forensics — the corruption evidence in this PR's description does not implicate upstream.

Slot-level checksum instrumentation overturned the ownership-aliasing theory. The true root cause of every corrupted hit we observed was in our downstream model integration: our KDA attention op (GLM-5.3 support, #270) never implemented the ×64-boundary track-snapshot write that the hybrid-radix design expects (_write_track_snapshot exists in the Qwen GDN op and is a per-op responsibility). Every reuse point our deployment donated therefore carried an unwritten, all-zero recurrent state; hits restored zero state and ran the prefix on the full-attention layers alone — usually survivable, intermittently wrong. That also explains why the failure rate seemed to respond to unrelated synchronization: it never did; the rate was constant model-derailment noise on top of a deterministic zero-state restore.

With the writer implemented on our side, restore-side checksums show real boundary state on every hit and 90 adversarial hammer rounds run clean — with or without the changes in this PR.

What remains of this PR on its own merits, with no corruption claim attached:

  • copy-on-donate severs the slot-id sharing between a request's in-flight pipeline and the tree (structural hardening; cost ≈ one ~MB copy per donation);
  • the admission/donate barriers are likely unnecessary given stream ordering and can be dropped;
  • a gdn_track_snapshots-style guard (not in this PR) may be worth more than either: an op that lacks the snapshot writer currently donates unwritten state silently — a loud gate would have caught our integration gap immediately.

Happy to re-scope the PR to the guard + optional copy-on-donate, or close it — maintainers' call. Apologies for the initial misattribution; the ground-truth probes and checksum forensics that led here are documented in the linked repository.

@gdevenyi

gdevenyi commented Sep 4, 2026

Copy link
Copy Markdown

Ran this on a Qwen3.8-Flash-Next TP=2 deployment (36 GDN layers on the same hybrid_radix / linear-state path). Nothing to report either way, which is worth having on record:

main 86214a9 + this PR
radix-hit probe: a fixed deterministic question re-asked 24 times as a prefix-cache hit while 4 x 256-token generations run concurrently (8 rounds) 0 / 24 wrong 0 / 24 wrong
single-stream / 8-conc decode 90.8 / 326.0 tok/s 89.8 / 321.2 tok/s (noise)
TTFT, ~1k prompt 0.81 s 0.89 s

So the corruption did not reproduce here with this probe, and the copy-on-donate clone plus the barriers cost nothing measurable on decode or TTFT. Two differences from your setup that may matter for reproducing: my hit requests are short (one-shot answers, 16 tokens) so the donated slot is rarely still in flight, and the probe alternates only one cached prompt. If you can share the hammer script I can run it on this box, since a second machine confirming the fix would help it land.

Setup: 2x RTX 6000 Ada (48 GiB, sm_89, PCIe Gen4, no NVLink), 2x Xeon Gold 6526Y, 503 GiB RAM, CUDA 13.3, torch 2.11+cu130, sgl_kernel 0.4.5; RadixArk/Qwen3.8-Flash-Next-NVFP4 served as one TP=2 instance (local qwen4_exp TP patch on main 86214a9, plus a local fix so --moe-cache-auto honours --num-tokens, see #383). Flags: --moe-backend offload --ple-backend pinned --num-tokens 262144 --memory-ratio 0.94 --moe-prefill-hit-d2d --max-running-requests 16 --cuda-graph-max-bs 16 --tp-size 2 --gpu 0,1. Single-stream = median of three 64-vs-256-token completion pairs; aggregate = 8 concurrent 256-token completions; TTFT on a ~1k-token prompt; one run per configuration, baseline spread about +-3%.


Update 2026-09-05, merged into the deploy branch. Tried on 2 x RTX 6000 Ada (sm_89) serving Qwen3.8-Flash-Next (RadixArk NVFP4) at TP=2, offload backend, fp8 KV pool of 8 x 262,144 tokens, merged onto my deploy branch (main af71ba4 + #385/#386/#389/#392/#354 and ten other open PRs), tests run on the box, then put in production.

Merged with conflicts against my image-aware cache key in scheduler/cache.py (three donate sites, mechanical). Two things needed on top:

  • the two torch.cuda.synchronize(self.device) barriers also fire on a CPU device, which the scheduler unit tests use, so tests/scheduler fails without a GPU (RuntimeError: No CUDA GPUs are available, 7 tests); I guarded them on device.type == "cuda".
  • test_hybrid_cache_manager_donate_then_hit and test_hybrid_finish_donates_live_slot assert the old ownership transfer (mamba_value == pp[0], ping-pong slot replaced) and need to follow the clone contract.

On this box the corruption does not reproduce with or without the PR: 0 mismatches in 50 hit requests (control, sequential aborts, 4-way concurrent aborts, 5k-token conversations) on a GDN + vision deployment. Server-side decode is unchanged (444-450 tok/s at 8 running, same as without the PR); one GDN slot here is 55 MiB, so the clone per donation is a 55 MiB device copy. Kept in production.

gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Sep 5, 2026
…orruption -- GDN snapshot copy-on-donate + admission barriers

Upstream FlashML-org#287 at abc13da, merged onto deploy/chatdnp for the PR sweep.
Conflicts in scheduler/cache.py: the three donate sites take the PR's private clone, keyed
on _key_ids(req) (the image-aware cache ids of the deploy branch) instead of req.input_ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Sep 5, 2026
…ollows ForwardOutput (FlashML-org#224)

The donate and hit-admission barriers of PR FlashML-org#287 call torch.cuda.synchronize on the
cache manager's device; the scheduler unit tests run that path on a CPU device, so guard
the call on device.type. The drained-forward fake in test_abort_inflight_prefill built a
bare tuple, which PR FlashML-org#224's scheduler no longer unpacks: build a ForwardOutput instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Sep 5, 2026
… contract

The tree now holds a private clone of the GDN snapshot and the request keeps its
own slots, so the two tests that asserted ownership transfer (tree slot == request
slot, ping-pong slot replaced) assert the clone contract instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 5, 2026
…ge costs

Two things made a screenshot expensive out of all proportion to what it says.

**An image prompt could not share a prefix at all.** ``CacheManager`` matched image
requests against the empty prefix and never inserted them, on both sides and on purpose:
every image expands to the same placeholder id, so a match keyed on the raw ids could hand
one image's KV to another. Correct, but total -- one screenshot anywhere in an agent
conversation made every later turn re-prefill the whole context. Measured here at 163k
tokens: ~40 s before the first token, every turn, for as long as the image stays in scope.

The key can be fixed instead of abandoned. ``tokenize`` now derives ``cache_ids``:
``input_ids`` with the i-th placeholder run replaced by ids from that image's blake2b
digest, above every vocabulary (>= 2**30) and carrying the position within the run. The
cache manager keys on it through ``_key_ids`` and the two exclusion branches go away. Which
run belongs to which image is read off the prompt -- placeholder runs come out in image
order -- with a single combined hash as the fallback when the counts disagree; that is
coarser but never wrong. Verified on both families that have a tower here: Qwen3-VL and
gemma-4 keep runs separate for 1, 2 and 3 images, adjacent or not.

**A 4K screenshot cost 8,184 soft tokens.** Qwen3-VL is native-resolution and the
checkpoint's processor only downscales above 16.7 Mpx, i.e. never; at 32x32 pixels per soft
token a 4K frame lands just past ``--max-prefill-length``, where a multimodal prompt is
terminal (it cannot be split). The processor's resize bounds are now clamped on the way in,
~1 Mpx by default, ``FREETOKEN_IMAGE_MAX_PIXELS`` to raise it. gemma-4 is untouched by this:
``Gemma4ImageProcessor`` has no ``size`` and caps itself at 280 soft tokens.

One interaction needed a decision. A hit that ends INSIDE the image span is unusable -- the
model scatters ``mm_embeds`` against the image tokens of one forward, so the run has to sit
entirely on one side of the boundary -- and it happens on every prompt ending with its
image, because the match always leaves the last token out. ``match_req`` caps the key at the
span start there. Without it the adder finds a cut span it cannot move and declines
transiently forever: nothing about the next pass changes the cache, so the request never
runs and the scheduler spins.

Measured on Ornith-1.5-35B-A3B-NVFP4, one 4090, bf16 KV 262,144:

  4K image                      8,184 -> 967 prompt tokens, answer unchanged
  16,140-token prompt + image   3.2 s cold -> 1.2 s repeated (cached 16,128)
  short prompt + image          cached 960 / 965 on the repeat
  different image, same text    cached 0, "Green." vs "Blue." -- no false hit
  gemma-4, 4K                   264 soft tokens before and after the clamp

tests/tokenizer 65, tests/scheduler 88, tests/kvcache/radix 142 -- all passed, unchanged
from this branch without the commit. Cold system-test runs against the same branch without
it: the five text cases are byte-identical, the two image cases differ in wording and agree
in answer, which is the clamp changing the resolution the tower sees. Two cold runs of this
build are byte-identical to each other. It does not need FlashML-org#287.

Assisted-by: Claude Opus 5
MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 5, 2026
…ike text

037f102 narrowed the rule from "the whole prompt in one chunk" to "the image span in one
chunk", which is what a 196-token sprite in a 166k-token turn needs. The span is [first
image token, last+1) because ``mm_embeds`` is one concatenated tensor scattered in one
forward -- so it grows with the TEXT between two screenshots, not just with the pictures.
An agent conversation reaches the limit by talking:

  400 prompt with images needs 10392 contiguous tokens in one prefill chunk
      (the image tokens span [160334, 170726) and cannot be split)

Nothing configurable moves that. Cheaper images (~490 tokens each after the clamp) only buy
more turns before the gap between the first and last one exceeds a chunk, and raising
--max-prefill-length OOMs long before it helps: a 32k chunk's activations do not fit beside
a 5 GiB KV pool on a 24 GiB card (measured -- it took the worker down twice today).

So the concatenated tensor stops being scattered whole. ``_merge_multimodal`` takes the rows
belonging to the placeholders inside ITS OWN forward -- the ones an earlier chunk or a
prefix-cache hit already consumed sit in front of the window -- and the adder chunks an image
prompt exactly like a text one. ``Req.mm_scatter`` and the whole pull-back / reject path go
away with it, ~90 lines. Both families that carry a tower here are converted; the approach is
gdevenyi's, from FlashML-org#386 (28fd56d).

The span cap 09ea814 put in ``match_req`` goes too. It existed because a hit landing inside
a placeholder run left half the run cached and half to forward, which the all-in-one-forward
scatter could not represent; the window skips the cached half instead. Without the cap a
prompt that ends with its image keeps its prefix -- 20,800 of 20,840 tokens on the repeat
here, 6.0 s -> 1.2 s, and a different image at the same position still misses (answered
"Green" where the cached one answers "Blue").

Measured on Ornith-1.5-35B-A3B-NVFP4, one 4090, --max-prefill-length left at its 8192 default:

  2 images with 9k of text between   span ~19k   10,186 tokens,  3.2 s   (was a 400)
  6 images with 9k between each      span ~50k   55,360 tokens, 18.1 s   (was a 400)
  A(blue) 9k B(green), and reversed              "Blue, Green" / "green blue"
                                                 -- read across the boundary, in order

tests/tokenizer 58, tests/scheduler 90, tests/kvcache/radix 142: all passed. Twelve tests
pinning the removed rule are gone and three cover the window (a span wider than a chunk now
admits; a chunk scatters only its own rows; a chunk holding no placeholder scatters nothing).
The ``_NoSwa`` stub gained the ``page_size`` the reservation math has been reading, which is
what had six of these failing on this branch already. A cold system-test run is
character-identical to the same branch without this commit, all seven cases.

Assisted-by: Claude Opus 5

Re-verified on this branch (no FlashML-org#337/FlashML-org#354/FlashML-org#287 under it): tests/tokenizer 58, tests/scheduler
88, tests/kvcache/radix 142 all passed; a cold system-test run is character-identical to the
same change on the daily branch, all seven cases; the two shapes that used to 400 (spans of
~19k and ~50k tokens) answer at the 8192 default.
@Cerynitius Cerynitius closed this Sep 5, 2026
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.

2 participants