Repository navigation
fix(qwen3.5): keep PAGE checkpoint images out of the KV rows decode reads - #2635
ZLkanyo009 wants to merge 5 commits into
Conversation
…eads Since #2519, Qwen3.5 GDN prefix checkpoints are copied into MHA KV blocks as PAGE images. aiter's gluon paged-attention decode reads KV rows no sequence owns: padded block-table lanes load physical block 0, and a block's unwritten token slots load too. Those lanes get zero probability, but at fp8 an image byte is often a NaN encoding, and 0 * NaN in the PV MFMA wipes the output row. With an image on block 0 the whole decode batch is hit; TP8 GSM8K (util 0.26, interval 256) dropped to 0.82-0.83. - A pool that holds state images never hands out block 0, so it stays zero. - Units a checkpoint releases are drained into the next batch's state maintenance (`StateMaintenanceOps.checkpoint_clears`) and zeroed before its copies and forward, one `index_fill_` per MHA field (`MhaKvPool.zero_entries`). The warmup's units are cleared too. - The ids reach the device through a pinned buffer fenced on its previous upload, so the clear never waits out the queued forward. TP8 GSM8K with the original aiter kernel: 0.8673 / 0.8704 / 0.8696. Serving throughput is unchanged within restart noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
The NaN mechanism was spelled out at five sites; it now lives at `BlockPool.state_images` and the others point there. `clear_page_units` synchronizes its upload event unconditionally: a never-recorded event returns at once, so the pending flag added nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2635 has the GDN builder zero the PAGE units a state checkpoint gives back (`clear_page_units`), walking the MHA `kv_pools`. The Qwen3.8 builder keeps its PAGE images in the QSA arena and leaves `kv_pools` empty, so with both PRs in, `--state-checkpoint-interval-tokens` fails in `warmup_per_req_cache` with `ValueError: max() iterable argument is empty`. Override `clear_page_units` for Qwen3.8: upload the ids through a pinned buffer fenced on its previous upload, as the GDN version does, and put those blocks of every QSA field back to its declared fill with the new `LayerMajorArena.reset_entries`. A 1-byte field (fp8 K/V, fp4 / e8m0 kept as uint8) is filled through a uint8 view: `index_fill_` has no fp8 kernel on either device. The id buffer is rebuilt if a later arena has more blocks. Carried in this PR so the merge order does not matter. The override uses nothing from #2635, and until #2635 lands nothing calls it: its only callers (`checkpoint_clears` in the attention builder and the end of `warmup_per_req_cache`) come with #2635. Whichever of the two merges second, Qwen3.8 clears its released units in the right pool. Tests: `reset_entries` on fp8 / fp32 / bf16 / uint8 / int8 fields, zero and non-zero fills, runs on the CPU runner; the builder-level test (empty `kv_pools`, QSA arena reset) needs AITER like the other builder tests here. Co-authored-by: Cursor <cursoragent@cursor.com>
No conflicts. Picks up #2669 (fix(mla): migrate Triton decode to maintained aiter API): the 10-20 heavy accuracy jobs failed at import with ModuleNotFoundError: aiter.ops.triton.attention.mla_decode, raised from this branch's old triton_mla.py against the current aiter wheel, so a re-run of that merge ref would fail the same way. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The CI failures here (GLM-5.3-Flash, Kimi-K3, Kimi-K3 DSpark in run 39380615429) all stop at I have that merge ready locally (
I have not pushed it, because this PR's clear-on-release causes a regression when it is combined with #2636. With Qwen3.8 in-process LMCache offload and interval 256, GSM8K pass 2 (retrieved from CPU) drops to 0.927-0.973, against 0.977-0.986 for pass 1. The pass-2 answers solve a different problem or run to the token limit. Without this PR, or with Qwen3.8's The GDN |
…load fills them `BlockPool.take_released_units` assumed the batch whose allocation reuses a given-back unit is the one that drains it, so the forward's `build` clears it before anything writes it. True for the forward, not for a connector load: `EngineCore` dispatches the connector metadata (`start_load_kv`) ahead of the forward, and the load fills its blocks from the connector's own threads and streams, ordered after nothing on the compute stream. A unit is free again the moment it is given back, and under pool pressure a load's blocks are exactly that: allocating them evicts a checkpoint, and its units come straight back out. Two ways the clear then lands after the load: * A pass with a forward: the load and the `build`-time clear race. * A pass with no forward -- every request parked on a load, which is how the second pass of an offload run starts -- builds an empty batch that drains nothing, yet its metadata still goes out and the loads start. The units are cleared by the next non-empty batch, after the loads finished: the retrieved KV is zeroed. `_dispatch_idle_offload_work` and PP's `_dispatch_connector_only_batch` dispatch loads with no batch at all. With #2636 (Qwen3.8 LMCache offload, whose QSA arena now clears too) requests fetched back from CPU lost part of their context: GSM8K on the fetched pass fell to 0.93-0.95 from 0.977-0.984, with 3-19 empty / truncated answers, independent of MTP and of the CPU pool size. Qwen3.5's LMCache offload on main has the same path. Clear before loads instead: whenever connector metadata with work is about to be dispatched, the scheduler moves every unit given back so far onto it (`ConnectorMetadata.checkpoint_clears`, `Scheduler.carry_checkpoint_clears`), at both scheduling branches (an empty pass included), the idle dispatch and PP's connector-only dispatch. The worker zeroes them on the compute stream and waits for every clear issued so far, a forward's included, before `start_load_kv` (`clear_released_units` / `wait_released_unit_clears`). The wait costs nothing once the clears have run, which is the usual case since the engine already waited for the previous forward's output. Metadata with no work is left alone: the engine may drop it, and with no load in it the batch clears those units as before. Units are not held back from allocation instead: KV allocation evicts a checkpoint and reuses its units in the same pass (`_ensure_page_units`), which `can_allocate` already counts on. Tests: the scheduler carries the units on the metadata of a pass where every request is parked on a load (no forward) and of a pass with one, and leaves them to the batch when the metadata has no work; the idle and PP dispatches carry them; the worker clears and waits before `start_load_kv` (needs AITER); the wait waits for the device (needs a GPU). Co-authored-by: Cursor <cursoragent@cursor.com>
Clear-on-release zeroes KV a connector load just wrote: fixed in
|
| Tree | Model | Spec | Questions | pass1 | pass2 (fetched) | Empty / truncated | Fetched |
|---|---|---|---|---|---|---|---|
| main + this branch + #2562, before | Qwen3.5-35B-A3B-FP8 (interval 1024) | 0 | 440 | 0.9636 | 0.9136 | 27 | 373/440 |
| main + this branch + #2562, after | Qwen3.5-35B-A3B-FP8 (interval 1024) | 0 | 440 | 0.9682 | 0.9750 | 2 | 373/440 |
| #2636 + this branch, before | Qwen3.8 Flash Next MXFP4 | 0 | 1319 | 0.9727 | 0.9325 | 42 | 1318/1319 |
| #2636 + this branch, before | Qwen3.8 Flash Next MXFP4 | MTP 3 | 440 | 0.9841 | 0.9545 | 7 | 440/440 |
| #2636 + this branch, after | Qwen3.8 Flash Next MXFP4 | 0 | 1319 | 0.9780 | 0.9780 | 0 | 1318/1319 |
| #2636 + this branch, after | Qwen3.8 Flash Next MXFP4 | MTP 3 | 1319 | 0.9773 | 0.9780 | 1 | 1319/1319 |
| #2636 alone (reference) | Qwen3.8 Flash Next MXFP4 | MTP 3 | 440 | 0.9773 | 0.9773 | 1 | 440/440 |
- After the fix, the per-question pass1-vs-pass2 differences are noise. With MTP 3, 7 questions are right only in pass 1 and 8 only in pass 2; with spec 0 it is 7 and 7. Qwen3.5 after the fix: 4 vs 7. Before the fix the split was 65 vs 12 (Qwen3.8, spec 0, 1319 questions), 14 vs 1 (Qwen3.8, MTP 3, 440 questions) and 23 vs 1 (Qwen3.5).
- Before the fix, MTP 3 tokens per forward rose from 3.16 in pass 1 to 3.39 in pass 2. The output degenerates into repetition, which the draft predicts more easily. After the fix it is 3.16 in both passes.
- Qwen3.5-35B-A3B-FP8 runs on one GPU with thinking on, about 6.4k tokens per question. At interval 256 its checkpoints overflow the CPU state pool, so the run uses interval 1024 and
--gpu-memory-utilization 0.22, which pushes KV out of HBM so pass 2 has to load it. Its pass 1 already has 2–5 answers that think up to the length limit. The 397B was not run: its weights are not mounted in the available containers. - Full GSM8K on the fixed [Qwen 3.8] LMCache offload for QSA + GDN + PLE state #2636 tree, MTP 3, no offload: 0.978 strict. Acceptance 0.721, 3.16 tokens per forward.
- MTP 3 pass-2 wall time per 440 questions: 114 s after the fix, 122 s before it, 119 s for [Qwen 3.8] LMCache offload for QSA + GDN + PLE state #2636 alone. No measurable cost.
Tests
New tests:
test_state_checkpoint.py: the scheduler carries the units on the metadata of a pass where every request is parked on a load, and of a pass with a forward. It leaves them to the batch when the metadata has no work.test_lmcache_offload_connector.py: the idle dispatch and PP's connector-only dispatch carry them.test_gdn_page_checkpoint.py:- the worker clears and then waits before
start_load_kv(needs AITER); - the wait actually waits for the device (needs a GPU).
- the worker clears and then waits before
Without the fix, all 8 new tests fail. With only the carry call sites removed, the 4 carrying tests fail.
Results with the fix:
- CPU suite: 3 failed / 9734 passed. The 3 failures are identical to main's (
test_graph_marker×2,test_sparse_indexer_side_effect_op). - GPU files of this PR (
test_gdn_page_checkpoint,test_mha_kv_pool,test_block_pool,test_state_checkpoint,test_lmcache_mp_native_scheduler,test_lmcache_offload_connector): 713 passed, 0 skipped. Merged with [Qwen 3.8] LMCache offload for QSA + GDN + PLE state #2636, plus its offload test files: 815 passed. - black 26.10.1 and ruff 0.17.2 are clean.
The NaN-poison probe was not rerun: the fix does not change what is cleared or the block-0 rule.
|
@valarLip could you take a look at this one? Updated today: main merge CI: Pre Checkin is green. The one Accuracy failure (GLM-5.3-Flash kpool, run 40284323652) ran on runner |
|
will fix in kernel level |
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
|
Kernel-level fix for the FlyDSL path: ROCm/aiter#7092 (FlyDSL On the boundary tile, it zeroes V bytes for tokens at or past E2E on Qwen3.5-35B-A3B-FP8 with With that in, all three parts here (block 0 never allocated, released units zeroed, zeroing before connector loads) are redundant on the FlyDSL path. They are still needed for gluon (default, not fixed there; peeling the last partition is the cheap way, details in the aiter PR), for calls that fall back from FlyDSL, and for K3's MLA pool. So nothing changes here yet. Once gluon gets the same mask, the MHA-pool parts can be dropped. |
…an-guard Co-authored-by: Cursor <cursoragent@cursor.com>
|
@valarLip Merged latest Conflict. Only the import block of from atom.kv_transfer.disaggregation.types import ConnectorMetadata
from atom.model_engine.block_manager import AdmissionProbe, BlockManagerVerification
|
|
Closing this in favor of the kernel-level fix. ROCm/aiter#7092 is merged ( Two decode kernels still read KV rows that no sequence owns. They are left to the kernel owners (cc @zhuyuhua-v @fsx950223 @coderfeli):
Until those land, Qwen3.5 / Qwen3-Next on the default gluon path and Kimi-K3 with PAGE checkpoints can still pick up a state image's bytes in a KV row the decode reads. FP8 KV makes this most visible. The measurements and the guard's design remain in this PR's description for reference. |
Ready for review. No blocking issues.
Since #2519, Qwen3.5 GDN prefix checkpoints are copied into MHA KV blocks as PAGE images. aiter's gluon paged-attention decode reads KV rows no sequence owns: padded block-table lanes load physical block 0, and a block's unwritten token slots load too. Those lanes get zero probability, but at fp8 an image byte is often a NaN encoding, and 0 * NaN in the PV MFMA wipes the output row. With an image on block 0 the whole decode batch is hit. Kimi K3, whose KDA checkpoints take the same PAGE path into its MLA pool, reads physical page 0 the same way.
Dev record
pa_decode_gluon. aitermla_decode_fwd(K3, page size 1) also reads physical page 0. On K3 the damage is quieter: the fp8 V up-projection quantizes per 128-element group with a NaN-ignoring max and a clamp, so a few NaN latent columns become finite but wrong values instead of NaN rows.StateMaintenanceOps.checkpoint_clears) and zeroed before its copies and forward, oneindex_fill_per MHA field. Check the drain order intake_state_maintenance_opsandCommonAttentionBuilder.buildfirst.Named result
TP8 GSM8K, original aiter kernel: 0.8673 / 0.8704 / 0.8696 with the guard; 4 of 6 passes at 0.824-0.833 without it. Kimi K3 GSM8K: 0.9553 / 0.9545 with the guard; 0.9303 on main when block 0 held an image the whole pass. Serving throughput unchanged within restart noise.
Accuracy
GSM8K, original aiter kernel, Triton autotune pinned (
TRITON_CACHE_AUTOTUNING=1, one cache).Qwen3.5-397B-A17B-FP8. TP8,
--gpu-memory-utilization 0.26,--state-checkpoint-interval-tokens 256, block 16, max_model_len 2048, prefix caching, c65, 3-shot, LM Cache off. "Long thinking" counts answers that open aThinking Processblock and get cut at 256 tokens; it is where the lost points go.Kimi K3. TP8, agentic recipe: block 128, MLA page size 1, FP8 KV, prefix caching,
--state-checkpoint-interval-tokens -1,ATOM_STATE_CHECKPOINT_DEMAND=0, util 0.93, 64 seqs. 5-shot, completions, c64. main is53e4fb524plus env-gated debug code that logs block 0's checkpoint reservations and, in passes 2-4, checks its first row each decode step; this branch has the same base. The recipe range is 0.9538-0.9591.Standard error is 0.006 per pass (0.007 for 0.9303). Main averages 0.9454 over four passes (0.9505 over the first three); this branch averages 0.9549. Only one of the 7 garbled answers lost its point; the others had one stray token mid-answer and still ended on the right number.
Block 0 is the lowest id in the free heap, so once the pool fills (about 4 minutes in) each checkpoint that releases it hands it straight to the next one. Garbled answers are stray CJK tokens mid-answer, e.g.
<<4*3=蚕豆>>12. K3 answers vary a lot from pass to pass (about 23% identical text between two passes on one server), so compare totals.Performance
Qwen3.5-397B-A17B-FP8, TP8, FP8 KV, block 16, prefix caching,
benchmark_servingrandom dataset, 640 prompts at concurrency 64. Two restarts per arm, alternating main / branch, Triton autotune pinned. Measured at e439bd8; 7abd832 only rewords comments and drops a flag.Main against itself across a restart moves 0.2% (first scenario) and 0.6% (second) in throughput.
Gates
branch @ 7abd832: CPU suite failures identical to main (38, environment); the 24 GPU-only test files 573 passed; 8 new tests; GSM8K smoke (200 questions, 169 checkpoints released) 0.880. black, ruff clean on changed files.
Evidence
Probe: fill KV rows no sequence references (and unwritten tails) with zeros, then NaN; the outputs must be finite and bit-identical.
pa_decode_gluonon aiter84077b3c0: 48 of 48 shapes fail (ps / partitioned, 1-2 KV heads, bf16 / fp8 KV, block 16 / 64 / 1024).mla_decode_fwd, page size 1: poisoning all of physical page 0 wipes every row, and a single NaN byte there turns that latent column NaN in every row; poisoning other unlisted pages changes nothing.K3 mechanism, measured: block 0's page 0 held 2-3 NaN latent columns per MLA layer. In the kernel alone each NaN byte there turns that column NaN for every row and head.
batched_gemm_a8w8_a_per_token_group_prequant_w_per_batched_tensor_quantthen maps the NaN to finite values: 1 NaN column gives 13% relative error in the V up-projection, 3 give 24%. Only an all-NaN page 0 zeroes the output, and injecting that into a block 0 no sequence owns drops GSM8K to 0.005 (200 questions).The second performance scenario first ran at util 0.26, but KV block counts are sized from free memory at startup and two restarts differed by 19% there, so it moved to 0.30 (blocks within 3.5%).
🤖 Generated with Claude Code