Skip to content

Resident decode: attention page arrays in the graph, sampler scalars in one copy, a row per KV pool - #1143

Draft
NSagan271 wants to merge 17 commits into
mainfrom
host-overhead/resident-sampler-attn
Draft

NSagan271 wants to merge 17 commits into
mainfrom
host-overhead/resident-sampler-attn

Conversation

@NSagan271

@NSagan271 NSagan271 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1127 (resident decode planning). Two host-time cuts on resident decode steps that #933/#966 had and #1127 doesn't, a resident row per KV pool, and Qwen3-TTS and Whisper on resident tables. #933/#966 stay as drafts for reference.

Attention page arrays built in the graph

On Hopper, FlashInfer's paged decode reads an indptr, a flat page-index list and last-page lengths, not a block table, so a resident step still built the page lists on the host (the KV plan) and copied them (the attention plan).

  • FlashInferDecodeWrapper.gather_resident (mstar/engine/resources/attn/wrappers.py): one kernel per resident capture fills the wrapper's three arrays from Resident decode planning: decode rows on device tables #1127's block table and the lengths the KV gather already reads, through the step's slots.
  • It is recorded only when the wrapper's plan takes the direct planner path (schedules_alone); the (bucket, pool) then joins ResidentPlanner.lengths_only, next to host_plan_free.
  • On such a bucket the KV plan returns host lengths only, from the tables' host mirror (KVResidentTables.decode_indptrs), and the attention plan runs FlashInfer's planner alone (plan_schedule). A promoted step reuses its pre-plan's lengths.
  • The fmha (Blackwell) path is unchanged.

Sampler scalars in one copy

gather_static staged each scalar buffer (temperature, top-k, top-p, seed, penalty, and any optional ones) with its own H2D, ~14 µs of host time apiece. The per-step rows are now views of one byte block per graph slot (StaticRows), staged and copied once. This one doesn't depend on #1127.

A resident row per KV pool

#1127's planner keeps one stream per row: one block table (_seal refuses a second), one label per step, one ResidentWrites.kv_len, and one block table, lengths buffer and attention mode per capture. Whisper's decoder has two KV pools, its self-attention cache and its cross attention context, so it couldn't run resident.

  • Each of these is keyed by resource key (mstar/engine/resources/resident.py): every KV pool binds its own block table and lengths, positions read the length of the pool their PositionConfig.kv_cache names, and an attention reads its own pool's table. bind_resident takes the resource's key.
  • A pool whose stream a step declares at span 0 is read only: the capture gathers its lengths and leaves its table, the host mirror doesn't advance, and the pool keeps its host plan. Whisper's context is the case: written by the prefill, read by every decode step.
  • Adjacent runs of a rewritten row go in one copy, so a row of one pool is still one copy, as on Resident decode planning: decode rows on device tables #1127.
  • Cross attention over a context in its own pool runs on resident steps, packing its queries off the step's rows; a context in the query's own pool still refuses.
  • This is a proposal for Resident decode planning: decode rows on device tables #1127; if Resident decode planning: decode rows on device tables #1127 takes its own version, this commit drops.

Models

  • Qwen3-TTS: the Talker's talker_decode walk opts in (resident_walks).
  • Whisper: the decoder's decode walk opts in.

Results

2× H200; one GPU per server. Rust frontend and SHM arena for Qwen3.5. Each table is ABBA, back to back: #1127's head (863cf87), this branch, this branch, #1127's head.

Qwen3.5-0.8B, prefix cache off, --ignore-eos, 256–512 output tokens, text tok/s:

#1127 this Δ
c=16 9,269 / 9,108 9,317 / 9,415 +1.9%
c=128 27,445 / 27,599 28,647 / 28,294 +3.4%

Phase timing (benchmark/worker_phases), µs per iteration, before the per-pool commit (64085a9):

#1127, c=16 this, c=16 #1127, c=128 this, c=128
plan thread (pre_plan) 481 322 836 527
↳ KV 92 39 218 35
↳ attention 131 68 79 45
↳ sampler 37 17 112 49
GPU thread total 1,006 826 2,410 2,115
GPU thread waiting on the plan 226 116 401 171
iteration 1,797 1,660 4,607 4,336

Qwen3-TTS 0.6B (benchmark.tts_speech_bench, 128 sentences, 5 repeats per concurrency), audio-s/s:

#1127 this Δ
c=32 169.9 / 167.4 169.6 / 168.4 +0.2%
c=64 236.7 / 239.7 239.8 / 238.8 +0.5%

Whisper large-v3-turbo (benchmark.asr_eval, LibriSpeech test-clean, 640 requests at c=64, four passes per server; the first is warmup), req/s, mean of passes 2–4. The transcripts are greedy and score the same WER, so req/s compares like for like:

#1127 this Δ
c=64 101.98 / 102.91 105.43 / 105.30 +2.8%

Each pass is ~6 s; the twelve measured passes don't overlap (#1127 101.6–103.5, this 104.7–106.3).

Output checks:

Tests

  • ruff check .: clean.
  • New in test/modular/test_resident_planning.py:
    • the KV plan's host lengths match the full host plan of the same step, padding rows included (CPU);
    • the kernel's page arrays match the host indptr and the block table's pages (CUDA, ran on H200);
    • two pools bind rows of their own;
    • a pool the step only reads keeps its row, and its host plan, against a host-planned reference, including beside a lengths-only pool;
    • a rewritten row is one copy per pool;
    • cross attention's resident query packing matches the host plan's.
  • test/modular/test_resident_planning.py, test/modular/test_sampler_*.py, test/modular/test_sampling_*.py, test/sampling_test, and the cross attention, Whisper and Qwen3-TTS modular tests: pass on H200. test_sampler_static_gather_skip.py now counts the one packed gather.
  • Modular tests matching kv/attention/sampler/resident/decode/capture/position/recurrent/whisper/tts/qwen3_5 on H200: every failure also fails on Resident decode planning: decode rows on device tables #1127's head (test_client_error_text, test_gpu_mla_attention shared-prefix capture, test_kv_offload OOM report, test_audio_watermark, test_bagel_cfg_positions, test_node_fp32_precision_gpu, test_qwen3_omni_encoders), except test_ragged_attention.py::test_piecewise_replay_matches_reference[one_empty], which failed only inside that run and passes alone on both.

AI assistance was used for this PR.

🤖 Generated with Claude Code

merceod and others added 14 commits November 1, 2026 18:36
A steady decode step only moves lengths by one and crosses a page now and
then, so keep each request's block table row, length, GDN slot and position
offset on the device. The captured step reads them by a row of slots and
commits its own lengths; the host sends what changed and stays the truth.
Mixed and eager steps keep the host plan and mark their rows. Behind
knobs: resident_planning (auto|off), Qwen3.5 decode opts in, fa2 keeps its
per-step plan.
The planner's stage runs before runner.plan sets plan_phase, so a fresh
stage's laps landed under res.plan. A resident step doesn't read or write
the recurrent addressing memo; the cached KV plan's np.insert still feeds
fa2's attention plan on a resident step.
Picks up #857, #977, #980 and #916. One conflict, in StepContext: the
resident fields and #916's chunk fields both added, kept both.
#980 has the runner drop its stage when an admit is refused, since the
unwind gave back the pages it planned into. The planner's stage names the
same pages in its crossing cells, so with no commit in between a retry
over the same rows promoted cells pointing at a page the row no longer
held. The KV pool now drops it wherever it gives a stage's pages back.
The knob lists in knobs.py and docs/serving.rst conflicted with #988's compile_deterministic entry; both sides kept.
#859 declares device loop-back (DeviceLoopback) and builds Qwen3.5's decode rotary tables in the graph. The engine enables the sampler's reader the way main does now and then builds the planner; Qwen3.5 keeps only resident_walks, since the loop-back hooks come from the declaration.
#820's LoRA lease (a base-only capture key when no row names an adapter) goes first, and the planner is matched to whichever lease it returns. Imports merged.
The scalar buffers' per-step rows become views of one byte block per
graph slot, so gather_static stages every buffer into one pinned block
and copies it once. A copy apiece cost ~14 us of host time each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a resident bucket whose FlashInfer decode wrapper plans the direct
way, a kernel in the graph fills the wrapper's indptr, page indices and
last page lengths from the block table and the gathered lengths. The KV
plan then hands the attention plan the host lengths alone, from the
tables' host mirror, and the attention plan runs FlashInfer's planner
alone: no page lists are built or copied on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One token a row in the main stream, and preprocess reads no planned
position or KV location, so the walk meets resident planning's terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NSagan271 and others added 2 commits November 3, 2026 01:50
The planner kept one stream per row: one block table, one label, one stored
length, one set of attention modes per bucket. Each is now keyed by resource
key, so every KV pool of a node binds its own row, positions read the length
of the pool they name, and an attention reads its own pool's table.

A pool whose stream the step declares at span 0 is read only: the capture
gathers its lengths and leaves its table, and it keeps its host plan, as
Whisper's cross attention context does. Adjacent runs of a rewritten row
are one copy, so a row of one pool is still one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-attention pool appends a token a row; the cross attention context
pool is read at span 0, so it binds a read-only row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NSagan271 NSagan271 changed the title Resident decode: attention page arrays in the graph, sampler scalars in one copy Resident decode: attention page arrays in the graph, sampler scalars in one copy, a row per KV pool Nov 3, 2026
Base automatically changed from users/atindra/resident-plan to main November 3, 2026 11:00

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants