Repository navigation
Conversation
… of runtime.make_cache() A restore from RAM builds the cache with the request's cache_factory; the SSD fallback (_restore_cold) ignored it and built the runtime's default layout, the paged KV cache for a long context: about 5x slower prefill and slower decode after every restore from disk. restore() now hands both factories to _restore_cold, which uses them exactly as the RAM restore does (meta state not imposed on the request's cache). Without a factory nothing changes.
…kernel, bank and admission
…he merge; real-kernel test
…ts instead of a gather; prefill route only above 32 rows; route counters per window size
…s storage (MLX copied the strided view on every kernel launch)
…rmat of the same rows (no history gather), guards behind MTPLX_SEGMENTED_KV_SSD
…o on the segment), /health block, SSD counters
…ts hold (nothing merged before: every turn's snapshot holds its segments)
…ith segments too (the fix itself is the base commit)
…attention not hooked, no flash route) keep the stock cache, log one line and report the reason on /health
…defensive lse fallback, tests (kernel tests pending a power-connected run)
…lx-lm Qwen3/Llama attention has no head_dim attribute)
…e hook body on a segmented cache (it would gather the history on every call otherwise); support check requires the layers to be reachable by that body
…ken, no context fence, no MIN_TOKENS); single-segment calls take the stock route on a zero-copy view instead of counting as gathers; several segments take the kernels at any length
…also holds on stock MLX
…ill over segments then gathers the history)
…(no NOMASK parameter); the segment kernel switches the mask off in its own derived source The NOMASK template parameter changed the stock kernel's source and instantiation name without a function: _fused_source replaced the expression anyway. It now replaces the stock `gp <= row_limit` directly by `(nomask_seg || gp <= row_limit)`, so the file the switch-off path runs is byte-identical to upstream. The kernel limits are named constants (MAX_M_ROWS = 32, MAX_Q_LEN = 10) shared with the route gate, and the docstrings no longer point at a gather_attention that does not exist.
…1 and 2 gathered windows of 11 to 32 rows) The sub-window size was 32 // gqa: 32 rows at GQA 1 and 16 at GQA 2, both above the kernel's q_len <= 10, so segments_supported refused and the window gathered the history. It is now min(MAX_Q_LEN, MAX_M_ROWS // gqa). The measured models (GQA 4, 6, 8) were not affected. Tests: the sub-window sizes at GQA 1 to 8 (CPU) and GQA 1 and 2 windows of 12 and 32 rows against an fp32 reference on the kernel (GPU). Docstring of decode_segments_attention describes the current route (1 to 32 rows, sub-windows, when the stock ladder runs).
…runtime load starts without one segmented_kv_enabled() was True while no model had been checked. The verdict is made in configure_split_full_attention, which the Laguna load route skips: such a model got segment caches without the split-attention hook, and its attention gathered every segment on every call. Without a verdict the switch now behaves as off (stock caches, bank and SSD path), /health reports model_not_checked, and runtime.load resets the verdict so a model never inherits the previous load's. The test that encoded the old behaviour now asserts the new one; the other segmented test modules give their caches a supported verdict, as a real load does. The SSD-restore test checks that a request's factory builds segment caches with the switch on.
…, one helper each for unique bytes and env switches, flag-off paths skip the segment walks - Reference counting (KVSegment holders, hold/release, refcount) steered nothing since the merge also merges held segments; removed with its test. Segments are shared by plain references; the module docstring says so. - Unused in production and removed: SegmentedKVState.prefix/segments/__len__, _prefix_refs, SegmentedKVCache.tail_rows/stats() and its per-cache counters (route_counts has them), compact(max_segments=), is_segmented, the _mtplx_segmentable tag. - "Unique bytes over segment id" lives in one place (unique_state_bytes over _unique_segment_bytes), used by the bank's overcount and /health; SegmentedKVCache.gather goes through _gather_refs. - adapt_layer_for_restore(entry, state) -> state; restore_cache reads the switch once per call instead of importing and reading it per layer. - One _env_on() for the module's switches; attention_split asks segmented_kv_requested(); the route counter is public (count_route) and the codec uses its own _count_ssd everywhere; a known segment block and a reuse hit share one block entry in the codec. - Constants instead of literals (VERIFY_WINDOW_MAX in the counter label, MAX_FUSED_SEGMENTS as the default segment limit), and one sentence on why small segmented tensors are concatenated in the codec. - With the switch off, SessionBank.total_nbytes and the admission no longer walk the snapshot trees for segments. - The state getter documents its side effect (it seals the tail). - Tests: the test of the never-shipped MIN_TOKENS setting is gone, the merge test no longer says "unshared", counters are read from route_counts; ruff I001 (attention_split imports, a test) and RUF059 fixed.
…behaviour only (no 32K fence history, windows 1 to 32 rows)
…t-attention hook mlx-lm's llama Attention has no q/k norm, so the hook did not take it and the support check reported attention_not_hooked (Mistral 7B v0.3 kept the stock cache). The hook body now serves an ungated attention with or without q/k norm, on a segmented cache only; with a stock cache the forward is mlx-lm's own, switch on or off. Tests: detection of the three forms, flag-off and stock-cache identity (with and without bias), support verdict, and a GPU test at Mistral shapes (32/8 heads, head_dim 128) through turns and rollback with no gather.
…hes only, the self-check probes the segment kernel Segments only engaged with MTPLX_GQA_PACKED_SDPA and MTPLX_NAX_FLASH_ROUTE on, which only the turbo profile sets; under sustained (the recommended profile of Qwen3.6-35B-A3B and Apodex-1.1-mini) the support check switched them off. Two holes besides: the verdict was made before the kernel self-check, so a self-check that disabled the kernel left segmented caches that gathered the history on every multi-segment call; and under a profile without an attention route the hook sent a segmented cache to the stock forward (a gather per call). - Verdict: neither switch is required; either one set to 0 still switches segments off (gqa_packed_sdpa_off, nax_flash_route_off). nax_unavailable stays (M1 to M4: off). - Hook: a segmented cache always takes the segment route, whatever the profile. - Self-check: runs whenever MTPLX_SEGMENTED_KV is on and probes the segment kernel (lane segmented_kv_sdpa, two segments against stock SDPA) at the model's head_dim and GQA factor. A failure, or a self-check that cannot run, turns the verdict off before the first cache is built: stock caches, reason segment_kernel_selfcheck_failed on /health, one log line. With the switch off nothing changes: every path sits behind segmented_kv_requested() or a segmented cache. Tests: verdict with the switches unset, on and 0; self-check probe shapes, failure (mismatch, raise, bail, no run) to stock caches; hook routing without the switches for gated, q/k-norm and Llama-style attention; flag off leaves the self-check and verdict alone; GPU: the segment kernel serves without the switches, no gather, and the probe passes on the real kernel at head_dim 128 and 256.
… kill switches, self-check on the segment kernel, Qwen3.6-35B-A3B sustained numbers)
…egments instead of repaging Past the dense-decode ceiling (auto: 15 % of RAM over 64 KiB a token, 157,286 tokens on a 64 GB Mac) the auto layout repaged the prefill cache into VllmMetalPagedKVCache, which is built from each layer's ``keys``: on a segment cache that gathers every segment into one paged copy of the history. The admission priced that copy correctly and refused the turn. 2026-10-10, Qwen3.8-27B, 64 GB: a follow-up turn of 161,835 tokens (161,751 restored, 84 new) was priced 11.6 GiB of growth for the repage (repage_copy_bytes = (prompt + 16,387 reserved rows) x 68 KiB) and refused with 10.1 GiB free, eleven retries in a row; the turn before it had decoded from 157,089 to 161,751 tokens on segments at 19 tok/s. With segments on (and KV unquantized) the auto layout is contiguous_dense_decode at any length, so the restore keeps its segments, nothing repages and the admission prices the new rows only (about 1.1 GiB for that turn). The stock cache, quantized KV and an explicit MTPLX_SUSTAINED_PREFILL_LAYOUT keep their repage.
…ll before release, youssofal#617 waits); slow cached requests report, VONDSTEN 128-129 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…present, keep return_lse as fallback All fused logsumexp calls go through sdpa_with_lse; the probe records which API answered and /health reports it as lse_api. The per-segment merge and the reference route no longer produce NaN for rows without a visible key (lse -inf): such rows get weight 0 and merge to zeros.
…ce after the merge The reference route rounded every segment's output to the query dtype before the logsumexp merge, and the merge rounded again. On Qwen3.8-27B tensors at 20K (one verify window, 16 layers) that put 26 to 29% of the attention elements one ulp away from the exact result rounded once, against 15 to 22% for the stock kernel and 3.5 to 7% with a single rounding. The per-segment outputs now stay float32 and merge_segment_outputs rounds once to the query dtype (MTPLX_SEGMENTED_KV_PREFILL=lse, reference route; the fused sdpa_with_lse kernel still returns the query dtype per segment). The existing exactness test compared bfloat16 output with a loose tolerance and could not see the second rounding; the new tests require the route to equal the float32 attention rounded once and to be no further from stock SDPA than that exact result is.
…l when the build offers it The fused lse prefill route still rounded twice: the kernel returned each segment's output in the query dtype and merge_segment_outputs rounded again. Our MLX build (fork branch prod-lse-f32out) can return the output in float32 next to the logsumexp (scaled_dot_product_attention(..., return_lse=True, float32_output=True)). sdpa_with_lse takes a float32_output flag, sdpa_lse_float32_output_available() probes the option once per process (a bfloat16 chunk of 16 rows must come back float32), and the kernel route uses it, so the merge rounds once to the query dtype like the reference route. Without the option the behaviour is unchanged. /health reports lse_float32_output.
…_dot_product_attention_with_lse scaled_dot_product_attention_with_lse returns the per-segment output in float32 for bfloat16/float16 queries (and the lse in float32), so the merge rounds once with no probing. sdpa_with_lse prefers that function and falls back to scaled_dot_product_attention(return_lse=True), whose output keeps the query dtype (the earlier behaviour, rounding a second time in the merge). Removes the float32_output keyword and its availability probe. Tests: the route rounds once only with the function (mocked kernels), and a real-kernel test compares the fused route with the same route rounded per segment. The mocked plumbing test hides the function so its keyword fake is used on builds that have it.
…_product_attention_with_lse
|
A note on how this PR meets #593 and #594. The segment kernel is derived at import from the source of the head-dim-split kernel. #593 (wide verify windows) and #594 (quarter head-dim split) turn the row blocks per threadgroup ( #639 holds that coupling. It is stacked on this PR, #593 and #594, so none of the three has to know about the others. Once those are merged, its diff is only the segment-kernel launch ( |
…for the rest Three changes to how a spill of segmented rows finds blocks already on disk: - A merge of two sealed segments keeps both segments' block digests at the shifted rows (the merged rows are a copy). Merges dropped them, so every block of a merged segment was evaluated, copied and hashed again on the next spill, only to be found on disk. - A block a segment names is referenced before any fingerprint is taken, and the fingerprints of segmented rows are taken per run of blocks that need one, the first time one does. With an empty memo a block inside one sealed segment needs none: its segment names it once captured. A session's first spill would otherwise read its whole history on the GPU for fingerprints nothing can match. - A block across two segments is never named by a segment; it is referenced through the incremental memo when one is given (confirmed by known_blob like a segment hit), and a segment remembers a memo hit inside it for the next spill.
Why
A follow-up turn copies the whole conversation. The session bank keeps a snapshot of each layer's KV buffer, and that snapshot shares memory with the live buffer, so the first write of the next turn triggers a full copy: about 5 GB at 80K tokens on a 27B model, on top of what the turn itself needs. That copy is what pushes long conversations into 507 refusals and evictions sooner. A clone or lease restore, a fork, or a return to an earlier turn pays the same copy again.
How it works, in plain terms
Instead of one growing buffer per layer, a conversation is kept as a list of segments: one immutable segment per finished turn, plus a tail that grows during the current turn. A snapshot, a fork or a return to an earlier turn is then a list of references to segments that already exist, so nothing is copied. Attention reads all segments in one kernel launch and merges the partial results with their log-sum-exp, which gives the same result as attention over one buffer, within rounding. Small recent segments are merged now and then, so the list stays short (3 to 5 segments over 12 turns at 50K). On SSD a segmented conversation is written in exactly the existing block format, so dedupe, crash safety and old entries keep working, with the switch on or off.
Past the dense-decode ceiling. Without segments, a request's dense KV cache is one contiguous slab, so the auto layout caps it at a share of RAM (
MTPLX_DENSE_DECODE_RAM_PERCENT, default 15 %) and repages the cache to the paged layout past that ceiling. With segments there is no single slab: each turn's segment is allocated on its own, so the reason for the ceiling is gone. Repaging a segmented cache is worse than useless: the paged cache is built from the concatenated segments, a full copy of the history at the moment memory is tightest, and the admission rightly prices that copy, so a follow-up turn just past the ceiling was refused with a 507 although it added only a few rows. With the switch on and an unquantized cache the auto layout now stays dense; the stock cache, a quantized cache and an explicitMTPLX_SUSTAINED_PREFILL_LAYOUTkeep their repage.What changes
Everything is behind
MTPLX_SEGMENTED_KV=1; with the switch off nothing changes.mtplx/segmented_kv.py: the segment cache, sealing per turn, tiered merging, snapshots and rollback without copies,/healthcounters.mtplx/kernels/sdpa_segmented.py: a verify/decode kernel that reads up to 12 segments in one launch (head_dim 256 and 128), derived from the stock dsplit kernel without changing it; windows above 10 rows run as sub-windows.mtplx/attention_split.py: the attention hook routes a segmented cache to that kernel for gated, q/k-norm and Llama-style attention; one segment takes the stock route on a zero-copy view.mtplx/session_bank.py,cache_bank/codec.py,cold_tier.py: snapshots hold segments; the SSD writer reads segments as one virtual buffer and hashes each sealed block once.mtplx/kernel_selfcheck.py: when the switch is on, the startup self-check tests the segment kernel at the model's shape; a failure means the stock cache, with the reason on/health./health; a model loaded without a check is treated as off.MTPLX_GQA_PACKED_SDPA=0orMTPLX_NAX_FLASH_ROUTE=0act as kill switches; the feature works under every profile.mx.fast.scaled_dot_product_attention_with_lse, or thereturn_lse=Truekeyword of a patched build; neither is in released MLX yet); otherwise it gathers the history for that chunk (about 5 % slower prefill on follow-up turns, decode and verify unaffected) and logs one line. Rows without a visible key (lse -inf) merge to zeros instead of NaN. The reference route (and soMTPLX_SEGMENTED_KV_PREFILL=lsewithout the MLX kernel) keeps each segment's output in float32 and the merge rounds once to the query dtype, instead of rounding every segment to bf16 first; the fused kernel still returns the query dtype per segment.mtplx/generation.py(_sustained_prefill_layout): with segments on and an unquantized cache, the auto layout stayscontiguous_dense_decodepast the dense-decode ceiling.Results
Qwen3.8-27B, four follow-up turns, median of 3, segments (eager verify) against the stock cache (compiled verify):
Qwen3.6-35B-A3B under the
sustainedprofile (only the 10 attention layers are segmented): 24 to 30 % less time per verify, peak per follow-up turn 0.5 to 0.8 GiB instead of 2.7 to 3.3, prefill unchanged. Mistral 7B v0.3: peak per follow-up turn 0.7 to 1.7 GiB instead of 3.2 to 7.0, speed equal. Greedy output is identical to the stock cache except where two tokens tie within one bf16 step.Tests
Nine test files (
tests/test_segmented_kv*.py,tests/test_sdpa_segmented*.py): the cache against a plain buffer through turns, forks and rollback; the kernels against stock SDPA for 1 to 13 segments, head_dim 128 and 256, GQA 1 to 8; bank, SSD round trip in both directions, merging, support verdicts, self-check failure, and that no call gathers the history. Full suite passes.Notes
Stacked on #608 (the SSD restore builds the request's cache layout); this PR shrinks by that commit once #608 is merged. Apple M5 only: on M1 to M4 the kernel is unavailable and the feature stays off. Gemma 4 is not covered yet (it stays off).
With an MLX build that has
mx.fast.scaled_dot_product_attention_with_lse, the lse route takes the per-segment output in float32 from that function and the merge rounds once. The last three commits add the docstring for this and two tests: the route rounds once only with the function, and the real function is closer to the exact attention than rounding every segment first. They also make two existing tests independent of the build, because they mocked only thereturn_lsekeyword. Taken together they change no code.