Skip to content

feat: add physical block-pool K/V tensor storage to PagedBlockPool - #148

Merged
inureyes merged 2 commits into
mainfrom
feat/118-global-block-pool-tensor-storage
Jun 2, 2026
Merged

feat: add physical block-pool K/V tensor storage to PagedBlockPool#148
inureyes merged 2 commits into
mainfrom
feat/118-global-block-pool-tensor-storage

Conversation

@inureyes

@inureyes inureyes commented Jun 2, 2026

Copy link
Copy Markdown
Member

Summary

Phase 1 of epic #116 (unified paged KV cache). Adds the physical main K/V tensor storage to PagedBlockPool plus the write/gather primitives, byte accounting, and unit tests, following layout A and the gather-then-SDPA strategy locked by ADR 0001 (docs/adr/0001-paged-attention-gather-vs-fused-kernel.md).

This is an additive capability that later phases consume. The live model/decode flow is intentionally not rewired here (decode-read is #119, prefill-write is #120, fused kernel is #123), per the project's integration rule that integration depending on later sub-issues is deferred to them. Net effect in live operation after this PR: pool tensors stay unallocated (no live writer wired yet), dense KVCache placeholders still carry live K/V, the existing paged decode path is byte-for-byte unchanged, and all existing paged tests pass. The new storage is exercised and proven only by the new unit tests, ready for #119/#120 to wire.

What changed

  • src/lib/mlxcel-core/src/cache/paged.rs
    • New per-layer physical pool state on PagedBlockPool: pool_k / pool_v (Vec<Option<UniquePtr<MlxArray>>>, layout A [capacity_blocks, block_size, n_kv_heads, head_dim], separate K and V per layer), pool_meta (lazily-inferred n_kv_heads/head_dim/dtype/capacity_blocks), block_rows (per-layer block_id -> row), free_rows, next_row. PagedBlockPool::new(layout) signature is unchanged (tensors start None).
    • write_block(block_id, layer_idx, slot_start, k_block, v_block) — lazily allocates the layer pool on first write, assigns/looks up the layer-local row (reusing freed rows), grows in POOL_GROW_CHUNK_BLOCKS (32) chunks when a fresh row exceeds capacity, and writes via slice_update reassigning the pool tensor so MLX donates the buffer (O(block) append per ADR 0001). Accepts both [1, n_kv_heads, n_slots, head_dim] (SDPA-style, primary) and [n_slots, n_kv_heads, head_dim] (bare slab) block layouts; validates shape/dtype against captured geometry on every write.
    • gather_visible(state, layer_idx) — builds the physical-row index from the layer's block table (fragmented / out-of-order rows gather in block-table order), takes the rows, flattens, slices the visible [logical_start, len) window, and transposes into [1, n_kv_heads, visible_len, head_dim], byte-identical to the equivalent dense contiguous buffer's visible slice. Returns Ok(None) for an empty layer.
    • pool_tensor_bytes() — sums array_nbytes over allocated pool_k/pool_v.
    • release_block now frees the main-K/V row (push to free_rows, remove from block_rows) in addition to the existing turbo_sidecars.forget.
    • No astype on the K/V path: dtype is preserved through transpose_axes/reshape/slice_update/take/slice. Fp16 stays Fp16, Int8 stays Int8. Int8 quantization scales stay in the dense path and are not touched.
    • Updated the PagedBlockPool doc comment to state the pool can now own physical main K/V storage (layout A) and that dense-placeholder removal lands with Phase 2: Paged decode attention over real block tables #119/Phase 3: Paged prefill into the block pool #120.
  • src/lib/mlxcel-core/src/cache.rs
    • Re-export the new GatheredKv type alias; wire the paged_pool_tests module.
    • CachePool::memory_usage_bytes adds pool.pool_tensor_bytes() additively next to turbo_sidecar_bytes(); the layout-derived reserved_bytes/used_bytes/stats_for_sequences budgets are unchanged.
  • src/lib/mlxcel-core/src/cache/paged_pool_tests.rs (new) — 13 unit tests covering the acceptance criterion and all required cases.

Test plan

  • cargo check --lib -p mlxcel-core --features metal,accelerate — clean
  • cargo clippy --lib -p mlxcel-core --features metal,accelerate -- -D warnings — clean
  • cargo clippy -p mlxcel-core --tests --features metal,accelerate -- -D warnings — clean
  • cargo test --lib -p mlxcel-core cache::paged_pool --features metal,accelerate — 13 passed
  • cargo test --lib -p mlxcel-core cache::paged_turbo --features metal,accelerate — 26 passed (existing nbytes_* budget assertions intact)
  • cargo test --lib -p mlxcel-core cache::paged --features metal,accelerate — 57 passed (detach round-trips intact)
  • cargo fmt -p mlxcel-core --check — clean

Closes #118

Phase 1 of epic #116 (unified paged KV cache). Adds the physical main K/V tensor storage to `PagedBlockPool` plus the write/gather primitives, accounting, and unit tests, following layout A and the gather-then-SDPA strategy locked by ADR 0001. This is an additive capability that later phases consume; the live model/decode flow is intentionally not rewired here (decode-read is #119, prefill-write is #120), so in live operation the pool tensors stay unallocated, the dense placeholders still carry live K/V, and the existing paged decode path is byte-for-byte unchanged.

Storage uses layout A: each block occupies `[block_size, n_kv_heads, head_dim]` and a layer's pool tensor is `[capacity_blocks, block_size, n_kv_heads, head_dim]`, with separate K and V tensors per layer. Tensors are lazily allocated on the first write to a layer (geometry and dtype inferred from the written block via `array_shape`/`array_dtype`), so an Fp16/Int8 sequence that never writes adds zero tensor or row overhead and `PagedBlockPool::new` keeps its existing signature. A per-layer `block_id -> row` map keeps the pool tensors compact while global `PagedBlockId`s stay unique across layers; rows are layer-local, assigned lazily on first write, freed back to a per-layer free list on `release_block` (refcount 0) alongside the existing Turbo4 sidecar drop, and recycled by the next fresh block.

`write_block` assigns/looks up the row, grows the pool tensor in `POOL_GROW_CHUNK_BLOCKS` chunks when a fresh row exceeds capacity, and writes via `slice_update`, reassigning the pool tensor so MLX donates the buffer (O(block) append per ADR 0001). It accepts both the SDPA-style `[1, n_kv_heads, n_slots, head_dim]` and the bare `[n_slots, n_kv_heads, head_dim]` block layouts and validates shape/dtype against the captured per-layer geometry on every write. `gather_visible` builds the row index from the layer's block table (so fragmented/out-of-order rows gather in block-table order), takes the rows, flattens, slices the visible `[logical_start, len)` window, and transposes into `[1, n_kv_heads, visible_len, head_dim]` byte-identical to the equivalent dense contiguous buffer's visible slice. Both Fp16 and Int8 main K/V route through the pool with no astype on the K/V path (dtype preserved); Int8 quantization scales stay in the dense path.

`pool_tensor_bytes` sums real pool-tensor bytes and is added to `CachePool::memory_usage_bytes` additively; the layout-derived `reserved_bytes`/`used_bytes`/`stats_for_sequences` scheduling budgets are unchanged so the existing `nbytes_*` assertions still hold. The new `cache/paged_pool_tests.rs` proves the byte-identity acceptance criterion (contiguous), fragmented gather, partial final block, `logical_start > 0`, Int8 byte-identity, row recycle with fresh data, `pool_tensor_bytes`, and Turbo4 sidecar coexistence.

Closes #118
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:high High priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers status:review Under review labels Jun 2, 2026
@inureyes

inureyes commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

Security & Performance Review — Phase 1 block-pool storage (#118)

Reviewed scope: the three changed files (cache/paged.rs, cache.rs, cache/paged_pool_tests.rs). In-memory single-owner (&mut self) cache code with no network/parse/injection surface, so the review focused on memory-safety, donation correctness, integer-overflow/bounds, panic-safety, and stale-data on row recycle. Live-flow wiring deferred to #119/#120 is treated as in-design, not a defect.

Verdict: no CRITICAL or HIGH findings. Nothing auto-fixed (no fix warranted). PR stays at status:review.

Warm verification (all clean, no new compile/lint/test surface introduced):

  • cargo check --lib -p mlxcel-core — clean
  • cargo clippy --lib and --tests -- -D warnings — clean
  • cargo fmt --check — clean
  • cargo test --lib cache::paged — 56 passed (12 new pool tests + 44 existing; detach round-trips and nbytes_* budgets intact)

Findings

1. Unbounded growth / retention — LOW (acceptable, not a leak). The pool grows in 32-row chunks via grow_pool and never shrinks; next_row is monotonic. This is standard pool high-water retention, identical to the dense KVCache pre-allocate-and-keep discipline. Freed rows are recycled correctly: release_block pushes to free_rows, and assign_row pops free_rows before advancing next_row, so capacity is bounded by peak concurrent blocks (itself bounded by admission control, which now sees pool_tensor_bytes()). No UniquePtr<MlxArray> is leaked — every .take() is immediately followed by a reassign that consumes the old array into a slice_update graph node (freed at eval). Acceptable.

2. Donation correctness / no hidden O(pool) copy — CORRECT. write_block and grow_pool both follow the ADR 0001 donation pattern: let old = pool[i].take(); pool[i] = Some(slice_update(&old, ...)). After .take(), old is the sole Rust owner; the FFI passes const MlxArray& (no mlx::core::array copy), the lazy Slice primitive captures src.inner, and old drops before eval, so the array's refcount is 1 at eval and MLX donates the buffer in place (O(block) append). In grow_pool the freshly-zeros'd larger buffer is the donation target and old is the read-only update source — the one genuine data copy here is unavoidable on a real resize. Matches the proven dense-cache slice_update path. No lingering reference forces a full copy on the steady-state append.

3. Integer overflow / bounds — LOW (defense-in-depth only; no memory-safety risk). All hot-path arithmetic is sound: next_row += 1 and row + 1 need 2^64 blocks to wrap; grow_pool uses div_ceil + saturating_mul + .max(); growth trigger row >= capacity ⇒ grow(row+1) has correct 0-indexed boundary (no off-by-one). The only externally-arbitrary value not pre-bounded before an as i32 cast is write_block's slot_start (slot_start as i32 + n_slots): a pathological slot_start ≥ ~2^31 would wrap (debug: panic; release: wrap) and could bypass the > block_size check. Likewise n_blocks * block_size in gather_visible is a theoretical i32 product overflow at absurd context lengths. Neither can cause out-of-bounds memory access: there is no unsafe and no raw indexing — every slice/index goes through MLX, which bounds-checks at the C++ layer and throws rather than corrupting memory. Realistic callers (#119/#120 prefill/decode) always pass slot_start < block_size (default 32). Optional future hardening: validate slot_start as usize against block_size before the cast. Not blocking.

4. New unsafe — CLEAN. Zero unsafe blocks in the new code (confirmed by scan). The K/V path uses only the safe FFI wrappers take/slice_update/slice/reshape/transpose_axes/from_slice_i32/zeros/array_nbytes — never the unsafe gather/concatenate/stack variants.

5. Panic safety (.expect() in non-test code) — CORRECT. The five new .expect() calls (write_block pool_k/pool_v takes, gather_visible pool_meta, grow_pool pool_k/pool_v takes) are each guarded by an invariant established immediately prior in the same &mut self/&self method: pool_k/pool_v/pool_meta are always allocated together (the match pool_meta { None => set all three } arm), so "meta present ⇒ tensors present" and "tensors present ⇒ meta present" both hold. No interior mutability and no await points, so no scheduler/attacker-influenced interleaving can violate them. Acceptable.

6. Stale-data leak on row recycle — CORRECT (no leak path). A recycled row is not zeroed, but it cannot serve stale data: release_block removes the old block_id from block_rows, and gather_visible returns an error for any block with no row entry, so the window between re-acquire and re-write cannot gather. After re-write, the row holds fresh data. Trailing padding slots of a partially-rewritten recycled row are never exposed because gather_visible slices exactly [logical_start, len) (verified by partial_final_block_gathers_exactly_visible_len). The released_row_is_recycled_and_serves_fresh_data test confirms the full free→recycle→rewrite→gather path serves fresh bytes only.

Notes (informational, not findings)

Add `fp16_main_kv_round_trips_byte_identically` to `paged_pool_tests` to directly cover the FLOAT16 dtype-preservation claim. The test casts FP32 blocks to FP16 via `ffi::astype`, writes them through `write_block`, gathers via `gather_visible`, and asserts both the `FLOAT16` dtype on the result and raw-byte identity against a dense reference built the same way. Complements the existing INT8 test and addresses the reviewer LOW-1 note (fp16 gather path uncovered by direct test).
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 2, 2026
@inureyes
inureyes merged commit 2b4a074 into main Jun 2, 2026
5 checks passed
@inureyes
inureyes deleted the feat/118-global-block-pool-tensor-storage branch June 2, 2026 03:40
inureyes added a commit that referenced this pull request Jun 3, 2026
The 5b. ordered-list marker added in #148 tripped clippy::doc_lazy_continuation under -D warnings on a cold lint (warm-incremental clippy missed it); renumber the test-index list into a clean ordered list. Drive-by while implementing #119.
inureyes added a commit that referenced this pull request Jun 3, 2026
* feat: pooled paged decode read path over real block tables (#119)

Phase 2 of epic #116 (unified paged KV cache). Adds the Rust pooled-decode read path that gathers each sequence's visible K/V from `PagedBlockPool` (real, possibly fragmented physical block tables) instead of slicing dense compatibility buffers, following ADR 0001 strategy option A (gather-then-SDPA, reusing existing FFI with no new kernel). This is additive machinery consumed later by #121 (live scheduler wiring) and #123 (fused kernel); the live decode path stays byte-for-byte unchanged.

`paged_decode_attention_pooled_fallback` in `layers.rs` mirrors `paged_decode_attention_dense_fallback` exactly, differing only in the K/V source: it calls `PagedBlockPool::gather_visible` (which builds the `take`/`reshape`/`transpose` graph MLX fuses into the SDPA read) per batch index and feeds the result into the identical `attention_from_ptr` fused-SDPA call, then concatenates the per-sequence outputs along axis 0. `gather_visible` already returns the `[1, n_kv_heads, visible_len, head_dim]` SDPA-ready window, so no reshape/transpose is needed at the call site. `Ok(None)` (no visible tokens) is mapped to an error since decode requires a non-empty window, the pooled analogue of the dense fallback's `kv_len > 0` precondition. The path covers full-attention and sliding-window sequences uniformly: the visible window is encoded in the per-sequence block table plus `logical_start`, so there is no ring-buffer wrap and no separate rotating pooled variant.

Adds three parity tests in `ffi_tests.rs` alongside the existing dense-compat parity test. `test_pooled_paged_decode_matches_dense_over_200_steps` is the acceptance: it drives one sequence 200 steps in lockstep through the pool and a contiguous dense `[1,H,T,D]` buffer, forces the target onto non-contiguous physical rows by interleaving a spacer sequence's block writes (the pool assigns rows in first-write order, so the target lands on rows 0,2,4,...), and asserts the pooled-vs-dense output RMS stays < 5e-3 every step; with FP32 K/V/q the two paths are bit-identical so the measured max RMS is 0. `test_pooled_paged_decode_sliding_window_via_logical_start` covers a `logical_start > 0` window, and `test_pooled_paged_decode_batch_of_two` exercises the batch concat with two sequences of different kv_lens.

Deferred and intentionally not touched (consumed by later phases): the C++ `paged_decode_attention_dense_compat`/`_rotating_compat` kernels (the native/fused pooled gather is #123), and live model dispatch in `model_owned.rs`/`qwen3.rs`/`llama3.rs`/`gemma3.rs`/`llama4.rs` (routing needs the pool populated by the #120 prefill writer and routed by the #121 scheduler, so wiring it now would be dead gated code). The existing dense/rotating fallback and compat functions stay as-is as the live path and parity baseline.

Closes #119

* chore: fix doc_lazy_continuation clippy lint in paged_pool_tests

The 5b. ordered-list marker added in #148 tripped clippy::doc_lazy_continuation under -D warnings on a cold lint (warm-incremental clippy missed it); renumber the test-index list into a clean ordered list. Drive-by while implementing #119.
@inureyes inureyes added this to the 0.1 milestone Jun 21, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers 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 1: Global block-pool tensor storage

1 participant