Repository navigation
Conversation
At 261,120-token context the Flash-Next prefill completes and the request OOMs a few decode tokens later with a Metal command-buffer execution failure. The cause is the speculative-verify step: the 12 full-attention layers keep dense KV over the whole context, and a multi-row verify (q_len 3-8) at GQA 12 exceeds MLX's fused vector-attention limit of q_len * GQA <= 32, so SDPA falls to an unfused path that materializes the [24, q_len, T] fp32 score plane (linear in T) and GQA-expands k/v. Across the 12 dense layers in one verify command buffer that transient lands on top of a ~93.8 GiB steady resident and tips past the 100 GiB Metal wired limit; at 131,072 tokens the same terms are half and it fits. The QSA layers avoid this via bounded rows-gather; the dense layers cannot, and the fixed-M4 verify path gates off the tiler and rows-gather, so every prior lever was a no-op. This optimization splits the query heads of the dense-fallback SDPA into chunks small enough that each fused call satisfies q_len * heads_per_chunk <= 32, each chunk paired with its own single kv head so k/v is never GQA-expanded, then concatenates along the head axis. Every dispatch stays on the bounded fused kernel: no [24, q_len, T] plane, working set one chunk regardless of context. The heads in a chunk share one kv head, so it is bit-identical to an unchunked fused call (verified < 1e-6); versus the unfused path it replaces it is rounding-class, like mask-fuse. It covers the fixed-M4 verify, the ordinary verify, and small prefill tails; wide prefill chunks keep their single flash call. MTPLX_QWEN4_VERIFY_SDPA_HEAD_CHUNK (default on, =0 opt-out, read at use) gates it; engagement prints one log line and surfaces at /health under qwen4_install_reports.verify_sdpa_head_chunk.
After the verify SDPA head-chunk removed the O(T) score plane, the 261,120-token request still OOMs a few decode tokens in, at the same ~100.8 GB peak. The remaining ~6.4 GB is the verify's own KV-cache update. The fixed-M4 verify uses TensorOffsetKVCache, whose update_and_fetch writes the new rows with the functional mx.slice_update. That op reallocates the whole [1,2,capacity,256] buffer per key and per value tensor (267 MB each at the 262K capacity, measured), so across the 12 full-attention layers it is 6.4 GB, matching the gap: with MTP off (S=1 on the stock in-place KVCache) the same prompt fits at 94.19 GB, with MTP on and the verify running it peaks at 100.82 GB. The bank demotes long generations to the eager verify, so the served 261K verify runs that path with a concrete offset. Take an in-place path in update_and_fetch and the trim rollback restore when the offset is a concrete host value: write the S new rows with a slice assignment into the existing buffer (0 allocation, measured) after materializing an independent copy of the pre-write rows for rollback. When the offset is a tracer (inside an mx.compile trace) the compile-visible functional slice_update path is kept unchanged, so the compiled replay's donation contract is untouched. The write is byte-identical to slice_update (verified on random tensors) and the rollback snapshot is independent, so trim restores exactly the pre-write rows.
|
This would blow the fixed budget by about 6.6gb due to the copy. I have a hard limit on my mac so it would risk OOM and terminate. |
|
Agreed, and that is why the in-place write is the right shape. Copying the verify KV at that context costs about what you say, and we run under the same hard budget here, so a copy was never an option. Your three commits are already on the 2.11.3 branch with your authorship intact. I rebased them onto the release line this morning, so the hashes differ from this branch and GitHub will not mark the pull request merged on its own; I will close it when the release goes out. Your two test files pass there alongside our full suite, and on our 128 GB machine the head-chunk path shows as engaged in |
Add the cascade context-ladder decode chart (cascade_decode_by_context.svg): exact pairing and cascade alpha 0.0/0.5/1.0/2.0 across 1K-128K, fastest of seeds with min-max variance bands; 16,384 merged from the arm-G sweep; 261,120 absent (every arm OOMs on the youssofal#475 base without youssofal#482). Update the charts manifest.
cascade_decode_by_context now carries eight arms: the exact pairing, the OPT rule at alpha 0.0/0.25/0.5/0.75/1.0/2.0, and the TokenV3 rule at alpha 0.95 across 1K-128K (fastest of three seeds, min-max bands). 261,120 is absent because every arm exceeds the memory knob on the youssofal#475 base without youssofal#482. Chart and manifest only; nothing under mtplx/.
Two charts comparing this pull request's operating points against youssofal#475 and youssofal#478, both re-derived from the receipt json rather than from any table: cascade_vs_475_478_16k.svg decode at 16,384 tokens for release 2.11.2 (exact), youssofal#475 (exact), youssofal#478 (typical 0.09), OPT alpha 0.25, and TokenV3 alpha 0.75 and 0.95, each bar annotated with its own HumanEval strict pass@1 cascade_vs_475_478_ladder.svg decode against context size, 1,024 to 131,072, for the same four arms that have a full ladder Bar height and line point are the fastest seed, every band is min-max, and acceptance mode is in each label: a tok/s figure is not readable without it. The alphas of the two rules are different quantities and the captions say so. No arm carries a 261,120 point, because on the youssofal#475 base without youssofal#482 every arm on this pack exceeds the memory knob.
Two charts comparing this pull request against youssofal#475 and youssofal#478 on BOTH model packs, so the pack and the optimization set are separated rather than confounded. Both are re-derived from the receipt json: bare_vs_475_478_16k.svg decode at 16,384 tokens, the Optimized-Speed pack (release exact, youssofal#475 exact, youssofal#478 typical 0.09) beside the Bare-Speed pack (the same three, then this pull request at the exact law, at typical 0.09, at cascade OPT alpha 0.5 and at cascade TokenV3 alpha 0.95) bare_vs_475_478_ladder.svg decode against context size, 1,024 to 261,120, for youssofal#475 and youssofal#478 on both packs and this pull request on Bare-Speed The two colour families are the two packs, not the two pull requests. Bar height and line point are the fastest seed, every band is min-max, and the acceptance mode is in each label: a tok/s figure is not readable without it. At 261,120 the Bare-Speed arms carry a point and the Optimized-Speed arms do not, because the latter exceed the memory knob on the youssofal#475 base without youssofal#482.
…verflow The long-context verdict table blamed a QSA-indexer prefill transient for the 261,120-token OOM on all four arms. The prefill completes on every arm; the overflow is the first decode step's speculative-verify KV-cache write, where TensorOffsetKVCache.update_and_fetch used the functional mx.slice_update and reallocated the full per-layer KV buffers (about 6.4 GB in one command buffer). The probes W1 and W2 targeted prefill and so could not have fit. This matches the youssofal#475 body's Section 1.5 and the youssofal#482 fix arm, which fits 261,120 on all three cold seeds at 100.82 GB.
… upstream queue
…ttention for 261,120-token decode
The copy-block route writes 1 + block rows through the fixed-capacity verify cache in one eager update. When that window straddled the granted capacity, the functional slice_update clamped silently: MLX dropped the rows that did not fit while the offset still advanced past them, so the rest of the turn attended over missing KV rows (2.11.2). The in-place write from PR #482 refused the shape instead and the request failed with a 500. Grow the buffer first, as the stock KVCache does; ensure_capacity flips growth_after_grant so a granted leaf demotes rather than replaying a compiled graph over the old buffers. Seen on the 27B Optimized Quality pack, fourth turn of a six-turn coding session (40 copy blocks accepted): shapes (1,4,25,256) into 17 rows.
|
Merged for 2.11.3, your commits 69f222f and 57fa917 with their authorship kept (merge f5ae03d), thank you. 261,120-token prompts decode on Flash-Next now, and the release notes credit you. Closing here because the merge went in by patch rather than through this button. Release: https://github.com/youssofal/MTPLX/releases/tag/v2.11.3 |
Qwen3.8 Flash-Next: two verify-step memory fixes so 261,120-token prompts decode
This pull request adds two serving optimizations to the Qwen3.8 Flash-Next path. The base is upstream MTPLX 2.11.2. At the pack's advertised 261,120-token context,
mtplx servereads the whole prompt and then runs out of GPU memory in the first decode step, so the request fails after a few tokens. Both optimizations remove a per-layer allocation the speculative verify makes across the 12 full-attention layers, and both are needed: the verify's KV-cache write is what still overflows once the attention transient is gone.The two are ordered by what they remove. Optimization 1 writes the verify's KV cache in place instead of reallocating the whole buffer, which is the allocation that decides the 261,120-token cell. Optimization 2 keeps the verify attention on MLX's fused kernel, which removes an attention transient that scales with context; on its own it does not make the cell fit, and Section 3 gives its measured 261,120-token receipt.
The comparison has two arms: release 2.11.2 alone (A) and release plus both optimizations (F, this pull request). The headline is F against A at 261,120 tokens, where A exceeds the memory knob and F does not.
Headline figures:
insufficient_memoryin the first decode step; this pull request completes the request, fits ×3, at 62.84 tok/s decode (56.82-62.84) and 1082.9 tok/s prefill, peak 100.82 GB against the 107.374 GB knob.Terms used in this document:
TensorOffsetKVCacherather than the stock in-placeKVCache.1. Benchmark results
Two arms ran on one machine. Each arm received the same request body per cell. Section 6 gives the settings.
1.1 Peak memory by context
Peak memory by context size, release against this pull request. The 100 GiB cap holds on every arm that reaches a cell; release exceeds it at 261,120 tokens and this pull request does not.
insufficient_memory)1.2 261,120-token result
Decode tok/s at 261,120 tokens. Release does not reach a decode rate because it fails in the first decode step; this pull request completes the request.
Release 2.11.2 does not complete this cell. Its receipt shows the prompt fully read and only a few tokens emitted before the failure:
new_prefill_tokens261120, time to first token 243.5 s,completion_tokens12,finish_reasonerror, sampled peak 100.82 GB, with[METAL] Command buffer execution failed: Insufficient Memory. Section 2 traces the cause.This pull request completes the request in three cold windows:
The summary reports the fastest window with the min-max across the three: prefill 1082.9 tok/s (1081.5-1082.9), decode 62.84 tok/s (56.82-62.84), time to first token 242.290 s (242.290-242.631), wall 254.03 s (254.03-264.33), peak 100.82 GB (100.82-100.82). Peak stays under the 107.374 GB knob on every window, with about 6.5 GB of headroom. Completion tokens differ by seed (461-1024) because each seed stops on its own; every window reached a normal stop or the 1,024-token cap, never an error.
1.3 16,384-token no-regression check
Both optimizations engage in the verify step, which runs at every context, but 16,384 tokens never overflowed, so this cell measures that the token rate is unchanged. This pull request's arm F is release 2.11.2 plus both optimizations, so the comparison is F against release A.
For context, pull request #475's six-optimization arm reaches 82.84 tok/s fastest (76.91 mean) at this cell on the same instrument. That arm is a separate, larger change; this pull request is the memory fix alone and does not aim to move the 16K token rate.
2. The 261,120-token overflow
The prefill is not what overflows. All four arms of the earlier 261,120-token battery, release 2.11.2 (A), release plus #475 (C), and the two #478 arms (D and E), show the same shape: the full prompt is read and tokens are already emitted before the failure.
The error field is identical on all four:
The overflow is in the first decode step, which is the fixed four-row speculative verify. Two separate per-layer allocations sit in that step, both multiplied by the 12 of 48 layers that keep dense KV over the whole context (
full_attention_interval4). The model has 24 query heads over 2 key/value heads, so 12 query heads per key/value head, at head_dim 256.The attention transient. A multi-row verify has a query length of 3 to 6 rows, and 3 rows times the group factor of 12 is already past the 32-row limit of MLX's fused vector attention kernel. MLX declines the fused call, and the unfused route runs instead, materializing a
[24, S, 261120]score tensor per full-attention layer and expanding keys and values per query head. That transient scales with context.The KV-cache write, which is the binding one. The fixed-M4 verify holds its KV in
TensorOffsetKVCache, whoseupdate_and_fetchwrote the new rows with the functionalmx.slice_update(mtplx/graphbank.py:499-522before the fix, the write calls at:514for keys and:520for values). That op returns a new array: it reallocates the whole[1, 2, capacity, 256]bf16 buffer for keys and again for values, about 267 MB each at the 262K capacity, so about 6.4 GB across the 12 full-attention layers. The stockKVCacheon the plain autoregressive path writes in place and allocates nothing.Both land on a steady state that is already about 100.8 GB, against a 107.4 GB limit, so the single decode command buffer exceeds the knob. At 131,072 tokens the same terms are about half and the request fits; at 261,120 tokens they do not.
Evidence chain
Four measurements identify the KV write, in the order they were taken.
W5, native MTP off. Decode becomes plain autoregressive at query length 1: the fused kernel is eligible so no score plane is built, and the stock in-place
KVCacheruns instead ofTensorOffsetKVCache. That configuration fits the same 261,120-token prompt at 94.19 GB and decodes 547 tokens to a normalstop, time to first token 223.6 s. With MTP on and the verify running, the peak is 100.82 GB. The 6.4 GB gap is what the verify adds, and turning MTP off removes both candidate terms at once, so on its own it does not say which. Receipt:battery475/receipts/arm-C-w5-nomtp/.Head chunk alone. Removing only the attention transient left the peak unchanged at 100.82 GB and the request still failed. That isolates the remaining 6.4 GB to something other than attention. Section 3, "What did not fix it".
The allocation probe. A direct probe of
TensorOffsetKVCacheat this geometry (context 261,120, capacity 261,128, 12 layers, 5 verify rows) measures one KV buffer at 0.27 GB, resident KV across keys, values and the 12 full-attention layers at 6.42 GB, and, with the in-place write, an update transient above resident of 0.00 GB. Unfixed, that transient is the full 6.4 GB again, becausemx.slice_updatereturns a new buffer per tensor per layer. Log:battery475/kvprobe/kvprobe-20260908T005614Z.log.The fix arm. With the in-place write, the same prompt fits on all three cold seeds at a 100.82 GB peak, 6.5 GB under the 107.374 GB knob, with MTP on and the verify running. Section 1.2.
The probe's 6.42 GB resident and 0.00 GB transient, against the 6.4 GB gap W5 exposed, is the arithmetic that closes the chain.
The served log records the refusal verbatim, one line per shape class:
The refused shape classes observed at this cell are query length 3, 4, 5 and 6. The wide 256-row prefill chunks still fuse, so prefill is unaffected; the served log shows
fused SDPA for shape class [causal-mask q_len 256 x GQA 12 at head_dim 256 bfloat16]in the same run. The QSA sparse layers avoid the transient through their own bounded row selection; the 12 dense full-attention layers cannot, and the fixed four-row verify path additionally turns off the score tiler and the rows-gather route, which is why lowering the allocator cache limit, turning the session bank off, and the earlier score-tiler probe were each no-ops at this cell. The full analysis trail is inover100-reports/pr-bodies/261k-correction-report.mdandover100-reports/oom255k/report.md.3. What the optimizations do
MTPLX_QWEN4_VERIFY_SDPA_HEAD_CHUNK1. In-place verify KV write (the fix)
TensorOffsetKVCache.update_and_fetchwrote the verify's new KV rows with the functionalmx.slice_update, which returns a new array rather than writing into the existing one. At the 262K capacity that reallocates a[1, 2, capacity, 256]bf16 buffer, about 267 MB, once for keys and once for values, in each of the 12 full-attention layers: about 6.4 GB inside one verify command buffer. The stockKVCacheused by plain autoregressive decode writes in place and allocates nothing, which is why the same prompt fits with native MTP off.update_and_fetch, and the trim rollback restore, take an in-place path when the cache offset is a concrete host value: the S new rows are written with a slice assignment into the buffer that already exists, after materializing an independent copy of the pre-write rows for rollback. The offset is concrete on the eager verify path, which is the path the served 261,120-token request takes, because the bank demotes long generations to the eager verify. When the offset is a tracer, that is, inside anmx.compiletrace, the compile-visible functionalslice_updatepath is left exactly as it was, so the compiled replay's buffer-donation contract is untouched.slice_updatewould have stored, verified on random tensors, and the rollback snapshot is materialized independently before the write, sotrimrestores exactly the pre-write rows.mtplx/graphbank.py.2. Verify SDPA head chunk (
MTPLX_QWEN4_VERIFY_SDPA_HEAD_CHUNK)[24, query length, context]score tensor per layer and expands keys and values per query head. That transient scales with context.[24, query length, context]tensor is built and the working set is one chunk regardless of context. Wide prefill chunks, at query length above 32, keep their single fused call. Stock MLX, no native extension.mtplx/models/qwen4_exp.py.MTPLX_QWEN4_VERIFY_SDPA_HEAD_CHUNK=0turns it off. The key is read at use, so the server's auto-arm reaches it.Optimization 2 applies at the single dense-fallback attention call, so it covers the fixed four-row verify, the ordinary verify, and any small prefill tail chunk in the same band.
What did not fix it
Optimization 2 was measured alone at 261,120 tokens before optimization 1 existed, and it did not fit. The head chunk engaged,
GET /healthreportingverify_sdpa_head_chunk{engaged: true, q_len: 5, heads_per_chunk: 6, chunks: 4, n_kv_heads: 2}, so the[24, S, 261120]score plane was not built; the request still failed in the same place, at the same peak:Receipt:
battery475/receipts/arm-F-headchunk/arm-F-headchunk-261120-s20260829-*. A peak unchanged to two decimals, with the attention transient provably gone, is what pointed at a second per-layer allocation of about the same size and led to the KV-cache write.The same arm at 16,384 tokens is a clean no-regression: three cold windows, decode 71.11 to 71.56 tok/s, and the output is byte-identical to release on all three seeds,
text_sha256matching arm A seed for seed (ee9993ec...,eec89cc6...,1ac58119...) with the same completion-token counts. Receipt:battery475/receipts/arm-F-headchunk/arm-F-headchunk-16384-*.4. Independence from pull requests #475 and #478
This pull request is based on upstream main (MTPLX 2.11.2) and touches one function in
mtplx/models/qwen4_exp.pyand one inmtplx/graphbank.py. It does not depend on #475 (the PR 391 remainder and two exact decode optimizations) or #478 (typical acceptance), which are also based on upstream main. It fixes the 261,120-token cell that both of those pull requests report as exceeding the memory knob. The three can land in any order; if #475 or #478 land first, these changes apply on top with no conflict, since they touch one attention call and one KV-cache write that the others do not.5. Quality and tests
Optimization 1 is exact: the in-place write stores the bytes
slice_updatewould have stored. Optimization 2 is rounding-class against the unfused path it replaces and bit-identical to an unchunked fused call. Correctness rests on the equivalence tests below plus the repo's code-eval gate.CPU tests in
tests/test_tensoroffset_kv_inplace.py:trimrestores the pre-write rows;mx.slice_updatenumerically;CPU tests in
tests/test_qwen4_verify_sdpa_head_chunk.py:[24, query length, context]tensor is ever built and keys and values are never expanded;/health.The existing
tests/test_health_qwen4_install_reports.pystill passes with the receipt added.Optimization 2's engagement is observable without the serve log. On first engagement the server prints
[mtplx] verify SDPA head-chunked: q_len=S heads_per_chunk=H chunks=N, andGET /healthcarriesqwen4_install_reports.verify_sdpa_head_chunkwith the query length, heads-per-chunk, chunk count and key/value head count once it has fired. Optimization 1 has no receipt of its own; it is observable as the peak-memory drop at long context and is covered by its CPU tests.6. Benchmark method
Youssofal/Qwen3.8-Flash-Next-MTPLX-Optimized-Speed, revision29ba90f82124961d0d902a9ea9bbb1034972af2fmtplx serveselects for this pack; a fresh SSD session cache directory per windowxhigh7. How to run and how to disable
Serve the pack; the server arms both optimizations by default:
mtplx serve \ --model ~/.mtplx/models/Youssofal--Qwen3.8-Flash-Next-MTPLX-Optimized-Speed \ --model-id mtplx-flash-next-optimized-speedRead
GET /healthand confirmqwen4_install_reports.verify_sdpa_head_chunkonce a long-context request has decoded, or watch the serve log for[mtplx] verify SDPA head-chunked:. The in-place KV write has no key and no receipt; it shows up as the long-context peak-memory drop.Turn optimization 2 off with its key set to
0:8. File map and provenance
mtplx/graphbank.py(optimization 1),mtplx/models/qwen4_exp.py(optimization 2)mtplx/server/openai.py(the/healthinstall receipt)tests/test_tensoroffset_kv_inplace.py,tests/test_qwen4_verify_sdpa_head_chunk.pydocs/perf/qwen38-longctx-prefill-memory.md; analysis trailover100-reports/oom255k/report.mdandover100-reports/pr-bodies/261k-correction-report.md21be78b3(MTPLX v2.11.2).perf/qwen38-longctx-prefill-memory-main@9f306e01= upstream main, then two code commits and one docs commit. The code commits:e0ebdc41"Head-chunk the small-q_len verify SDPA to keep long-context decode fused" (optimization 2);fc0d2a29"Write the fixed-M4 verify KV in place to fit long-context decode" (optimization 1, the fix).mtplx/graphbank.py+75/-16 region,mtplx/models/qwen4_exp.py+144,mtplx/server/openai.py+12, two test files (+165, +239) anddocs/perf/qwen38-longctx-prefill-memory.md.9f306e01"docs(perf): 261,120-token decode fix results, evidence chain, and receipts index" addsdocs/perf/qwen38-256k-fix/(README plus the two charts this body renders). Full SHA9f306e015073a35e0797650d7796d27d5749fe8b.