Skip to content

Segmented KV on Macs without tensor units (M1-M4): per-segment attention with a logsumexp merge - #630

Open
jvmenen wants to merge 37 commits into
youssofal:mainfrom
jvmenen:feat/segmented-kv-without-nax
Open

jvmenen wants to merge 37 commits into
youssofal:mainfrom
jvmenen:feat/segmented-kv-without-nax

Conversation

@jvmenen

@jvmenen jvmenen commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #610 (segmented KV cache). This branch contains #610's commits; please review it after #610. The change itself is the last three commits.

What it changes

Segmented KV (MTPLX_SEGMENTED_KV=1) currently needs the tensor units of an M5-class GPU. On M1 to M4 the support check reports nax_unavailable and keeps the stock cache. This PR adds a second decode route so segments also work there. MTPLX_SEGMENTED_KV_DECODE=lse selects it by hand, and MTPLX_FORCE_GPU_FAMILY_FALLBACK=1 forces it on an M5 for testing. The M5 default is unchanged.

Why

Segments keep a follow-up turn from copying the whole history, which lowers peak memory, often by several GiB at long context. M1 to M4 Macs usually have less RAM, so they gain the most. The speed on those Macs is not known; users can try it and choose.

How it works

Without the tensor units there is no fused segment kernel. Instead, decode runs attention over each segment separately in float32 and also keeps each segment's logsumexp (how much softmax weight that segment carries). One exact merge then combines the pieces into the same result as attending over the whole history. The per-segment outputs stay float32 and the merged result is rounded to bf16 once, like a single attention call. A cache with a single segment still takes the stock route, prefill is unchanged, and rows that see no key at all get weight 0 instead of NaN. /health shows decode_route and lse_api.

Tested

Measured (M5 with forced fallback, NOT real M1-M4 speed)

Mean of the two runs per variant. Peak is GPU memory above the memory at the start of the turn.

Context Turn tok/s off tok/s on Peak above start off (GiB) on (GiB)
20K 1 23.6 19.4 3.78 1.64
20K 2 29.1 20.1 4.04 1.22
20K 3 20.9 15.7 4.18 2.02
20K 4 24.8 18.9 5.56 2.16
50K 1 24.6 17.0 6.92 1.79
50K 2 21.4 14.6 5.94 2.05
50K 3 23.4 17.8 6.08 2.48
50K 4 18.4 10.6 7.49 2.76

Parity with the stock cache

Greedy output is not token-identical between off and on; each is deterministic run to run. I checked whether that is a bug or rounding, on real Qwen3.8 27B tensors (M5, forced fallback):

  • Attention output (one 4-row verify window, all 16 attention layers, 3 segments): after the last commit, 12 to 23 % of the bf16 elements differ from the stock kernel's output. The exact float32 result, rounded once, differs from the stock kernel in the same 12 to 23 %, so the remaining difference is the stock kernel's own rounding. For comparison, the M5 segment kernel (Segmented KV cache: follow-up turns, forks and restores without copying the history (MTPLX_SEGMENTED_KV) #610) differs from its stock kernel in 20 to 33 %.
  • The fix (now in Segmented KV cache: follow-up turns, forks and restores without copying the history (MTPLX_SEGMENTED_KV) #610, which this PR includes): each segment's output was rounded to bf16 before the merge, and the merged result was rounded again. That double rounding put 24 to 34 % of the elements one ulp away from stock. The per-segment outputs now stay float32. The fix lives in Segmented KV cache: follow-up turns, forks and restores without copying the history (MTPLX_SEGMENTED_KV) #610's reference route and merge, so its prefill lse route benefits too; this PR keeps the decode-route tests. A test requires the route to equal the float32 attention rounded once (the old 2e-2 tolerance could not see this) and to be no further from stock SDPA than the exact result.
  • Logits with forced identical tokens (200 reference tokens, windows of 4): the mean |logit difference| from stock is 0.035 to 0.048. The M5 segment kernel gives 0.040, and the two stock kernels (fallback vs tensor units) differ from each other by 0.041 on the same tokens. Top-1 differs in 1 to 4 of 199 rows, always where the stock top-2 margin is 0 or 0.125 (a bf16 tie or one ulp).
  • Greedy runs (20K and 50K, 5 turns each): every first divergence, before and after the fix, is at a bf16 tie or one to two ulps (margins 0, 0.0625 or 0.125, checked by recomputing the stock logits at that position). That matches the M5 kernel route in Segmented KV cache: follow-up turns, forks and restores without copying the history (MTPLX_SEGMENTED_KV) #610 (margins 0.0 / 0.125). This text has many exact ties (5 of 199 rows at 20K have margin 0), so no route that sums in a different order can be token-identical to stock.

The fix does not change speed or memory measurably (ms per verify within run-to-run noise, same peak).

Known risk

Per-segment float32 attention costs more than the fused kernel, and the cost grows with context and segment count. On the M5 it ran at 0.58 to 0.82 of the stock cache's tok/s. On M1 to M4 the stock route is slower too, so the ratio there is unknown.

Please test on M1-M4

If you have an M1-M4 Mac, please post decode tok/s and peak memory with MTPLX_SEGMENTED_KV=1 vs off, for example:

# off
mtplx serve --model <model>
# on
MTPLX_SEGMENTED_KV=1 mtplx serve --model <model>

Send a long prompt (20K+ tokens) and a few follow-up turns in the same conversation, then post the decode tok/s from the dashboard (or the response timings) and the peak memory per turn, plus the decode_route value from /health.

Jeroen van Menen added 30 commits October 9, 2026 16:11
… 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.
…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
…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)
Jeroen van Menen added 3 commits October 10, 2026 09:39
…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.
…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.
@jvmenen
jvmenen requested a review from youssofal as a code owner October 13, 2026 13:54
jvmenen pushed a commit to jvmenen/MTPLX that referenced this pull request Oct 14, 2026
…incl. single-rounding fix), 134 (youssofal#629), new 135 (fused lse prefill rounds twice)
Jeroen van Menen and others added 4 commits October 14, 2026 06:47
…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.
… a logsumexp merge

The segment kernel needs the Metal 4 tensor units of an M5-class GPU. On M1 to M4
the support check reported nax_unavailable and kept the stock cache. Add a decode
route that attends per segment (exact float32 attention with its logsumexp, then
one exact merge), selected automatically without tensor units or with
MTPLX_SEGMENTED_KV_DECODE=lse. The memory gain is the same; the speed is not
measured on those Macs yet.
…uery dtype

The reference route and the merge now keep per-segment outputs in float32
and round once (segmented KV lse prefill route); the decode route on Macs
without tensor units picks that up. Document it in the decode route's
docstring, and add the equivalent exactness tests for decode and verify
windows: the result equals the float32 attention rounded once and is no
further from stock SDPA than that exact result is.
@jvmenen
jvmenen force-pushed the feat/segmented-kv-without-nax branch from 2789ab6 to 4fb7480 Compare October 14, 2026 04:51
jvmenen pushed a commit to jvmenen/MTPLX that referenced this pull request Oct 14, 2026
jvmenen pushed a commit to jvmenen/MTPLX that referenced this pull request Oct 17, 2026
The segment-kernel coupling with youssofal#593/youssofal#594 is on prod since cb017d5; youssofal#639 now
holds it upstream. This brings the docstrings and the two hd128 tests that pin
MTPLX_NAX_FLASH_WIDE off to the youssofal#639 + youssofal#630 text, so prod carries exactly what
the PRs hold. No code change (AST-identical).

This branch has not been deployed

No deployments
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