You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Plugin][SGLang] Carry Flash PLE history through the mamba radix cache; guard DFLASH on the resolved backend - #2723
Follow-up to #2675, stacked on it: this PR branches off qwen35-qwen38-radix-cache and its first six commits are that PR. Review only the last two; the base will be main once #2675 merges.
Both fixes were raised by the automated reviewer on #2675 and deferred there to keep that PR reviewable:
Flash PLE history is not carried through the mamba radix cache. A prefix hit resumes GDN from the cached state while PLE reads whatever window the working slot's previous tenant left.
The Qwen3.5 DFLASH guard rejects per-mode backend overrides that do not select Aiter at all.
1. PLE history and the radix cache
Qwen3.8-Flash-Next keeps two side states per mamba slot next to the GDN state: the n-gram token ids and the short-convolution window. SGLang owns the slot and has a contract for exactly this:
its ShortConvPool / NGramPool are registered with MambaPool.register_slot_state, so copy_slots moves them when a hit or fork hands one request's slot to another, and clear_slots resets them when a slot is recycled;
its own qwen4_exp.py writes them into the ping-pong track slot at the snapshot boundary (_ple_track_targets), because the tree caches that slot, not the working one.
Under the plugin neither half applies. _get_ple_req_pool_kwargs keys on isinstance(config, sglang.srt.configs.qwen4_exp.Qwen4ExpTextConfig), and the config the runtime loads is transformers.models.qwen4_exp.configuration_qwen4_exp.Qwen4ExpTextConfig, so SGLang's PLE pools stay disabled. ATOM's own _atom_ple_conv_state / _atom_ple_ngram_state were neither siblings nor tracked.
The fix joins both halves:
Slot lifecycle — _PLESlotState implements SGLang's SlotIndexedState protocol over the two buffers and is registered on the mamba pool once. Copy, reset and the host round-trip now follow the slot. It reads the buffers off the model on every call because they are allocated lazily and may be replaced before capture. P/D transfer is left out: that contract is per-layer, and PLE is one layer's side state.
Track snapshot — written at the same boundary SGLang tracks GDN at, so the two halves of a cached node can never describe different positions:
extend: gathered out of this chunk. By the time the layer returns, the working slot holds the state after the whole chunk, so the n-gram half reads the input ids ending at the boundary and the convolution half reads the same rows of the convolution input the kernel would have carried over. An aligned boundary is not a special case. The window always lies inside the chunk: a tracked boundary is a positive multiple of mamba_cache_chunk_size (64), well past the 9-wide PLE window.
decode: a step advances one token, so the working slot already is the boundary state and a row copy is exact.
What this does and does not change in measurements
It does not move the prefix-reuse numbers, and I would not claim otherwise. The stale window is two token ids and a 9-wide convolution row, read by one layer for the first few recomputed tokens; the teacher-forced probe from #2675 cannot resolve it against run-to-run noise, before or after:
Qwen3.8, radix on, mean warm/cold ratio
before
after
first 4 suffix tokens, after dirtying every working slot
1.28 / 1.66 (two runs)
1.62 / 0.89
first 32 suffix tokens, same
1.02 / 0.99
1.06 / 0.92
whole 256-token suffix, same
0.99 / 1.01
1.01 / 1.02
The justification is the contract, not a delta: SGLang copies and tracks its own PLE pools, the plugin replaces the model that would have done so, and a resumed slot must not read another request's history. The unit tests pin each half of that contract directly.
2. DFLASH guard on the resolved backend
_validate_qwen35_runtime_contract required SGLANG_USE_AITER_UNIFIED_ATTN=1 whenever any of attention_backend, prefill_attention_backend or decode_attention_backend mentioned Aiter. With per-mode overrides that is a false positive: --attention-backend aiter --prefill-attention-backend triton --decode-attention-backend triton runs neither effective backend on Aiter and was still rejected before loading weights.
It now resolves the single backend DFLASH target verification uses — the decode backend when speculative_attention_mode is "decode", the prefill backend otherwise, each falling back to the default — and requires unified attention only for that one. The page-size-64 guard is untouched.
Unit tests. New tests/plugin/test_sglang_qwen4_exp_ple_radix.py (12) covers the sibling copy / reset / host round-trip, that it follows a regrown buffer, the extend window landing on the aligned boundary, the decode row copy, and that graph pad rows (PAD_SLOT_ID) are dropped. Four new cases in test_sglang_qwen35_runtime_contract.py cover the guard, including the reported false positive and both speculative_attention_mode directions; they fail without the production change.
Review round 2 (b78fb249e): snapshot ordering and the replay slot source, see below. Re-validated served on Qwen3.8, radix on, batch 64: prefix-reuse probe aligned 0.97-1.00, unaligned 0.98, decode 1.08; multi-turn 4/4; shared prefix c64 256/256; unique random c64 256/256; GSM8K 400 at 64 parallel 0.965-0.970; with every working slot dirtied before the hit, warm/cold 0.95-1.12 on the first 4 suffix tokens and 0.99 on the first 32.
Qwen3.5-397B-A17B-FP8, TP4, radix on on the same commit, since the guard runs at model construction on every Qwen3.5 launch: starts with the full batch cap, check_gdn_prefix_reuse 4/4 pass, prefix-reuse probe aligned 1.01, unaligned 0.91, decode 1.04, no fault or traceback. Qwen3.8 has the only PLE path, so that half is unaffected by construction. The guard change cannot alter any validated Qwen3.5 configuration (none sets a per-mode override), and the PLE path does not exist on Qwen3.5, so [Plugin][SGLang] GDN mamba radix cache (extra_buffer, page 64) for Qwen3.5 and Qwen3.8-Flash-Next #2675's Qwen3.5 results stand.
Merging
Rebase onto main after #2675 lands; the two commits touch files that PR already moves, so it is not independently mergeable.
Status of the measurements in this section
Review round 3 (bcfbc07a7) found that the replay epilogue was reading the PLE buffers off the SGLang wrapper while they live on the native model, so the replayed-decode snapshot never executed. Every served number above was therefore measured with that path inert: they are valid as a no-regression check against #2675, but they do not exercise this change's decode half. Re-validation is pending free GPUs on the test node.
Unit coverage for the three round-3 fixes is in place and each regression fails on the preceding commit.
Known gap: speculative verify
A boundary crossed inside an accepted draft still gets GDN without PLE. A verify batch carries no track mask (spec_utils clears it) and the commit runs in update_mamba_state_after_mtp_verify, whose PLE half needs the per-draft-step intermediate_conv_state / intermediate_context. ATOM's PLE kernels fold num_accepted_tokens into the working slot directly and keep no per-step snapshots, so the boundary is not recoverable after the fact.
Closing it means giving those kernels per-step intermediates and scattering them at mamba_steps_to_track. That is a kernel-level change and will be a separate PR. It affects speculative decoding plus radix only; prefill-side reuse, the dominant case, is unaffected.
PLE conv state was captured at max_bs, so extra_buffer radix recycled a higher mamba index into an undersized buffer and faulted. Size it to the physical pool, keep Hybrid's static slot indices, and snapshot mamba state after decode-graph replay.
Co-authored-by: Cursor <cursoragent@cursor.com>
Log the optional PLE pool lookups instead of swallowing them, and drop the unused noqa on the decode-graph track epilogue.
Co-authored-by: Cursor <cursoragent@cursor.com>
…buffer)
`--mamba-radix-cache-strategy extra_buffer --page-size 64` is what lets
Qwen3.5 run prefix caching with paged KV and overlap scheduling. SGLang's
HybridLinearAttnBackend owns that cache and computes the track metadata,
but the snapshot calls live inside its own GDN kernels, which the plugin
replaces wholesale. Nothing ever wrote the track slots, so a prefix hit
resumed from whatever state a recycled slot last held: short retrieval
answers usually survive that, through the full-attention layers, but
logprobs drift and a 9k-token hit decodes to an empty string.
`SGLangGatedDeltaNet` now makes those calls after the ATOM forward, through
SGLang's own helpers so the snapshot semantics stay defined in one place:
- extend: the conv window from the pre-conv input, and the SSM state from
the chunk kernel's per-chunk `h` (unaligned) or the runtime slot
(aligned), via `_track_mamba_state_extend`;
- decode: `_track_mamba_state_decode` with the layer id, which collapses
into one all-layers launch at the last GDN layer.
`h` is kept only when SGLang asks for a snapshot: a new
`GDNAttentionMetadata.keep_intermediate_states` lets the plugin request it
without placing native `ssm_checkpoints`, and it is read back with the
existing `pop_last_intermediate_states`.
ReplaySSM is turned off whenever the radix cache is on. Under the ring a
request's slot holds a checkpoint plus uncommitted records rather than its
state, and the commit hook scatters only the conv window, so every state
SGLang copied into a track slot would be stale. Speculative serving with
the radix cache therefore verifies through the batched snapshot path;
forcing `ATOM_ENABLE_REPLAYSSM=1` alongside the cache is rejected.
Co-authored-by: Cursor <cursoragent@cursor.com>
ATOM's GDN chunk kernel exposes recurrent states every 64 tokens, while
SGLang snapshots at `max(64, page_size)`. An extra_buffer page size other
than 64 reads a valid but wrong state row and produces fluent corruption,
so refuse it at model construction.
DFLASH target verify on Aiter has no custom mask and needs Aiter's
unified-attention path; refuse `SGLANG_USE_AITER_UNIFIED_ATTN` unset there
rather than crashing on the first verify batch.
Server args come from `sglang.srt.runtime_context.get_server_args`;
`get_global_server_args` raises on SGLang 0.5.20, which would have turned
both checks into silent no-ops.
Co-authored-by: Cursor <cursoragent@cursor.com>
Drives a running server: a prefix hit at temperature 0 must reproduce a
cold run. Byte equality alone passes a broken cache, because a quarter of
Qwen3.5's layers attend over the intact KV cache and short answers
survive a stale GDN state, so the warm run's output logprobs must also
sit within the cold-to-cold spread. The cold arm runs several times since
MoE reductions make it nondeterministic at near-ties.
Every case reads back `cached_tokens` and is INCONCLUSIVE without a hit.
The tree is populated with the bare prefix, because extra_buffer stores
state only at the last 64-token boundary a request reached. The
multi-turn case builds turn 2 from turn 1's token ids so the hit extends
into generated tokens (the decode-side snapshot), and stops turn 1 just
past a track boundary so the recomputed tail is too short for the forget
gate to wash a stale state out.
On Qwen3.5-397B-A17B-FP8 TP4 against origin/main without the snapshot
fix, prompt-prefix cases fail (logprob gaps 0.12-0.43, and an empty
completion at ~9k tokens); with it all cases pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
… Qwen3.8)
The extend snapshot (chunk-boundary `h`) and the Flash decode-graph fix each
tracked both modes, and together they broke each other:
* Qwen3.8 prefills on FlyDSL, which kept no chunk states for SGLang, so the
first tracked extend raised "this prefill kept none".
* The decode-graph helper also row-copied the finished state on extend. Where
the track point is unaligned, that overwrote the chunk-boundary snapshot
with the state after the whole extend, and a later hit resumed from a state
that had already consumed up to 63 of the tokens it then recomputed.
Now extend is tracked only by `_track_mamba_states` from the prefill kernel's
chunk states, Triton or FlyDSL (published through the same pop contract), and
decode only by `_track_mamba_radix_slots`, inline when eager and from the
post-replay epilogue for a replayed graph. That also drops the in-graph
per-layer decode track that ran without `check_freed_slots` over padded slots.
…ed on decode snapshot
* `_ple_state_pool_slots` floored the PLE row count at `max_total_tokens`,
the KV-cache token budget. Each Flash PLE row is 184 KB, so an explicit
`--max-total-tokens` allocated one row per KV token (65536 -> 12 GB) on top
of a mamba pool of a few hundred slots. Size from mamba capacity only.
* The post-replay decode track epilogue logged and swallowed failures. It is
the only snapshot writer for a replayed decode graph, so a failure left a
track slot unwritten that later prefix hits would resume from. Let it
propagate.
`_mamba_physical_slots` read axis 0 of the full conv / temporal pools, which
is the layer count; the slot axis is 1 and holds `size + 1` rows (SGLang's
reserved row included). With `--max-mamba-cache-size` at or above the
estimate floors, PLE got `size` rows while slot `size` is valid: eager PLE
clamped it onto another request's row and the decode graph indexed past the
buffer.
A resolved pool now sizes PLE exactly. The estimate (512 floor,
`max_running_requests * 64`, capacities + 1 for the reserved row) applies only
while no pool is reachable. At 64 requests / 330 slots that is 331 rows instead
of 4096.
`_align_flydsl_decode_slots` stopped materializing a contiguous copy, so the
decode graph cannot bake a throwaway address. The gfx942 test still asserted
the old behaviour and failed on hardware; the CPU CI job skips it, so the
break was invisible.
It now expects the strided view to be rejected, covers the int32 check, and
asserts that narrowing a padded buffer returns a view of the same storage.
The Qwen3.5 startup guard required SGLANG_USE_AITER_UNIFIED_ATTN whenever any
of the three backend settings mentioned Aiter. Per-mode overrides made that a
false positive: `--attention-backend aiter --prefill-attention-backend triton
--decode-attention-backend triton` runs neither effective backend on Aiter and
was still rejected.
Resolve the one backend DFLASH verifies on -- the decode backend when
`speculative_attention_mode` is "decode", the prefill backend otherwise, each
falling back to the default -- and require unified attention only for that one.
Flash keeps its PLE history (n-gram ids, short-conv window) in plugin-owned
buffers indexed by mamba slot. SGLang's equivalent pools are slot-lifecycle
siblings and are snapshotted into the track slot by its own Qwen4-Exp model,
but they stay disabled under the plugin, and the plugin's buffers joined
neither contract. A prefix hit therefore resumed GDN from the cached state
while PLE read whatever window the working slot's previous tenant left.
* Register the buffers as a slot sibling, so SGLang copies them with the slot
on a hit or fork and clears them when a slot is recycled.
* Snapshot them at the boundary SGLang tracks GDN at. Extend gathers the
window out of this chunk (the working slot holds the state after the whole
chunk by then); decode copies the working slot, which already is the
boundary state. Capture is skipped and the post-replay epilogue owns the
replayed decode graph, as the GDN track already does.
Runs automatically on every eligible PR before approval:
✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests
Heavy model tests:
✅ Run after the PR is approved and Pre Checkin passes
✅ Run immediately when an approval review is submitted
✅ Can be requested before approval with labels
Label
Tests
ci:full
Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom
Run native ATOM model accuracy tests
ci:vllm
Run ATOM vLLM OOT model accuracy tests
ci:sglang
Run ATOM SGLang model accuracy tests
Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2723 --add-label <label>
This follow-up to #2675 connects Flash PLE history to SGLang’s mamba radix-cache lifecycle and adjusts the Qwen3.5 DFLASH guard to use the effective verification backend.
Changes:
Registers PLE slot state and adds boundary snapshots for extend and decode.
Resolves per-mode attention overrides before requiring Aiter unified attention.
Adds regression coverage alongside the stacked GDN tracking, pool-sizing, and graph-replay changes.
File
Description
tests/test_aiter_flydsl_gdn.py
Checks decode slot validation and storage aliasing.
Two defects in the previous commit, both found in review:
* The snapshot ran before `_short_conv`, so a decode row copy paired a
convolution window from before this token with n-gram ids from after it.
It now runs once both halves have advanced.
* The post-replay epilogue read the last built PLE metadata. Replay does not
rebuild it, so it still described whatever forward ran eagerly before --
typically a prefill, with its own slot vector and batch size. The epilogue
now takes the buffers from the model and the slots from the live linear
backend's `forward_metadata`, and nothing stashes the metadata any more.
Both regressions fail against the previous commit.
Avoid CUDA synchronization during tracking mask processing
atom/plugin/sglang/qwen4_exp_bridge.py:1464
For a CUDA tracking mask, nonzero() synchronizes the host even when every entry is false, and bool(live.all()) adds another synchronization on tracked steps. This puts a GPU wait into eager decode and, once the buffer-owner lookup is fixed, the replay epilogue. Use fixed-size slot vectors with a GPU-masked copy that skips untracked and negative slots.
…drop its host syncs
Three defects from review, all in the decode half:
* The replay epilogue read the PLE buffers off the SGLang wrapper, but
`build_ple_metadata` is called with the native model, which the wrapper
keeps in `__dict__` rather than as a child module. The lookup returned
None, so a replayed decode silently snapshotted nothing.
* An uncaptured decode batch below the graph ceiling still builds its PLE
metadata through the graph path, yet runs eagerly with no epilogue behind
it. It carried no track destination, so GDN was tracked and PLE was not.
That metadata now gets the decode track fields whenever it is not capture.
* Selecting the tracked rows used `nonzero()` and a `.all()` check, two host
synchronisations per decode step. The snapshot is now full-batch and
branch-free: untracked rows and graph pads are routed to a scratch row,
one past the pool, that nothing reads.
Converted back to draft: the PLE code in this branch hangs the GPU under concurrent prefill, and I have not located the cause yet.
HW Exception by GPU node-N reason :GPU Hang followed by Fatal Python error: Aborted, reproduced twice. The control run rules out the workload and the image: same host, same GPU pair, same launch arguments and same stress script on qwen35-qwen38-radix-cache (d15213dd7, this PR minus its three commits) completes with zero hangs.
It only triggers with speculative decoding off -- with EAGLE on, decode goes through TARGET_VERIFY, which carries no track mask, so the decode-side snapshot never runs and the full MTP suite stays green. That is also why the earlier rounds of testing here missed it.
Do not merge. Nothing else is affected: #2675 and #2645 do not contain this code, and #2675 is exactly what the clean control run used.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #2675, stacked on it: this PR branches off
qwen35-qwen38-radix-cacheand its first six commits are that PR. Review only the last two; the base will bemainonce #2675 merges.Both fixes were raised by the automated reviewer on #2675 and deferred there to keep that PR reviewable:
1. PLE history and the radix cache
Qwen3.8-Flash-Next keeps two side states per mamba slot next to the GDN state: the n-gram token ids and the short-convolution window. SGLang owns the slot and has a contract for exactly this:
ShortConvPool/NGramPoolare registered withMambaPool.register_slot_state, socopy_slotsmoves them when a hit or fork hands one request's slot to another, andclear_slotsresets them when a slot is recycled;qwen4_exp.pywrites them into the ping-pong track slot at the snapshot boundary (_ple_track_targets), because the tree caches that slot, not the working one.Under the plugin neither half applies.
_get_ple_req_pool_kwargskeys onisinstance(config, sglang.srt.configs.qwen4_exp.Qwen4ExpTextConfig), and the config the runtime loads istransformers.models.qwen4_exp.configuration_qwen4_exp.Qwen4ExpTextConfig, so SGLang's PLE pools stay disabled. ATOM's own_atom_ple_conv_state/_atom_ple_ngram_statewere neither siblings nor tracked.The fix joins both halves:
_PLESlotStateimplements SGLang'sSlotIndexedStateprotocol over the two buffers and is registered on the mamba pool once. Copy, reset and the host round-trip now follow the slot. It reads the buffers off the model on every call because they are allocated lazily and may be replaced before capture. P/D transfer is left out: that contract is per-layer, and PLE is one layer's side state.mamba_cache_chunk_size(64), well past the 9-wide PLE window.What this does and does not change in measurements
It does not move the prefix-reuse numbers, and I would not claim otherwise. The stale window is two token ids and a 9-wide convolution row, read by one layer for the first few recomputed tokens; the teacher-forced probe from #2675 cannot resolve it against run-to-run noise, before or after:
The justification is the contract, not a delta: SGLang copies and tracks its own PLE pools, the plugin replaces the model that would have done so, and a resumed slot must not read another request's history. The unit tests pin each half of that contract directly.
2. DFLASH guard on the resolved backend
_validate_qwen35_runtime_contractrequiredSGLANG_USE_AITER_UNIFIED_ATTN=1whenever any ofattention_backend,prefill_attention_backendordecode_attention_backendmentioned Aiter. With per-mode overrides that is a false positive:--attention-backend aiter --prefill-attention-backend triton --decode-attention-backend tritonruns neither effective backend on Aiter and was still rejected before loading weights.It now resolves the single backend DFLASH target verification uses — the decode backend when
speculative_attention_modeis"decode", the prefill backend otherwise, each falling back to the default — and requires unified attention only for that one. The page-size-64 guard is untouched.Test plan
Image
rocm/atom-dev:sglang-v0.5.20-nightly_20261021(=sglang-latest,9b3c1b75a7da), SGLang 0.5.2094602c9c2bunmodified, 2x MI308X.tests/plugin/test_sglang_qwen4_exp_ple_radix.py(12) covers the sibling copy / reset / host round-trip, that it follows a regrown buffer, the extend window landing on the aligned boundary, the decode row copy, and that graph pad rows (PAD_SLOT_ID) are dropped. Four new cases intest_sglang_qwen35_runtime_contract.pycover the guard, including the reported false positive and bothspeculative_attention_modedirections; they fail without the production change.test_sglang_qwen35_runtime_contract.py11,test_sglang_qwen4_exp_graph_padding.py51,test_sglang_gdn_radix_track.py12,test_sglang_qwen4_exp_mtp.py29,test_sglang_qwen35_attention.py21.Registered Flash PLE history as a mamba slot-state sibling), PLE rows 331 as before.check_gdn_prefix_reusemulti-turn 4/4 pass;b78fb249e): snapshot ordering and the replay slot source, see below. Re-validated served on Qwen3.8, radix on, batch 64: prefix-reuse probe aligned 0.97-1.00, unaligned 0.98, decode 1.08; multi-turn 4/4; shared prefix c64 256/256; unique random c64 256/256; GSM8K 400 at 64 parallel 0.965-0.970; with every working slot dirtied before the hit, warm/cold 0.95-1.12 on the first 4 suffix tokens and 0.99 on the first 32.check_gdn_prefix_reuse4/4 pass, prefix-reuse probe aligned 1.01, unaligned 0.91, decode 1.04, no fault or traceback. Qwen3.8 has the only PLE path, so that half is unaffected by construction. The guard change cannot alter any validated Qwen3.5 configuration (none sets a per-mode override), and the PLE path does not exist on Qwen3.5, so [Plugin][SGLang] GDN mamba radix cache (extra_buffer, page 64) for Qwen3.5 and Qwen3.8-Flash-Next #2675's Qwen3.5 results stand.Merging
Rebase onto
mainafter #2675 lands; the two commits touch files that PR already moves, so it is not independently mergeable.Status of the measurements in this section
Review round 3 (
bcfbc07a7) found that the replay epilogue was reading the PLE buffers off the SGLang wrapper while they live on the native model, so the replayed-decode snapshot never executed. Every served number above was therefore measured with that path inert: they are valid as a no-regression check against #2675, but they do not exercise this change's decode half. Re-validation is pending free GPUs on the test node.Unit coverage for the three round-3 fixes is in place and each regression fails on the preceding commit.
Known gap: speculative verify
A boundary crossed inside an accepted draft still gets GDN without PLE. A verify batch carries no track mask (
spec_utilsclears it) and the commit runs inupdate_mamba_state_after_mtp_verify, whose PLE half needs the per-draft-stepintermediate_conv_state/intermediate_context. ATOM's PLE kernels foldnum_accepted_tokensinto the working slot directly and keep no per-step snapshots, so the boundary is not recoverable after the fact.Closing it means giving those kernels per-step intermediates and scattering them at
mamba_steps_to_track. That is a kernel-level change and will be a separate PR. It affects speculative decoding plus radix only; prefill-side reuse, the dominant case, is unaffected.