Skip to content

fix(cuda/quantized): chunk long-prompt qmm launches to avoid gridDim>65535 and int overflow#652

Merged
inureyes merged 1 commit into
mainfrom
fix/648-cuda-longprompt-grid-limit
Jul 6, 2026
Merged

fix(cuda/quantized): chunk long-prompt qmm launches to avoid gridDim>65535 and int overflow#652
inureyes merged 1 commit into
mainfrom
fix/648-cuda-longprompt-grid-limit

Conversation

@inureyes

@inureyes inureyes commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

Long-prompt prefill of 4-bit models aborted on the CUDA backend with an invalid launch (cuGraphAddKernelNode / cuLaunchKernelEx "invalid argument", SIGABRT). This surfaced as FAIL:bench cells in the long-prompt sweep (epic #623/#624) and as crashes in mlxcel generate on long prompts. Two distinct causes in the quantized-matmul path:

  • MoE GatherQMM: qmm_sm80 sets grid.z = l = tokens * num_experts_per_tok, which exceeds the CUDA gridDim.z limit of 65535 once tokens*top_k >= 65536 (qwen3-30b-a3b @8192, mixtral-8x7b @32768).
  • Dense LM head: l = out.size() / (m * n) is computed with int32 m,n, which overflows when tokens*vocab >= 2^31 (about 16744 tokens for a 128256 vocab), yielding l = 0 and an invalid grid.z of 0.

Fix

All in the mlxcel-owned overlay src/lib/mlx-cpp/patches/mlx/backend/cuda/quantized/quantized.cpp:

  • run_batch_chunked splits GatherQMM's gathered batch into <= 65535 slices (sub-array views over the same buffer).
  • run_row_chunked splits QuantizedMatmul's M into slices of min(65535, INT32_MAX/N) so each launch keeps grid.y <= 65535 and m*n < 2^31. Gated on single_batch = out.size() == size_t(M)*N (the LM-head output is 3D [1, tokens, vocab], so an ndim==2 gate would miss it).
  • int B = out.size() / M / N (sequential 64-bit division) so the overflow no longer yields B=0 and misroutes dispatch.

Both helpers early-return to the original single call when no split is needed, so normal-size matmuls are byte-identical. The change is CUDA-only; the Metal backend already uses out.size()/M/N and has no 65535 grid cap, so it is unaffected.

Validation (GB10, NVIDIA sm_121)

Previously-failing long-prompt cells now pass, exit 0 with real throughput:

  • Bench (what scripts/bench_longprompt.sh runs): llama-3.1-8b-4bit @17000 / @32768, qwen3-30b-a3b-4bit @8192.
  • mlxcel generate: llama-3.1-8b-4bit @23491 and qwen3-30b-a3b-4bit @~11k complete with coherent output, confirming the sub-array offsets are correct.

Closes #648

…65535 and int overflow

Long-prompt prefill of 4-bit models aborted with an invalid CUDA launch
(cuGraphAddKernelNode / cuLaunchKernelEx "invalid argument", SIGABRT). Two
distinct causes in the quantized-matmul path (#648):

- MoE GatherQMM sets qmm_sm80 grid.z = l = tokens * num_experts_per_tok,
  which exceeds the CUDA gridDim.z limit of 65535 once tokens*top_k >= 65536
  (qwen3-30b-a3b @8192, mixtral-8x7b @32768).
- The dense LM head computes l = out.size() / (m * n) with int32 m,n, which
  overflows when tokens*vocab >= 2^31 (about 16744 tokens for a 128256 vocab),
  yielding l = 0 and an invalid grid.z of 0.

Fix, all in the mlxcel-owned overlay quantized.cpp:
- run_batch_chunked splits GatherQMM's gathered batch into <=65535 slices.
- run_row_chunked splits QuantizedMatmul's M into slices of
  min(65535, INT32_MAX/N) so each launch keeps grid.y <= 65535 and m*n < 2^31.
- int B = out.size() / M / N (sequential 64-bit division) so the overflow no
  longer yields B=0 and misroutes dispatch.
Both early-return to the original single call when no split is needed, so
normal-size matmuls are byte-identical.

Validated on GB10: previously FAIL:bench long-prompt cells now pass
(llama-3.1-8b-4bit @17000/@23491/@32768, qwen3-30b-a3b-4bit @8192), with
coherent generation confirming the sub-array offsets are correct. CUDA-only;
the Metal backend uses out.size()/M/N and is unaffected.

Refs #648
@inureyes inureyes added area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:high High priority type:bug Bug fixes, error corrections, or issue resolutions labels Jul 4, 2026
@inureyes inureyes merged commit 337c4a6 into main Jul 6, 2026
5 checks passed
@inureyes inureyes deleted the fix/648-cuda-longprompt-grid-limit branch July 6, 2026 07:11
inureyes added a commit that referenced this pull request Jul 6, 2026
…gible models (#676)

* fix(cuda/attention): bound long-prompt prefill memory to O(chunk*L) (#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.

* feat(generate): opt-in chunked prefill via MLXCEL_PREFILL_CHUNK (#672)

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).

* chore(benchmarks): add post-fix long-prompt sweeps for #648 and #672

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).
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) priority:high High priority type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CUDA: long-prompt prefill aborts (exit 134, invalid launch config) when a kernel grid dim exceeds 65535

1 participant