Skip to content

perf(cuda/attn): port native paged-attention decode kernel to CUDA#731

Merged
inureyes merged 5 commits into
mainfrom
perf/634-cuda-paged-attention-decode
Jul 10, 2026
Merged

perf(cuda/attn): port native paged-attention decode kernel to CUDA#731
inureyes merged 5 commits into
mainfrom
perf/634-cuda-paged-attention-decode

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Port the fused paged-attention decode kernel from Metal-only (mlx::core::fast::metal_kernel) to mx.fast.cuda_kernel so CUDA no longer falls back to gather-then-SDPA for the pooled paged decode path, wire the runtime gate to allow native on CUDA behind the existing MLXCEL_PAGED_ATTENTION_NATIVE control, keep the gather fallback selectable, and add a native-vs-fallback numerical parity test across the issue's case matrix.

What changed

src/lib/mlx-cpp/turbo/paged_attention.cpp: add PAGED_ATTENTION_DECODE_CUDA_SOURCE, a cuda_kernel port of the split-K flash-decoding scheme. One CUDA block per (batch, query head), (32, NumSplits) threads where the 32 warp lanes partition the head dimension and NumSplits warps sweep strided token stripes; online-softmax accumulation in registers; a butterfly __shfl_xor_sync all-reduce replacing Metal simd_sum (every lane needs the score, so the reduction is an all-reduce, not a reduce-to-lane-0); __syncthreads() combine over __shared__ scratch. Grid (32, NumSplits, B*Hq) over threadgroup (32, NumSplits, 1) ceil-divides to exact blocks, so no padded threads and no grid-padding guards. The launcher selects the CUDA body via !metal::is_available(), mirroring the #631/#727 SSM and MoE ports. The port matches the Metal kernel's contract exactly (f32 Q/out, f16 K/V pool via const __half*, *_shape metadata, template args, grid).

src/lib/mlxcel-core/cpp/mlx_cxx_kernels.cpp, mlx_cxx_bridge.h, src/lib/mlxcel-core/src/lib.rs: add a cuda_is_available() FFI probe over mlx::core::cu::is_available() (backend-agnostic, no_cuda stub off CUDA).

src/lib/mlxcel-core/src/layers.rs: add PagedDecodeBackend::Cuda; paged_decode_backend() returns it when CUDA is available; select_pooled_paged_dispatch makes native the default on CUDA for any single-slab layer (the Metal-measured batch/context ceilings do not apply on CUDA); widen the dispatch cache's backend tag to two bits (decision bit moves to bit 50). The cached MLXCEL_PAGED_ATTENTION_NATIVE override is unchanged and still forces native (=1) or gather (=0) both ways; Metal dispatch is untouched.

src/lib/mlxcel-core/src/ffi_tests.rs: add test_fused_paged_decode_native_vs_fallback_matrix (head dims 64/80/96/128, GQA 1:1/4:1/8:1, page-boundary cases, batch > 1); the older test_fused_paged_decode_gqa_and_batched no longer skips on non-Metal.

examples/paged_attention_kernel_bench.rs: pick the backend at runtime so the select= column reflects CUDA; document the CUDA run command.

Dtype and scope

The Metal source is fp16-only (f16 K/V pool, f32 Q/out), so the port is fp16-only as well and says so. Quantized KV pages are out of scope here and interact with #635.

Reachability (server vs library)

The fused kernel is reached only via the library-only paged_decode_attention_pooled -> PagedBlockPool::paged_decode_fused path, exercised by examples/paged_attention_kernel_bench.rs and external mlxcel-core consumers. #720 retired the pooled decode from the server: mlxcel-server --decode-storage paged routes pool-backed layers through the per-sequence update_and_fetch gather+SDPA intercept (src/models/llama3.rs returns None for is_paged_backed() caches), and mlxcel-bench-decode uses dense single-stream caches. Neither reaches the fused kernel, so MLXCEL_PAGED_ATTENTION_NATIVE does not change their output. The single-slab constraint (#235) also caps the servable context at 1024 tokens; beyond that the kernel declines and falls back to gather, which bounds the achievable long-context win independently of this port. This PR delivers the CUDA kernel and gate; wiring the fused kernel onto the server decode path would reverse #720's deliberate library-only decision and is out of scope.

Verification (GB10, CUDA)

Numerical parity: test_fused_paged_decode_native_vs_fallback_matrix passes with worst RMS 5.45e-5, worst max-abs 2.35e-4 over 60 configs (well under the 5e-3 fp16 threshold). All 11 ffi_tests paged-decode tests and 53 layers::tests pass under --features cuda.

Sanity A/B (mlxcel-bench-decode, llama-3.1-8b-4bit, 2048-token prompt, 32 decode tokens): MLXCEL_PAGED_ATTENTION_NATIVE=0 decode 50.93 tok/s, =1 decode 50.94 tok/s. Identical because bench_decode does not route through the fused kernel (dense single-stream caches), confirming no regression and no accidental path divergence.

Kernel A/B (examples/paged_attention_kernel_bench, the path that does reach the fused kernel): select=native now fires on CUDA for single-slab shapes; fused vs gather speedup 1.31x (batch 1, ctx 512), 1.49x (batch 1, ctx 1024), and 2.55x / 6.20x / 1.27x on the single-slab batched island. Multi-slab shapes correctly decline and read gather.

Test plan

  • cargo test --release --features cuda -p mlxcel-core --lib ffi_tests::test_fused_paged_decode_native_vs_fallback_matrix -- --test-threads=1 (worst RMS 5.45e-5)
  • cargo test --release --features cuda -p mlxcel-core --lib paged_decode -- --test-threads=1 (11 passed)
  • cargo test --release --features cuda -p mlxcel-core --lib layers::tests -- --test-threads=1 (53 passed)
  • cargo run --release --features cuda --example paged_attention_kernel_bench (native fires on CUDA, 1.31x-6.20x)
  • MLXCEL_PAGED_ATTENTION_NATIVE=0 vs =1 bench_decode A/B (no crash, no regression)

Closes #634

The fused paged-attention decode kernel existed only as a Metal JIT body (`src/lib/mlx-cpp/turbo/paged_attention.cpp`, `mlx::core::fast::metal_kernel`), which throws "[metal_kernel] No Metal back-end" on the CUDA backend. Every CUDA paged decode therefore fell back to the gather-then-SDPA reference, paying a separate gather pass plus extra KV-pool traffic per step exactly where long-context decode is bandwidth-bound.

Add a `mx.fast.cuda_kernel` port of the same split-K flash-decoding scheme following the #631/#727 SSM-port conventions: one CUDA block per (batch, query head), a `(32, NumSplits)` thread layout where the 32 warp lanes partition the head dimension and the NumSplits warps sweep strided token stripes, online-softmax accumulation in registers, a butterfly `__shfl_xor_sync` all-reduce replacing Metal `simd_sum` (every lane needs the score), and a `__syncthreads()` combine over `__shared__` scratch. The grid `(32, NumSplits, B*Hq)` over threadgroup `(32, NumSplits, 1)` ceil-divides to exact blocks with no padded threads. The kernel matches the Metal kernel's contract exactly: f32 Q/out, f16 K/V pool, `*_shape` metadata, template args, and grid. It is fp16-only like the Metal source; quantized KV pages are out of scope (interacts with #635). The launcher selects the CUDA body via `!metal::is_available()`, mirroring the SSM/MoE ports.

Wire the runtime gate: `paged_decode_backend()` now returns a new `PagedDecodeBackend::Cuda` when `cuda_is_available()` (new FFI probe over `mlx::core::cu::is_available()`), and `select_pooled_paged_dispatch` makes native the default on CUDA for any single-slab layer (the Metal-measured batch/context ceilings do not apply because the CUDA win grows with context; the kernel still declines multi-slab layers). The cached `MLXCEL_PAGED_ATTENTION_NATIVE` override is unchanged and still forces native (=1) or gather (=0) both ways. Metal dispatch is untouched. The dispatch cache's backend tag widens from one bit to two for the Metal/Cuda/Other trichotomy, moving the decision bit to bit 50.

Add `test_fused_paged_decode_native_vs_fallback_matrix`, a GPU parity test that runs the native kernel and the gather fallback over head dims 64/80/96/128, GQA ratios 1:1/4:1/8:1, page-boundary cases (single partial block, exact block edge, many blocks), and batch > 1, asserting RMS < 5e-3. Measured worst RMS 5.45e-5, worst max-abs 2.35e-4 over 60 configs on GB10. The older `test_fused_paged_decode_gqa_and_batched` no longer skips on non-Metal now that the kernel dispatches on CUDA, and `examples/paged_attention_kernel_bench.rs` picks the backend at runtime so its `select=` column reflects CUDA.

Reachability note: the fused kernel is reached only via the library-only `paged_decode_attention_pooled` -> `paged_decode_fused` path (`examples/paged_attention_kernel_bench.rs` and external mlxcel-core consumers). #720 retired the pooled decode from the server, whose `--decode-storage paged` path routes pool-backed layers through the per-sequence `update_and_fetch` gather+SDPA intercept, and `mlxcel-bench-decode` uses dense single-stream caches; neither reaches the fused kernel, so `MLXCEL_PAGED_ATTENTION_NATIVE` does not alter their output. The single-slab constraint (#235) caps the servable context at 1024 tokens, so the kernel declines and falls back to gather beyond that, which bounds the achievable long-context win independently of the port. On the reachable single-slab regime the CUDA A/B shows 1.31x-6.20x over gather.

Refs #634
@inureyes inureyes added type:performance Performance improvements priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers status:review Under review labels Jul 10, 2026
inureyes added 4 commits July 10, 2026 20:24
…ment ref

Address two LOW review findings on the #634 CUDA paged-attention port.

Add host-side unit tests for the GPU-independent selector logic that previously only the GPU-gated runtime test exercised: `selector_cuda_backend_native_on_single_slab_only` (CUDA + single-slab selects Native across the b=1 and long-context regimes Metal routes to gather; multi-slab declines to Gather), `dispatch_cache_cuda_tag_does_not_alias_metal_or_other` (a same-shape roundtrip proving the widened 2-bit backend tag keys three distinct cells), and `dispatch_cache_cuda_pack_key_roundtrips_without_collision` (the packed keys differ per backend and stay clear of the decision bit and empty sentinel).

Fix the doc reference that attributed the pooled-decode retirement to #710: #720 (commit 8b2329c) retired the entry point; #710 is the issue it resolved under ADR 0001. The `select_pooled_paged_dispatch` and `paged_decode_attention_pooled` doc comments now credit #720 with #710 as the resolved issue.

Verified with `cargo check --features cuda --lib --tests` and the narrow selectors: `layers::tests` 56 passed (3 new), `ffi_tests::test_fused_paged_decode` 3 passed.

Refs #634
…ridDim.z

Security-review follow-up (MEDIUM): the CUDA launch places batch *
query_heads in gridDim.z, capped at 65535 (Metal has no cap), and the
bridge entry returns a bare UniquePtr so a failed launch would abort
rather than surface an error. paged_decode_fused now declines such
shapes (returns None) so callers take the gather fallback, consistent
with the existing multi-slab decline semantics. Unreachable for the
single-slab island's small batches; guards external library callers.
…DR docs

MLXCEL_PAGED_ATTENTION_NATIVE's default-column and description in docs/environment-variables.md, and the matching paragraph in docs/turbo-kv-cache.md, described the fused kernel and its selector as Metal-only, which #634 makes stale: the kernel now also runs on CUDA and the selector's CUDA arm dispatches native for any single-slab layer instead of the Metal batch>=4/ctx<=4096 gate. Both docs now describe the per-backend selector behavior. ADR 0001 gets a short addendum, following the addendum convention already used in ADR 0002, recording the CUDA port and pointing at the new benchmark note; the ADR's original decision text is unchanged.

Add docs/benchmark_results/paged-attention-cuda-port-gb10-2026-07-10.md with the GB10 kernel A/B (1.31x-6.20x fused-vs-gather on single-slab shapes, multi-slab declines), the parity result (worst RMS 5.45e-5 over 60 configs), and the sanity A/B confirming mlxcel-bench-decode is unaffected, plus the #720/#235 reachability caveat.

Validation:
- cargo fmt --check on the PR's touched Rust files (already clean, no changes needed)
- manual scan of added doc lines for em dashes and trailing whitespace (none found)

Refs #634
An independent re-run measured 0.88x at b1/512 (vs 1.31x in the first
sweep) and 1.97x at b1/1024; the 200-900 us kernel range makes the
single-batch ctx-512 comparison noise-dominated around parity while the
batched island and b1/1024 win reproduce.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jul 10, 2026
@inureyes inureyes merged commit 70b2b29 into main Jul 10, 2026
5 checks passed
@inureyes inureyes deleted the perf/634-cuda-paged-attention-decode branch July 10, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers priority:medium Medium priority status:done Completed type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(cuda/attn): port the native paged-attention decode kernel to CUDA

1 participant