Skip to content

feat: back paged scheduler sequences with the shared KV block pool - #167

Merged
inureyes merged 3 commits into
mainfrom
feat/121-scheduler-paged-wiring
Jun 6, 2026
Merged

inureyes merged 3 commits into
mainfrom
feat/121-scheduler-paged-wiring

Conversation

@inureyes

@inureyes inureyes commented Jun 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Sub-step (a) of #121 (epic #116): wire the live batch scheduler's paged decode backend onto the PagedBlockPool that #118–120 built and #152 proved through a real model. Path A — the pool rides inside the KVCache, so the LanguageModel trait and forward signature stay unchanged.

This is one of three sub-steps and does not close #121 — the radix adopt/donate + try_adopt_cached_prefix guard removal lands in sub-step (b), and the multi-model RMS sweep in sub-step (c).

What changed

Phase 1 — CachePool Rc refactor (behaviour-preserving)

  • CachePool.paged_pool → Option<Rc<RefCell<PagedBlockPool>>> and SequenceCacheSet.paged → Option<Rc<RefCell<PagedSequenceState>>>, so a pool-backed sequence shares ONE authoritative block table across its per-layer caches (each cache's PagedBacking holds a cheap Rc clone of the same handles).
  • Updated every access site — allocate*, detach_paged/adopt_paged/release_detached_paged/park_* (paged_detach.rs), sync_paged_state_with_*, paged_stats/stats_for_sequences, restore_paged_state, ensure_paged_pool*, release, and the paged_pool_ref/paged_pool_mut/get_paged_state/paged_state accessors (now hand out Ref/RefMut guards) — with tightly-scoped borrows. Request-boundary pool/state borrows never nest with the forward's update_and_fetch borrows; adopt_paged drops every pool borrow before the prepare_sequence_state callback.
  • No behaviour change: the Phase 1: Global block-pool tensor storage #118–120 detach/adopt/round-trip suites pass unchanged (adapted only for the Ref/RefMut type change).

Phase 2 — live paged wiring

  • CachePool::allocate builds pool-backed caches (KVCache::new_paged) for paged sequences whose model's natural backend is the dense external KVCache slice (qwen3/llama3) under an Fp16 layout. Model-owned families (gemma3/llama4/qwen3_5, which drive their own ModelOwnedSequenceState and ignore the caller's caches) and Turbo4 layouts keep their existing dense placeholders — byte-identical to before.
  • Added KVCache::is_paged_backed(). The qwen3/llama3 batched-decode dispatch returns None from its native-paged-kernel selection when any cache is pool-backed, falling through to the per-sequence update_and_fetch loop — the feat: transparent pool-backed KVCache + real-model paged parity test #152-validated pool path (write_prefill + gather_visible). The gemma3/llama4 shared dispatch helpers are untouched because those models never receive pool-backed caches.
  • KVCache::update gained the same pool intercept as update_and_fetch (factored into a shared write_paged), so llama3's single-sequence fused causal prefill path — which calls update without a fetch — also writes to the pool. qwen3's single-sequence prefill already routes through update_and_fetch.
  • sync_paged_state_with_dense skips pool-backed sequences (the pool is authoritative — write_prefill advances state.len in lockstep with the cache offset). trim/trim_front are dense-side no-ops for pool-backed caches (they would otherwise desync from / unwrap the absent dense buffers; paged trimming is a pool-API concern).
  • Hybrid-SSM carve-out: confirmed already in place — effective_decode_storage_backend gates on supports_paged_decode_backend() (trait default false) and supports_batching() (which mamba/mamba2/jamba/nemotron_h/nemotron_nas/recurrent_gemma/qwen3_next/rwkv7 override to false); either flag forces the dense backend. Added a test pinning the combined SSM profile across batch sizes.

Phase 3 — verification

  • New tests/paged_scheduler_parity.rs (#[ignore], skip-if-absent) drives the scheduler's paged allocation path via CachePool::allocate_with_layout (the exact API BatchScheduler::allocate_sequence_state calls for the paged backend) and asserts identical greedy tokens to the dense backend, for both the single-sequence path and a batched B=2 decode (which exercises the is_paged_backed() decode guard). BatchScheduler::new is pub(crate), so this CachePool-driven harness is the faithful constructible driver from an integration test.

Drive-by

Test plan

  • cargo test --lib -p mlxcel-core --features metal,accelerate cache:: — 370 pass (incl. 2 new sync tests)
  • cache::paged_detach / cache::detach regression suites — unchanged, pass
  • cargo test --lib --features metal,accelerate hybrid_ssm_workers_never_use_paged_decode_backend — pass
  • cargo clippy -p mlxcel-core --tests --features metal,accelerate -- -D warnings (cold) — clean
  • cargo clippy --lib --features metal,accelerate -- -D warnings — clean
  • cargo check --test paged_scheduler_parity --features metal,accelerate — compiles
  • cargo fmt --check — clean

Real-model parity test (loads qwen3-0.6b-4bit on the GPU; run by the orchestrator):

cargo test --test paged_scheduler_parity --release --features metal,accelerate -- --ignored --nocapture

Part of #121

Follow-up fixes (after the real-model parity run)

The orchestrator's paged_scheduler_parity real-model run surfaced two issues, now fixed on this branch (both crates cold-clippy clean; core regression 832 pass; scheduler/batch 173 pass; parity now passes for BOTH single-sequence and batched B=2 — qwen3-0.6b-4bit, 16 greedy tokens identical to dense):

fast_rope_batched stride aliasing — pre-existing, affects dense batched decode too

Batched B=2 parity initially diverged (row 1 looped). Root cause: fast_rope_batched's uniform-batch fast path collapsed to a single fast_rope over the full [B, n_heads, T, D] tensor. In T==1 batched decode the Q/K/V arrive transposed from [B, T, n_heads, D], where swapping the size-1 sequence dim leaves stride[batch] == stride[sequence]; MLX's fast::rope kernel then rotates batch element b to position offset + b instead of offset. This silently corrupted RoPE for batch rows past the first even with identical offsets — a latent bug in dense batched decode (the function's existing callers) that the paged same-prompt test exposed. Fix: drop the uniform fast path, always go per-row (B==1 cannot alias) + regression test test_fast_rope_batched_non_contiguous_t1_symmetric.

Int8/Turbo3 paged → dense (pool-backing is Fp16-only)

The Fp16 pool-backing gate keyed on the layout's cache_mode, but the scheduler built Int8/Turbo3 paged layouts with cache_mode = Fp16, so those sequences were pool-backed and stored as Fp16 (correct output, lost Int8 memory saving). Fix: the scheduler now builds the non-Turbo4 paged layout with the real effective_mode, so the gate pool-backs only genuine Fp16 and Int8/Turbo3 keep their dense path. Pool-native quantized storage is a later phase.

Sub-step (a) of #121: make the live batch scheduler's paged decode backend actually use the PagedBlockPool that #118-120 built and #152 proved through a real model. Path A — the pool rides inside the KVCache, so the LanguageModel trait and forward signature stay unchanged.

CachePool refactor (behaviour-preserving): `paged_pool` and `SequenceCacheSet.paged` become `Rc<RefCell<…>>` so a pool-backed sequence shares one authoritative block table across its per-layer caches. Every access site (allocate, detach/adopt/park, sync, stats, restore, accessors) now scopes its borrows tightly; the request-boundary pool/state borrows never nest with the forward's `update_and_fetch` borrows.

Live wiring: `CachePool::allocate` builds pool-backed caches (`KVCache::new_paged`) for paged sequences whose model uses the dense external KVCache slice (qwen3/llama3) under an Fp16 layout; model-owned families (gemma3/llama4/qwen3_5) and Turbo4 layouts keep their existing dense placeholders. The qwen3/llama3 batched-decode dispatch skips the native dense-pointer kernel when any cache `is_paged_backed()` and falls through to the per-sequence `update_and_fetch` loop, whose pool intercept writes the new K/V (`write_prefill`) and gathers the visible window (`gather_visible`). `update` gained the same intercept so the single-sequence fused causal prefill path (llama3) also writes to the pool. `sync_paged_state_with_dense` skips pool-backed sequences (the pool is authoritative), and `trim`/`trim_front` are dense-side no-ops for them.

Hybrid-SSM carve-out: confirmed `effective_decode_storage_backend` already excludes recurrent models — they keep `supports_batching() == false` and the `supports_paged_decode_backend()` default `false`, either of which forces the dense backend. Added a test pinning the combined SSM profile across batch sizes.

Verification: new `tests/paged_scheduler_parity.rs` (`#[ignore]`, qwen3-0.6b-4bit) drives the scheduler's paged allocation path (single-sequence and batched B=2) and asserts identical greedy tokens to the dense backend.

Drive-by: fixed a pre-existing `clippy::identity_op` in `utils.rs` (`0 + 2`).
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:high High priority status:review Under review area:architecture Architecture and code structure changes labels Jun 6, 2026
inureyes added 2 commits June 7, 2026 05:31
…n T=1 batched decode

The uniform-batch fast path collapsed fast_rope_batched to a single fast_rope over the whole [B, n_heads, T, D] tensor when all offsets matched. In batched decode (T==1) the Q/K/V arrive transposed from [B, T, n_heads, D], where swapping the size-1 sequence dim leaves stride[batch] == stride[sequence]; MLX's fast::rope Metal kernel then confuses the batch index for an intra-sequence position and rotates batch element b to position offset+b instead of offset. This silently corrupted RoPE for every batch row past the first even when sequences shared the same offset -- a latent bug affecting dense batched decode (the function's existing callers), surfaced by the paged B=2 same-prompt parity test. Drop the uniform fast path; always slice to [1, n_heads, T, D] (B==1 cannot alias) and concatenate. Adds test_fast_rope_batched_non_contiguous_t1_symmetric.
…ing is Fp16-only)

The pool-backing gate keys on paged_layout.cache_mode == Fp16, but the scheduler built Int8 (and Turbo3Asym) paged layouts via PagedKvLayout::uniform, which stamps cache_mode = Fp16, so those sequences were pool-backed and stored as raw Fp16 -- correct output but silently losing the Int8 memory saving. Build the non-Turbo4 paged layout with uniform_with_mode(effective_mode, 0) so the layout carries the real cache mode; the gate then pool-backs only genuine Fp16 and Int8/Turbo3 keep their existing dense path. Pool-native quantized storage is a later phase.
@inureyes
inureyes merged commit 7ed97ba into main Jun 6, 2026
5 checks passed
@inureyes
inureyes deleted the feat/121-scheduler-paged-wiring branch June 6, 2026 20:34
@inureyes inureyes added this to the 0.2 milestone Jun 21, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 6, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ablup#167)

* feat: back paged scheduler sequences with the shared KV block pool

Sub-step (a) of lablup#121: make the live batch scheduler's paged decode backend actually use the PagedBlockPool that lablup#118-120 built and lablup#152 proved through a real model. Path A — the pool rides inside the KVCache, so the LanguageModel trait and forward signature stay unchanged.

CachePool refactor (behaviour-preserving): `paged_pool` and `SequenceCacheSet.paged` become `Rc<RefCell<…>>` so a pool-backed sequence shares one authoritative block table across its per-layer caches. Every access site (allocate, detach/adopt/park, sync, stats, restore, accessors) now scopes its borrows tightly; the request-boundary pool/state borrows never nest with the forward's `update_and_fetch` borrows.

Live wiring: `CachePool::allocate` builds pool-backed caches (`KVCache::new_paged`) for paged sequences whose model uses the dense external KVCache slice (qwen3/llama3) under an Fp16 layout; model-owned families (gemma3/llama4/qwen3_5) and Turbo4 layouts keep their existing dense placeholders. The qwen3/llama3 batched-decode dispatch skips the native dense-pointer kernel when any cache `is_paged_backed()` and falls through to the per-sequence `update_and_fetch` loop, whose pool intercept writes the new K/V (`write_prefill`) and gathers the visible window (`gather_visible`). `update` gained the same intercept so the single-sequence fused causal prefill path (llama3) also writes to the pool. `sync_paged_state_with_dense` skips pool-backed sequences (the pool is authoritative), and `trim`/`trim_front` are dense-side no-ops for them.

Hybrid-SSM carve-out: confirmed `effective_decode_storage_backend` already excludes recurrent models — they keep `supports_batching() == false` and the `supports_paged_decode_backend()` default `false`, either of which forces the dense backend. Added a test pinning the combined SSM profile across batch sizes.

Verification: new `tests/paged_scheduler_parity.rs` (`#[ignore]`, qwen3-0.6b-4bit) drives the scheduler's paged allocation path (single-sequence and batched B=2) and asserts identical greedy tokens to the dense backend.

Drive-by: fixed a pre-existing `clippy::identity_op` in `utils.rs` (`0 + 2`).

* fix: per-row RoPE in fast_rope_batched to avoid MLX stride aliasing on T=1 batched decode

The uniform-batch fast path collapsed fast_rope_batched to a single fast_rope over the whole [B, n_heads, T, D] tensor when all offsets matched. In batched decode (T==1) the Q/K/V arrive transposed from [B, T, n_heads, D], where swapping the size-1 sequence dim leaves stride[batch] == stride[sequence]; MLX's fast::rope Metal kernel then confuses the batch index for an intra-sequence position and rotates batch element b to position offset+b instead of offset. This silently corrupted RoPE for every batch row past the first even when sequences shared the same offset -- a latent bug affecting dense batched decode (the function's existing callers), surfaced by the paged B=2 same-prompt parity test. Drop the uniform fast path; always slice to [1, n_heads, T, D] (B==1 cannot alias) and concatenate. Adds test_fast_rope_batched_non_contiguous_t1_symmetric.

* fix: keep Int8/Turbo3 paged sequences on the dense KV path (pool-backing is Fp16-only)

The pool-backing gate keys on paged_layout.cache_mode == Fp16, but the scheduler built Int8 (and Turbo3Asym) paged layouts via PagedKvLayout::uniform, which stamps cache_mode = Fp16, so those sequences were pool-backed and stored as raw Fp16 -- correct output but silently losing the Int8 memory saving. Build the non-Turbo4 paged layout with uniform_with_mode(effective_mode, 0) so the layout carries the real cache mode; the gate then pool-backs only genuine Fp16 and Int8/Turbo3 keep their existing dense path. Pool-native quantized storage is a later phase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes priority:high High priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 4: Radix-trie and block-pool unification (scheduler wiring)

1 participant