Summary
Program of work to raise prefill and decode throughput of the MLX backend on CUDA (primary validation target: NVIDIA GB10, sm_121, 122 GB unified LPDDR5x at ~273 GB/s). Based on the 2026-06-17 GB10 benchmark sweep (benchmarks/cuda_gb10_2026-06-17.csv, 147 models) compared against the same-week Metal M1 Ultra sweep, plus a code audit of the generation hot path, the CUDA binding layer, and the serving layer.
Diagnosis (baseline)
Dense single-stream decode is already close to the memory-bandwidth roofline on GB10:
| Model |
Measured decode |
Roofline (273 GB/s) |
Attained |
| llama-3.1-8b-4bit |
49.1 tok/s |
~59 tok/s |
~83% |
| llama-3.1-8b-bf16 |
14.8 tok/s |
~17 tok/s |
~87% |
| qwen2.5-0.5b-bf16 |
200 tok/s |
~276 tok/s |
~72% |
| llama-3.2-1b-4bit |
260 tok/s |
~390 tok/s |
~67% |
Consequences:
- For 7B+ dense models, kernel micro-tuning has at most ~15% headroom. The big single-stream levers are fewer bytes (KV quantization, lower-bit weights) and fewer steps (speculative decoding).
- Models under ~2B are launch/dispatch-overhead bound (67-75% attained). Pipelining and host-overhead work pays off there.
- Several model families are far below roofline on CUDA and represent outright defects rather than tuning targets:
| Family |
Evidence (CUDA vs Metal M1 Ultra) |
| MoE prefill |
mixtral-8x7b-4bit 12.5 vs 81.0 tok/s; llama-4-scout 28.2 vs 119.5; phi-3.5-moe 28.9 vs 114.0; gpt-oss-20b 126.2 vs 282.4 |
| nvfp4 |
gemma-4-31b-it-nvfp4 decode 0.9 vs 8.9 tok/s; prefill 16.3 vs 38.9 |
| Hybrid SSM |
granite-4.0-h-350m decode 64 vs 219.5; falcon-h1-tiny 103 vs 288; plamo-2-1b 34.4 vs 107.1; hunyuan-13b 14.8 vs 44.4 (pure mamba2 is fine: 81.4 vs 79.2) |
Code-audit findings that shape the plan:
- The pinned MLX commit
a6ec7123 (2026-06-10) predates upstream CUDA work that targets exactly these gaps: qmv_wide small-batch quantized matvec (#3764), RoPE without copy (#3704), JIT-compiled qmm/gather_gemm plus kernel cache (#3706, #3576, #3587), fused SDPA for asymmetric Q/V head dims (#3637), gather_qmm NAX kernel-name fix (#3632), and disabling CUDA managed memory on Tegra (#3701). GB10 is a Tegra-family unified-memory SoC, so #3701 alone may shift all numbers.
- The server
BatchScheduler decode loop is synchronous per step (eval + item_i32, src/server/batch/scheduler.rs:4778), while the CLI loop already overlaps graph build with execution via async_eval_pair (src/lib/mlxcel-core/src/generate.rs:1157-1260).
- The fused decode-MoE CUDA kernel is proven (+55% on qwen3-30b-a3b) but its activation threshold default (
MLXCEL_FUSED_MOE_MAX_DFF=4096) is tuned for Metal; the measured CUDA crossover is ~13-14k (docs/benchmark_results/fused-moe-decode-kernel-design.md).
- The native paged-attention kernel is Metal-only (
src/lib/mlx-cpp/turbo/paged_attention.cpp); CUDA always takes the gather-then-SDPA fallback.
- Serving throughput machinery (continuous batching, chunked prefill, prompt cache, paged KV) exists but ships disabled: effective batch size defaults to 1,
--max-batch-prefill defaults to 1, prompt cache is off unless configured.
- Host-side per-token costs: full-history re-detokenization every token (
src/server/model_worker.rs:2014), 3 mutex locks + JSON serialization per streamed token, prompt tokenization on the single generation thread.
Phases and sub-issues
Implementation order. Each phase gate is a benchmark comparison against the phase's entry baseline.
Phase 0: measurement foundation
Phase 1: upstream sync and re-baseline (gate for Phase 3 scoping)
Phase 2: low-risk defaults and knobs (parallelizable after Phase 1)
Phase 3: targeted CUDA gap fixes (scope re-confirmed by Phase 1 triage; close any item upstream already fixed)
Phase 4: dispatch and host overhead
Phase 5: long-context and bandwidth reduction
Phase 6: larger bets
Working agreements for implementers
- Hardware: CUDA items require a CUDA machine; the reference target is GB10 (sm_121). Checkpoints are expected under
./models/ as used by scripts/bench_all_models.sh and the CSVs in benchmarks/.
- Build:
cargo build --release --features cuda. The first build compiles MLX from source via CMake (src/lib/mlx-cpp/CMakeLists.txt); expect a long cold build. CI does not run the test suite, so cargo test --features cuda locally is the merge gate.
- Benchmarks:
scripts/bench_decode.sh (same-process decode harness, binary mlxcel-bench-decode), scripts/bench_all_models.sh (sweep, appends CSV under benchmarks/), scripts/bench_mlxlm.py (cross-check vs Python mlx-lm).
- Every sub-issue must land integrated into the real code path it targets (CLI and/or server), not as a standalone module. Benchmarks or metrics proving the effect are part of done.
- Do not regress Metal. Anything backend-conditional must key off runtime backend detection (existing pattern:
!mlx::core::metal::is_available() in src/lib/mlxcel-core/cpp/mlx_cxx_kernels.cpp) or build features, and keep the Metal defaults unchanged.
Non-goals
Summary
Program of work to raise prefill and decode throughput of the MLX backend on CUDA (primary validation target: NVIDIA GB10, sm_121, 122 GB unified LPDDR5x at ~273 GB/s). Based on the 2026-06-17 GB10 benchmark sweep (
benchmarks/cuda_gb10_2026-06-17.csv, 147 models) compared against the same-week Metal M1 Ultra sweep, plus a code audit of the generation hot path, the CUDA binding layer, and the serving layer.Diagnosis (baseline)
Dense single-stream decode is already close to the memory-bandwidth roofline on GB10:
Consequences:
Code-audit findings that shape the plan:
a6ec7123(2026-06-10) predates upstream CUDA work that targets exactly these gaps: qmv_wide small-batch quantized matvec (#3764), RoPE without copy (#3704), JIT-compiled qmm/gather_gemm plus kernel cache (#3706, #3576, #3587), fused SDPA for asymmetric Q/V head dims (#3637), gather_qmm NAX kernel-name fix (#3632), and disabling CUDA managed memory on Tegra (#3701). GB10 is a Tegra-family unified-memory SoC, so #3701 alone may shift all numbers.BatchSchedulerdecode loop is synchronous per step (eval+item_i32,src/server/batch/scheduler.rs:4778), while the CLI loop already overlaps graph build with execution viaasync_eval_pair(src/lib/mlxcel-core/src/generate.rs:1157-1260).MLXCEL_FUSED_MOE_MAX_DFF=4096) is tuned for Metal; the measured CUDA crossover is ~13-14k (docs/benchmark_results/fused-moe-decode-kernel-design.md).src/lib/mlx-cpp/turbo/paged_attention.cpp); CUDA always takes the gather-then-SDPA fallback.--max-batch-prefilldefaults to 1, prompt cache is off unless configured.src/server/model_worker.rs:2014), 3 mutex locks + JSON serialization per streamed token, prompt tokenization on the single generation thread.Phases and sub-issues
Implementation order. Each phase gate is a benchmark comparison against the phase's entry baseline.
Phase 0: measurement foundation
Phase 1: upstream sync and re-baseline (gate for Phase 3 scoping)
Phase 2: low-risk defaults and knobs (parallelizable after Phase 1)
Phase 3: targeted CUDA gap fixes (scope re-confirmed by Phase 1 triage; close any item upstream already fixed)
Phase 4: dispatch and host overhead
Phase 5: long-context and bandwidth reduction
Phase 6: larger bets
Working agreements for implementers
./models/as used byscripts/bench_all_models.shand the CSVs inbenchmarks/.cargo build --release --features cuda. The first build compiles MLX from source via CMake (src/lib/mlx-cpp/CMakeLists.txt); expect a long cold build. CI does not run the test suite, socargo test --features cudalocally is the merge gate.scripts/bench_decode.sh(same-process decode harness, binarymlxcel-bench-decode),scripts/bench_all_models.sh(sweep, appends CSV underbenchmarks/),scripts/bench_mlxlm.py(cross-check vs Python mlx-lm).!mlx::core::metal::is_available()insrc/lib/mlxcel-core/cpp/mlx_cxx_kernels.cpp) or build features, and keep the Metal defaults unchanged.Non-goals
spike/openxla/).src/lib/mlxcel-core/src/streams.rsfor issues feat: single-node multi-GPU tensor parallelism to load and serve models larger than one GPU (CUDA/DGX) #486/feat: multi-GPU tensor-parallel runtime with per-rank device placement and cross-GPU collectives #488; not part of this epic).