perf(cuda/attn): port native paged-attention decode kernel to CUDA#731
Merged
Conversation
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
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Port the fused paged-attention decode kernel from Metal-only (
mlx::core::fast::metal_kernel) tomx.fast.cuda_kernelso 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 existingMLXCEL_PAGED_ATTENTION_NATIVEcontrol, 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: addPAGED_ATTENTION_DECODE_CUDA_SOURCE, acuda_kernelport 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_syncall-reduce replacing Metalsimd_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 viaconst __half*,*_shapemetadata, template args, grid).src/lib/mlxcel-core/cpp/mlx_cxx_kernels.cpp,mlx_cxx_bridge.h,src/lib/mlxcel-core/src/lib.rs: add acuda_is_available()FFI probe overmlx::core::cu::is_available()(backend-agnostic,no_cudastub off CUDA).src/lib/mlxcel-core/src/layers.rs: addPagedDecodeBackend::Cuda;paged_decode_backend()returns it when CUDA is available;select_pooled_paged_dispatchmakes 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 cachedMLXCEL_PAGED_ATTENTION_NATIVEoverride is unchanged and still forces native (=1) or gather (=0) both ways; Metal dispatch is untouched.src/lib/mlxcel-core/src/ffi_tests.rs: addtest_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 oldertest_fused_paged_decode_gqa_and_batchedno longer skips on non-Metal.examples/paged_attention_kernel_bench.rs: pick the backend at runtime so theselect=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_fusedpath, exercised byexamples/paged_attention_kernel_bench.rsand external mlxcel-core consumers. #720 retired the pooled decode from the server:mlxcel-server --decode-storage pagedroutes pool-backed layers through the per-sequenceupdate_and_fetchgather+SDPA intercept (src/models/llama3.rsreturnsNoneforis_paged_backed()caches), andmlxcel-bench-decodeuses dense single-stream caches. Neither reaches the fused kernel, soMLXCEL_PAGED_ATTENTION_NATIVEdoes 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_matrixpasses with worst RMS 5.45e-5, worst max-abs 2.35e-4 over 60 configs (well under the 5e-3 fp16 threshold). All 11ffi_testspaged-decode tests and 53layers::testspass under--features cuda.Sanity A/B (
mlxcel-bench-decode, llama-3.1-8b-4bit, 2048-token prompt, 32 decode tokens):MLXCEL_PAGED_ATTENTION_NATIVE=0decode 50.93 tok/s,=1decode 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=nativenow 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 =1bench_decode A/B (no crash, no regression)Closes #634