Skip to content

Qwen 3.8 long context: paged KV with SSD tier, Quest sparse decode, MTP composition - #20

Merged
NeelM0906 merged 12 commits into
mainfrom
claude/qwen38-longctx-ssd-kv
Aug 26, 2026
Merged

Qwen 3.8 long context: paged KV with SSD tier, Quest sparse decode, MTP composition#20
NeelM0906 merged 12 commits into
mainfrom
claude/qwen38-longctx-ssd-kv

Conversation

@NeelM0906

@NeelM0906 NeelM0906 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Qwen 3.8 long context: paged KV cache with SSD tier, Quest sparse decode, and MTP composition

Qwen 3.8-27B's full-attention KV cache costs 64 KiB/token — 16 GiB at the model's full 262,144 context, which neither fits beside 14 GiB of weights on a 24 GB machine nor could be read per-token even if it did (dense attention at 262k = 16 GiB of reads ≈ 181 ms/token from RAM). This PR makes long contexts practical at full FP16 KV precision (no quantization) by treating capacity and bandwidth as separate problems:

  • Capacity → SSD tier. Full-attn KV lives in 64-token pages (a K+V page pair is 256 KiB — the measured sweet spot of the NVMe's random-read curve at 2.3 GiB/s). A bounded per-layer RAM pool (auto-sized from physical memory) holds the working set; sealed pages write behind to a sparse layer-major spill file and evict under LRU. The 48 GDN linear-attention layers keep their constant, exact 144 MiB recurrent state — untouched.
  • Bandwidth → query-aware sparsity. Each decoded token attends sinks + recent window + top-k pages ranked by Quest criticality (arXiv 2406.10774) computed on-GPU from per-page min/max K summaries. Selection is lag-one (scored with token t's query, applied at t+1) so fetches hide in the inter-token gap; the live selection is pinned so its own fetches cannot evict it.
  • Exact prefill at any depth. Chat turns appended beyond the pool run a blocked streamed path: the sealed past flows from the spill file through a double-buffered staging ring (sequential preads overlapped with GPU passes) into FP32 running-softmax carry state; the chunk's own pages fold causally from the pool. Same math as the resident path — only decode is sparse, by design.
  • MTP speculative decode composes with paged mode. The verify pass writes draft rows through the page store, runs per-position paged attention over the round's pinned selection (table extended across the span's page crossings), and the cursor rewind on rejected drafts un-seals pages cleanly. Byte-identity of spec vs plain decode is preserved under paging.

Controls

--kv-paged <on|off|auto>   auto: on above 32k --max-context
--kv-topk <pages>          decode budget (default 60 pages ≈ 3.8k tokens/layer)
--kv-pool-pages <n|auto>   resident pool per full-attn layer (auto: by RAM)

Correctness

  • Paged decode kernel is bit-identical to the contiguous kernel under full selection for any page→slot scattering (PagedAttentionParityTests).
  • Paged mode with a covering budget reproduces the dense runner's greedy stream exactly — decode, prefill + continuation, reset (Qwen38PagedKVParityTests).
  • Blocked streamed prefill agrees with the dense head through real spill/fetch, including mid-page chunk boundaries; growing-chat flows replay deterministically under eviction (Qwen38BlockedPrefillTests).
  • MTP spec decode in paged mode is byte-identical to plain paged decode, including rollbacks across page boundaries (Qwen38PagedMTPTests).
  • Full suite: 1,078 tests green.

Measured (M5 MacBook Pro 24 GB, real 27B checkpoint)

run context decode notes
dense baseline (no MTP) 4k 7.8 tok/s --kv-paged off
paged, all resident (no MTP) 4k 8.0 tok/s output identical to dense
paged + SSD needle @30% (no MTP) 5.4k prompt, pool 4.6k tokens 5.9 tok/s passkey retrieved exactly
paged + SSD needle @45% (no MTP) 12.2k prompt, pool 8k tokens 6.3 tok/s passkey retrieved exactly through spill
dense + MTP 4k 16.7 tok/s reference
paged + MTP 4k 16.8 tok/s identical output, zero paging overhead
paged + SSD needle + MTP 5.4k prompt, pool 4.6k tokens 10.5 tok/s † passkey retrieved exactly
262k max-context settings + MTP 262,144 14.0 tok/s full-context allocations, no regression

† Measured before the exactness gate (see review updates below): at the default budget the needle's context exceeds the exhaustive-selection window, so MTP now hands those decodes to plain paged tokens (≈ the 5.9 tok/s plain rate). Raise --kv-topk past the context length to keep speculative rounds running exactly.

Auto-pool sizing was tuned during the sweep: a 4 GiB pool beside 14 GiB of weights pushed the 24 GB host into memory compression (~2x decode loss); the default now leaves 22 GiB of headroom (2 GiB pool ≈ 32k resident tokens/layer) and full-262k settings decode at full speed.

Test plan

  • Unit: page store seal/spill/fetch/LRU/pin/rewind, file offsets, metadata layout, selector policy
  • Kernel parity: paged partial vs contiguous (bit-exact), score/min-max vs CPU reference
  • E2E toy parity: paged==dense greedy; blocked prefill==dense head; MTP paged==plain paged
  • Real model: 4k parity, SSD-pressure needle retrieval, capacity smoke at 262k settings

Review updates (2026-08-26)

Two commits addressing the Codex review:

  • 9b4538d — MTP exactness gate (P1). Speculative rounds run only while the page selection through the round's span (plus one position of margin) provably covers the entire context (KVPageSelector.coversEntireContext, conservative about unscored just-sealed pages); past that point decode falls back to plain paged tokens. Within the gated regime the round's single page table equals the per-token selection, and the margin guarantees the token feeding the first sparse selection's lag-one scores is always plain-decoded — so byte-identity with MTP-off paged decode holds everywhere, including across the crossover. New tests: coverage-boundary units, a rounds-stop-at-the-boundary test (fails 83→100 rounds without the gate), and a sparse-budget e2e byte-identity run.
  • cda21c4 — spill write failures surface cleanly (P2). The write-behind pwrites run through a complete-write loop (short writes/EINTR resumed); the first failure is recorded and thrown by the next spill-file read as KVPageStoreError.ioFailed instead of trapping the process from the background queue. reset() clears the recorded failure. The error box deliberately does not retain the store (spill closures retaining it would deadlock deinit's queue sync).

…nder full budget

kvPagedPolicy=on hands full-attn KV to KVPageStore; decode runs the paged
split-KV kernel over sinks+recent+top-k pages with lag-one Quest scores;
page seals compute min/max metadata on the token CB. Prefill writes stream
into the identity-mapped pool, existing kernels unchanged. MTP defers to
plain decode in paged mode (v1). E2E parity: paged==dense greedy streams.
…I flags

Pool smaller than context: sealed pages spill (write-behind, layer-major
sparse file) and evict under LRU; decode fetches selection misses with the
live selection pinned so its own fetches cannot evict it. Beyond-RAM chat
appends run the blocked prefill: chunk KV scatters into free slots, the
sealed past streams via one sequential pread per window through a 2-stage
ring overlapped with flash-update dispatches (FP32 carry state), tail pages
fold causally from the pool. In-chunk min/max metadata avoids a refetch
storm at first decode. CLI: --kv-paged on/off/auto, --kv-topk,
--kv-pool-pages; auto pool sizing from RAM.
Shared Qwen38PagedKVRuntime (extracted from the runner) drives one cursor,
pin set, and score state for both plain tokens and spec rounds. The verify
pass scatters draft rows through the page store, runs per-position paged
attention over the round's pinned selection (table extended across span
page-crossings), and scores sealed pages with the always-committed bonus
query. Cursor rewind on rejected drafts un-seals pages (KVPageStore.rewind;
stale spills unreachable). canRunRound now requires position >= 1 — a round
at 0 has no prior hidden to seed the drafter and anchored RoPE at -1.
Auto pool headroom 20->22 GiB: measured 2x decode loss from a 4 GiB pool
squeezing weights on the 24 GiB host.

Byte-identity of spec vs plain decode preserved under paging
(Qwen38PagedMTPTests); full suite 1078 green.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b33191abde

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Mference/Runtime/Inference/Qwen38MTPSpeculator.swift
Comment thread Sources/Mference/Runtime/KVCache/KVPageStore.swift Outdated
A speculative round reuses one page table across its verify rows where
plain paged decode reselects per token, and accepted rows are emitted
without their own Quest score pass — under a sparse selection the
speculative stream could drift from the plain paged stream (Codex P1 on
PR #20). Rounds now run only while the selection through the round's
span, plus one position of margin, provably covers the entire context;
past that point decode falls back to plain paged tokens. The margin
guarantees the token feeding the first sparse selection's lag-one scores
is always plain-decoded, so byte-identity holds across the crossover.

KVPageSelector.coversEntireContext is conservative about score
staleness: gap pages are picked by top-k only when scored, so any
unscored just-sealed page must sit inside the recent window.

Tests: selector coverage boundaries (agrees with select()), a
rounds-stop-at-the-boundary gate test (fails without the gate: rounds
kept running 83 -> 100 in the sparse tail), and a sparse-budget e2e
byte-identity run across the crossover. The suite's MTP-off reference
runners now disable MTP per-instance instead of setenv, which raced
runners constructed concurrently by other suites.
The write-behind pwrite pair asserted success from the spill queue, so
ENOSPC, a short write, or an I/O error terminated the whole process
(Codex P2 on PR #20). Writes now run through a complete-write loop
(resuming short writes and EINTR); the first failure is recorded and
thrown by the next spill-file read — fetch or readSpilledSpan — so the
foreground operation fails cleanly instead of reading garbage. reset()
clears the recorded failure along with the rest of the state, since
every page is rewritten before it can be read again.

The error box is deliberately separate from the store: spill closures
must not retain the store itself, because its deinit synchronizes on the
spill queue and dropping the last reference there would deadlock.
@NeelM0906
NeelM0906 merged commit 2d1754e into main Aug 26, 2026
4 checks passed
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