Skip to content

fix(cuda/attention): bound long-prompt prefill memory for flash-ineligible models - #676

Merged
inureyes merged 3 commits into
mainfrom
fix/672-cuda-longprompt-chunked-attention
Jul 6, 2026
Merged

inureyes merged 3 commits into
mainfrom
fix/672-cuda-longprompt-chunked-attention

Conversation

@inureyes

@inureyes inureyes commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

On CUDA, gemma-4/gemma-3 (head_dim 256) disqualify both fused SDPA kernels (cuDNN caps head_dim at 128; sdpa_vector covers 64/96/128 with q_len < 4), so a long single-pass prefill fell to MLX's materializing fallback: O(heads*L^2) attention scores (~68 GiB at 32k for the 31b), full-sequence 262k-vocab logits plus a same-size final_logit_softcapping copy, and two dense [L, L] f32 masks together pushed GB10 past its ~121 GB budget and OOMed. A cuDNN head_dim cap bump alone was tested and is insufficient; the fix has to stop materializing.

Changes

  • Query-chunked SDPA gate (layers::attention): when a CUDA config cannot reach a fused SDPA (head_dim > 128, softcap composite, or non-f16/bf16 dtype) and the score matrix would exceed MLXCEL_ATTENTION_CHUNK_BUDGET_MB (default 1024, 0 disables), the query axis is split into near-equal chunks. Masks are row-sliced per chunk and cast to the score dtype, because MLX's fallback adds the mask without casting and an f32 mask silently promotes the masked scores and softmax to f32. The maskless causal path in causal_attention routes through the same chunking with per-chunk causal masks and K/V sliced to the causal bound.
  • Last-position LM head (LanguageModel::forward_last_logits): the single-sequence prefill in generate_streaming/generate_with_stats now requests only the sampled position's logits. The default implementation is the previous forward-plus-slice; the gemma4 override projects a single hidden row through the 262k-vocab LM head for prefills longer than 4096 tokens, dropping ~17 GiB of logits plus an equal softcap copy at 32k.
  • Flag-path prefill masks for gemma4: text-only prefills longer than 4096 tokens leave the mask pair None, routing full-attention layers through the causal flag path and sliding layers through the over-window prefill path instead of retaining two dense [l, l+offset] f32 masks (~8 GiB at 32k) across the whole forward. Verify, padding, per-row valid-end, and vision-overlay shapes keep the dense masks byte-identically.
  • Lean mask builders: create_causal_mask / create_causal_mask_with_window_full build from broadcast index comparisons instead of the six-buffer ones/tril/triu/multiply/greater/where chain, cutting mask-construction transients from ~24 GiB to ~7 GiB at 32k.
  • Opt-in cache-level chunked prefill (MLXCEL_PREFILL_CHUNK, default 0 = single-pass): feeds the prompt through forward_last_logits in chunks, evaluating and releasing pool memory per chunk so rotating sliding-window caches trim between chunks and the cudaMallocAsync pool cannot accumulate per-shape high-water marks (measured 84 GB system peak for a ~30 GB live set without the release). Mirrors the server's prefill_chunk_size and upstream mlx-lm/mlx-vlm defaults; flipping the CLI default is feat: default the single-sequence CLI/bench prefill to chunked (2048) with a per-model opt-out policy #674.
  • mlxcel-bench-decode prints the MLX allocator peak after each run; benchmarks CSVs for the post-fix(cuda/quantized): chunk long-prompt qmm launches to avoid gridDim>65535 and int overflow #652 sweep and this validation are included.

Validation (GB10 CUDA, benchmarks/cuda_gb10_gemma_longprompt_672_2026-07-06.csv)

configuration before after
gemma-4-12b-it-4bit @32768, single-pass OOM class (scores alone ~34 GiB) completes, 56.8 GB MLX peak
gemma-4-12b-it-4bit @32768, MLXCEL_PREFILL_CHUNK=2048 n/a 16.6 GB MLX peak / 23 GB system, prefill 706 tok/s
gemma-4-31b-it-4bit @16384, single-pass ~74 GB system peak 54.3 GB MLX peak / 80 GB system
gemma-4-31b-it-4bit @32768, MLXCEL_PREFILL_CHUNK=2048 OOM at ~101 GB (issue repro) 29.9 GB MLX peak / 38 GB system

Greedy output: forced-chunk A/B on gemma-4-31b is token-identical to the unchunked path on a natural prompt; short-context behavior is byte-identical by construction (the gates never fire below the budget/threshold). Decode is unchanged (13.99 vs 14.03 tok/s at 8192 over 64 tokens). 13 new unit tests cover chunked-vs-unchunked equivalence (mask, softcap GQA, broadcast mask, causal offset), chunk-length math, mask row slicing, forward_last_logits, and chunked-prefill exactly-once feeding; cargo clippy -D warnings clean on default and cuda features; the mlxcel-core suite passes with only the pre-existing environment-bound failures (verified identical on a clean tree).

Scope beyond gemma

The chunk gate automatically covers every flash-ineligible CUDA config that routes through layers::attention/causal_attention: qwen3.5/3.6 (head_dim 256), baichuan-m1 (256), paligemma2 (288 + softcap), and the MLA families (deepseek-v2/v3 qk 192, glm-5 256, deepseek-v4 512). Decode throughput for these families remains fallback-bound and is tracked as #675; defaulting the CLI to chunked prefill is #674.

Closes #672

inureyes added 3 commits July 6, 2026 18:44
…672)

gemma-4/gemma-3 (head_dim 256) disqualify both CUDA fused SDPA kernels (cuDNN caps head_dim at 128, sdpa_vector covers 64/96/128 with q_len < 4), so a long single-pass prefill fell to MLX's materializing fallback: O(heads*L^2) attention scores (~68 GiB at 32k for the 31b), full-sequence 262k-vocab logits with a same-size final_logit_softcapping copy, and two dense [L, L] f32 masks together pushed GB10 past its budget and OOM'd (#672).

Four coordinated changes, each gated so short-context behavior stays byte-identical:

- layers::attention gains a query-chunk gate: when a CUDA config cannot reach a fused SDPA (head_dim > 128, softcap composite, or non-f16/bf16 dtype) and the score matrix would exceed MLXCEL_ATTENTION_CHUNK_BUDGET_MB (default 1024, 0 disables), the query axis is split into near-equal chunks; masks are row-sliced per chunk and cast to the score dtype, because MLX's fallback adds the mask without casting and an f32 mask silently promotes the masked scores and softmax to f32. The maskless causal path in causal_attention routes through the same chunking with per-chunk causal masks and K/V sliced to the causal bound.

- LanguageModel::forward_last_logits(last_pos) lets the single-sequence prefill in generate_streaming/generate_with_stats request only the sampled position's logits. The default implementation is the previous forward-plus-slice; the gemma4 override projects a single hidden row through the 262k-vocab LM head for prefills longer than 4096 tokens, dropping ~17 GiB of logits plus an equal softcap copy at 32k.

- gemma4 leaves the prefill mask pair None for text-only prefills longer than 4096 tokens, routing full-attention layers through the causal flag path and sliding layers through the over-window prefill path instead of retaining two dense [l, l+offset] f32 masks (~8 GiB at 32k) across the whole forward; verify, padding, per-row valid-end, and vision-overlay shapes keep the dense masks unchanged.

- create_causal_mask / create_causal_mask_with_window_full build from broadcast index comparisons instead of the six-buffer ones/tril/triu/multiply/greater/where chain, cutting mask-construction transients from ~24 GiB to ~7 GiB at 32k.

mlxcel-bench-decode now prints the MLX allocator peak after each run.

Validated on GB10 CUDA: gemma-4-12b-it-4bit completes a 32768-token single-pass prefill (previously the OOM class) with MLX peak 70.5 -> 56.8 GB across the optimization steps; gemma-4-31b-it-4bit at 16384 drops 58.4 -> 54.3 GB; forced-chunk A/B on gemma-4-31b is token-identical to the unchunked path on a natural prompt; 13 new unit tests cover chunked-vs-unchunked equivalence (mask, softcap GQA, broadcast mask, causal offset), chunk-length math, mask row slicing, and forward_last_logits; clippy -D warnings clean on default and cuda features.
The single-sequence CLI/bench prefill ran the whole prompt in one pass, so even with the #672 attention/logits/mask fixes a 32k gemma-4 prefill held every sliding-window layer's full-length KV plus chunk transients for the entire pass. The server already bounds this with prefill_chunk_size, and upstream mlx-vlm/mlx-lm chunk by default (DEFAULT_PREFILL_STEP_SIZE = 2048 with a per-model chunked_prefill_policy opt-out); the CLI path was the odd one out.

chunked_prefill_last_logits feeds the prompt through forward_last_logits in MLXCEL_PREFILL_CHUNK-token pieces (default 0 = single-pass, unchanged), evaluating each piece so the lazy graph never spans the whole prompt and rotating sliding-window caches trim to their window between pieces. Intermediate pieces project one hidden row through the LM head and drop the [1, 1, vocab] result. Each piece also returns freed buffers to the OS (clear_memory_cache): every piece sees a different key length, so its transients land in differently-sized allocations, and without the release the CUDA async-malloc pool accumulated each shape's high-water mark across the prompt (measured 84 GB system peak for a 32k gemma-4-31b chunked prefill whose live set is ~30 GB).

Measured on GB10 CUDA at 32768 prompt tokens: gemma-4-12b-it-4bit MLX peak 56.8 -> 16.6 GB (system 23 GB) with prefill 285 -> 706 tok/s; gemma-4-31b-it-4bit completes at MLX peak 29.9 GB (system 38 GB, previously the OOM/freeze class), prefill 271.6 tok/s. At 8192 tokens decode is unchanged (13.99 vs 14.03 tok/s over 64 tokens). Kept opt-in until multi-call prefill parity is validated across model families; a follow-up can flip the default with a per-model policy hook mirroring mlx-vlm.

Includes a chunked-vs-single-pass equivalence unit test over a stateful stub (token order, exactly-once feeding, final-logits equality).
cuda_gb10_longprompt_2026-07-04.csv is the post-#652 rerun of the long-prompt sweep on the clean rebuilt binary: llama-3.1-8b-4bit now completes 32768 prompt tokens (previously FAIL:bench from the qmm gridDim/int-overflow crash, #648).

cuda_gb10_gemma_longprompt_672_2026-07-06.csv records the #672 gemma-4 validation with memory columns: MLX allocator peak plus sampled system peak, single-pass vs MLXCEL_PREFILL_CHUNK=2048 runs, including gemma-4-31b-it-4bit completing 32768 prompt tokens at 29.9 GB MLX peak (previously OOM at ~101 GB).
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) bug labels Jul 6, 2026
@inureyes
inureyes merged commit 1d43fdf into main Jul 6, 2026
5 checks passed
@inureyes
inureyes deleted the fix/672-cuda-longprompt-chunked-attention branch July 6, 2026 11:21
inureyes added a commit that referenced this pull request Jul 17, 2026
…ode benchmark sweeps (#808)

## Summary

On the GB10 (DGX Spark, CUDA unified memory) machine, benchmark cells that OOM are worse than wasted time: near-OOM CUDA runs have repeatedly left the driver in a degraded state (NVRM NV_ERR_NO_MEMORY accumulating for hours, eventually wedging the kernel and requiring a reboot), so cells that are certain to OOM should never be launched at all. This PR adds two purely empirical, reactive-only OOM-avoidance layers to `scripts/bench_longprompt.sh`, plus a small hardening item in `scripts/bench_decode.sh`, plus three review-driven hardening fixes to the OOM-avoidance layers themselves.

## What changed

- `scripts/bench_longprompt.sh`: monotonic within-sweep backstop, keyed per model, that skips every remaining cell at length `>=` an observed `SKIP:oom` length for the rest of the run, emitting an explicit `SKIP:oom_backstop` CSV row plus a stderr message.
- `scripts/bench_longprompt.sh`: persistent cross-run OOM record at gitignored `benchmarks/.oom-record` (`model,prompt_target_len,date,commit,peak_bytes,source_csv`, `peak_bytes` always blank today). On later runs, any cell whose model has a recorded OOM at a length `<=` the cell's length is pre-skipped and logged as `SKIP:oom_record`, with the matching record line echoed to stderr. `MLXCEL_BENCH_IGNORE_OOM_RECORD=1` disables the pre-skip for one run (new `SKIP:oom` cells are still appended). Missing/empty record files and malformed lines are tolerated with a stderr warning, not a hard failure.
- `scripts/bench_longprompt.sh`: `MLXCEL_BENCH_FAKE_OOM="model:len[,model:len...]"` testing hook that skips invoking the runner for a listed cell and synthesizes a `SKIP:oom` row, so the backstop and record logic can be exercised deterministically without a real OOM.
- `scripts/bench_longprompt.sh`: only the exact `SKIP:oom` classification feeds either layer; `FAIL:bench`, timeout (exit 124), `SKIP:oom_estimate`, a missing `./models` symlink, and a missing model directory never do, since the gate is a strict string-equality check on the trailing classification token.
- `scripts/bench_longprompt.sh`: the record-file format, pre-skip semantics, the override env var, and staleness/pruning guidance (the PR #676 chunked-prefill fix is used as the concrete precedent: a record taken before that merge is stale and should be pruned with `grep -v`) are documented in both the script's header comment block and its `usage()` text.
- `scripts/bench_decode.sh`: extended the `is_oom_failure` pattern list with three CUDA-specific allocator markers (`CUDA_ERROR_OUT_OF_MEMORY`, `cudaErrorMemoryAllocation`, `cuMemAlloc failed`), kept alongside the existing generic and Metal patterns under the same "specific and grounded" rule.
- `.gitignore`: added `/benchmarks/.oom-record` since it is a locally accumulated, host-specific record and not sweep output meant to be committed.

### Review-driven hardening (3 follow-up fixes)

- `scripts/bench_longprompt.sh`: bash-version guard. The `OOM_MIN_LEN` / `FAKE_OOM_SET` associative arrays (`declare -A`) are a bash 4+ feature, but the script explicitly supports Darwin/Metal and stock macOS ships bash 3.2 as `/bin/bash`, where `declare -A` aborts the whole script at startup (including `--help`). The script now re-execs into a bash `>= 4` found on `PATH` (Homebrew `/opt/homebrew/bin` or `/usr/local/bin`) if one exists, and otherwise exits with an actionable "install a newer bash" message instead of the cryptic `declare: -A: invalid option` failure; the guard cannot loop since it only re-execs into a version that passes the check.
- `scripts/bench_longprompt.sh`: `load_oom_record` field-count validation. It previously validated only the first two fields (non-empty model, numeric length), so a truncated or hand-edited entry like `model,32768` was accepted and pre-skipped indefinitely, contradicting the header comment's promise that wrong-field-count lines are skipped with a warning. It now validates the full field count (`>= 6`, the documented `model,prompt_target_len,date,commit,peak_bytes,source_csv` format) so truncated records are rejected while a `source_csv` path that itself contains a comma stays accepted.
- `scripts/bench_longprompt.sh`: `--lengths` ladder validation. Both the monotonic backstop and the persistent-record pre-skip gate on `[[ "$len" -ge N ]]`, an arithmetic comparison; a non-numeric `--lengths` entry (a typo such as `2o48` or `32k`) made that comparison error out and silently evaluate false, so the cell the backstop was supposed to skip launched anyway and the OOM was never recorded, i.e. the safety net could fail open on a config typo. Every ladder length is now rejected up front, before the output CSV is truncated, if it is not a plain non-negative integer, naming the offending token.

Successful-row CSV output is unchanged; the trailing classification token remains an unnamed field after `prompt_target_len`, consistent with the existing `SKIP:oom_estimate` / `SKIP:oom` / `FAIL:bench` convention in `bench_decode.sh`.

## Test plan

- [x] `bash -n scripts/bench_longprompt.sh` and `bash -n scripts/bench_decode.sh` (syntax check)
- [x] `shellcheck scripts/bench_longprompt.sh scripts/bench_decode.sh` (no new warnings; one pre-existing unrelated SC2002 note in `bench_decode.sh`)
- [x] Fake-OOM sweep: `MLXCEL_BENCH_FAKE_OOM="qwen2.5-0.5b-4bit:512"` with `--lengths "512 2048 8192"` produced `SKIP:oom` at 512 and `SKIP:oom_backstop` at 2048/8192, with no real launch for the backstopped cells; `benchmarks/.oom-record` gained exactly one correctly-formatted line
- [x] Cross-model isolation: a real launch of a second model (`llama-3.2-1b-4bit`) in a separate sweep produced a normal successful row and left the first model's `.oom-record` entry untouched, confirming the backstop and record are keyed per model
- [x] Hand-written record: a manually written `(qwen2.5-0.5b-4bit, 512)` record line pre-skipped all three ladder lengths as `SKIP:oom_record` with no launch and left the record file unchanged; re-running with `MLXCEL_BENCH_IGNORE_OOM_RECORD=1` executed the cell for real instead of pre-skipping it
- [x] Non-OOM isolation: pointing `--models` at a nonexistent model directory produced the existing `[miss]` skip path with zero CSV rows and no `benchmarks/.oom-record` file created
- [x] Malformed-record rejection: a truncated two-field line (`model,32768`) appended to `.oom-record` is skipped with an explicit stderr warning and does not count toward loaded entries, while the valid six-field line alongside it still loads and pre-skips
- [x] Ladder validation: `--lengths "512 2o48"` exits 1 with an explicit "must be space-separated non-negative integers" message before any cell launches or the output CSV is truncated, while a valid ladder still runs and the backstop still fires
- [x] Working tree verified clean of test artifacts afterward; the ~20 pre-existing untracked `benchmarks/*.csv` files were left untouched throughout, and the test-only `benchmarks/.oom-record` file was removed

Closes #807
@inureyes inureyes self-assigned this Aug 31, 2026
@inureyes inureyes added the status:done Completed label Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) area:inference Generation, sampling, decoding (incl. speculative, DRY) bug priority:high High priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: gemma-4 long-prompt prefill OOMs on CUDA because attention materializes O(heads*L^2) scores instead of using flash SDPA

1 participant