Skip to content

fix: adopt partially matched paged prefixes instead of declining#230

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-225-paged-partial-adopt
Jun 12, 2026
Merged

fix: adopt partially matched paged prefixes instead of declining#230
inureyes merged 2 commits into
mainfrom
fix/issue-225-paged-partial-adopt

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Implements #225. try_adopt_cached_prefix declined every paged adoption whose matched_len fell short of the stored entry, so the default backend (auto resolves to paged under batching) never reused divergent-sibling prefixes, and with --apc-enabled the APC block clamp made even strict multiturn continuation miss (measured: multiturn cached tokens 11067 with APC off, 0 with APC on).

  • The scheduler's paged arm now floors matched_len to the pool block boundary and trims the detached set before adopting. Flooring means no partially filled tail block survives, so the suffix re-prefill always starts on a fresh block and needs no copy-on-write against a shared tail. Whole-entry matches skip the trim and stay bit-exact with the old path.
  • New CachePool::trim_detached_paged_to: releases BOTH references of every dropped tail block (the block-table allocation and the detach pin, mirroring release_detached_paged), clamps the dense-handle offsets and bookkeeping lengths so the subsequent adopt restores RoPE at the trimmed length, and rejects Turbo4 sidecar layouts and sliding-window (logical_start > 0) states. Release failures are collected and reported after the trim finishes so the set's bookkeeping never goes inconsistent halfway; the caller then releases the remaining pins and falls back to a cold prefill.
  • The block-floored length is also screened against the store's min_prefix_tokens and reported as the adopted length in logs, observability, and the scheduler return value.

Validation

  • New real-model gates paged_partial_prefix_share_parity (qwen3 + llama3): a store with APC enabled clamps a 52-token entry match to 48 tokens, the scheduler mirror floors to 32, trims, adopts (B keeps A's first physical block, drops the second), re-prefills the rest, and decodes byte-identically to a cold run.
  • New unit tests: the trim fully releases the dropped tail block (pool live count drops), the trimmed set adopts and gathers byte-identically against a dense reference, and misaligned or oversized targets are rejected.
  • HTTP end-to-end (llama-3.2-1b, --apc-enabled, default paged backend, scripts/bench_memory_footprint.py): burst cached tokens 0 to 3648, seqshare 0 to 25536, multiturn 0 to 10976, matching the dense backend's numbers; seqshare footprint peak 1412 MiB vs 1507 MiB on dense; wall times within run noise.
  • Existing whole-entry parity gates stay green and byte-identical: paged_prefix_share_parity, paged_scheduler_parity, paged_real_model_parity.
  • 3150 server lib tests, 868 core lib tests (one pre-existing release-mode pack3 should-panic failure also present on main, unrelated), cold clippy clean, fmt clean.

Concurrent siblings are still limited by the one-shot take_detached (only the first adopter wins); that is #227.

Closes #225

inureyes added 2 commits June 12, 2026 12:05
try_adopt_cached_prefix declined every paged adoption whose matched_len
fell short of the stored entry, so the default backend (auto -> paged
under batching) never reused divergent-sibling prefixes, and with
--apc-enabled the APC block clamp made even strict multiturn
continuation miss (measured: multiturn cached 11067 -> 0).

The paged arm now floors matched_len to the pool block boundary and
trims the detached set before adopting: the new
CachePool::trim_detached_paged_to releases both references of every
dropped tail block (allocation + detach pin, mirroring
release_detached_paged), clamps the dense-handle offsets and
bookkeeping lengths so adopt restores RoPE at the trimmed length, and
rejects Turbo4 sidecar layouts and sliding-window states. Flooring to
the pool block boundary means no partially filled tail block survives,
so the suffix re-prefill always starts on a fresh block and needs no
copy-on-write. Whole-entry matches skip the trim and stay bit-exact
with the old path.

Validation:
- New real-model gates paged_partial_prefix_share_parity (qwen3 +
  llama3): store with APC on clamps a 52-token entry match to 48,
  the scheduler mirror floors to 32, trims, adopts, and decodes
  byte-identically to a cold run.
- New unit tests: trim releases the dropped block fully and the
  trimmed set adopts + gathers byte-identically; misaligned and
  oversized targets are rejected.
- HTTP end-to-end (llama-3.2-1b, --apc-enabled, default paged):
  burst cached 0 -> 3648 tokens, seqshare 0 -> 25536, multiturn
  0 -> 10976, matching the dense backend; seqshare footprint peak
  1412 MiB vs dense 1507 MiB; wall times within noise.
- 868 core lib tests (one pre-existing release-mode pack3 failure on
  main, unrelated), 3150 server lib tests, cold clippy clean.
@inureyes
inureyes merged commit 95df7cb into main Jun 12, 2026
5 checks passed
@inureyes
inureyes deleted the fix/issue-225-paged-partial-adopt branch June 12, 2026 03:20
@inureyes inureyes added this to the 0.2 milestone Jun 21, 2026
sjswerdloff added a commit to sjswerdloff/mlxcel that referenced this pull request Jul 3, 2026
…q_len

Two tests introduced upstream in lablup#230 (95df7cb) and lablup#231 (d402468) panic
at `paged_detach.rs:1069`:

    debug_assert!(handle.offset >= target_tokens as i32);
    handle.offset = target_tokens as i32;

The assertion's comment claims a freshly detached pool-backed handle
"always satisfies offset == seq_len > target_tokens". This is false.

For a paged sequence, K/V data lives in the shared block pool. The
dense-side handles (`sequence.caches[]`) are metadata placeholders whose
`offset` field is NEVER advanced during writes — `write_prefill` touches
the paged state, not the dense-cache counter. When `detach_paged` builds
the set via `sequence.caches.iter_mut().map(|c| c.clone_handle())`, each
handle's offset is 0. `trim_detached_paged_to(&mut set, 8)` then hits
`debug_assert!(0 >= 8)` and panics.

The panic was masking, not caused by, the design. The line immediately
below the assertion — `handle.offset = target_tokens as i32` — is the
correct behavior: re-anchor the dense-side metadata offset to the
trimmed length so a later adopt resumes RoPE at the prefix boundary.
This mirrors `clone_detached_paged_prefix`'s
`pool_backed_handle_clone(target_tokens as i32)` (line 885).

Verified empirically: the two tests fail identically at 95df7cb (their
birth commit), i.e. they never passed on upstream main. Not a fork
regression.

Fix: rewrite the comment to describe actual behavior and tighten the
assertion to catch what would genuinely be corruption
(0 < offset < target_tokens — a partially-advanced handle) while
allowing both legitimate states:

  * Pool-backed handle: offset == 0 (dense K/V lives in the pool)
  * Dense-backed handle (Int8 dense-compat): offset >= seq_len

Tests:
  cache::paged_detach::tests::trim_detached_paged_to_enables_partial_prefix_adoption
  cache::paged_detach::tests::detached_paged_set_accounts_real_pool_bytes
Both now pass. Full paged_detach::tests suite: 35/35 green.
Full cache::paged: 87/87 green. Broader `cache` selector: 449/449 green.

Note: running `cargo test cache::` (all 925 cache tests) now segfaults
at aggregate teardown *after* all tests complete. Pre-fix this suite
reported clean 413/2 because the two panics leaked resources in a way
that MLX FFI teardown tolerated; the fix un-masks a downstream
order-dependent FFI teardown issue. Filed as follow-up.

Co-Authored-By: clement-7074f29f <clement-7074f29f@sjstargetedsolutions.co.nz>
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.

fix(server): paged backend declines every partial prefix adoption, and APC+paged loses even multiturn reuse

1 participant