Skip to content

feat(rocm): implement Hadamard and SearchSorted, fix narrow index dtypes - #1856

Merged
inureyes merged 6 commits into
mainfrom
feature/issue-1825-rocm-searchsorted
Sep 12, 2026
Merged

inureyes merged 6 commits into
mainfrom
feature/issue-1825-rocm-searchsorted

Conversation

@inureyes

Copy link
Copy Markdown
Member

Two of the three missing ROCm primitives from #1825, plus the defect the second one exposed. FFT is not here; it needs hipFFT linked and a plan cache, and its blast radius turned out to be two call sites rather than the whole audio stack.

SearchSorted is ported from mlx/backend/cuda/sort.cu into sort.hip, reusing this backend's LessThan so NaN ordering matches the CPU. mlxcel has no call site of its own: it is reached from residual_resample, which calls fused_sample, which falls back to random::categorical, whose inverse-CDF branch for 1-D logits is mlxcel's single-row sampling case. Unlike the CUDA original the kernel is grid-stride, because this backend caps a launch grid and one index per thread would silently leave the tail of a large output unwritten, the same class of defect as #1823.

Hadamard is a shared-memory radix-2 butterfly in a new hadamard.hip, covering m == 1 with a power-of-two last axis up to 8192. That is all mlxcel reaches: wht() asserts a power-of-two last axis and the KV cache advisor demotes Turbo modes for models whose head_dim is not one. Larger n and the m in {12, 20, 28} families throw a message naming the limit instead of returning a wrong answer. The scale is the primitive's scale_, which MLX defaults to 1/sqrt(n); that default is load-bearing, because it makes the transform the involution TurboQuant's rotation relies on, so the forward norm ratio is measured rather than assumed.

With the Hadamard stub gone, the TurboQuant tests reached a separate pre-existing defect, filed as #1853 and fixed here. Five dispatch sites in indexing.hip picked the kernel's index type with int32_t for int32 and uint32 and int64_t for everything else, so int8, uint8, int16, uint16 and bool index arrays reached a kernel reading eight bytes per element from a one or two byte buffer: an HSA_STATUS_ERROR_MEMORY_APERTURE_VIOLATION that aborts the GPU queue and leaves the process spinning. mlxcel hits it through the TurboQuant 4-bit codebook lookup, a take with uint8 indices.

Both fixes for that were implemented and measured before choosing. Instantiating every integer dtype is correct and takes indexing.hip from 26 s to 348 s to compile, because the index type multiplies twelve value dtypes and five to six index-count branches; upstream does instantiate per dtype in both Metal and CUDA but compiles those kernels at runtime, so the combinations are free there and not here. Widening a narrow index array to int32 once keeps the two instantiations and costs one copy pass, only on paths that are broken today, so that is what landed.

Numbers, on a Radeon 8060S (gfx1151) at f9ece5d9:

Check Result
examples/wht_numeric_probe, head_dim 64 / 128 / 256 norm ratio 1.000000 within 1e-5; fp16 relative RMS about 2.0e-4 against the M1 Ultra reference's 3.8e-4 to 5.0e-4; f32 about 1e-7, matching
tests/wht_op.rs 5 of 5
speculative::stochastic_accept, speculative::distribution_tests 18 of 18 and 16 of 16, previously 29 aborts
cache::turbo, cache::sparse_v, cache::detach 164, 19 and 51, all previously aborting
take against the CPU stream, 8 index dtypes x 4 shapes all match
searchsorted against the CPU stream, both sides matches on duplicates, strict increase, out-of-range, NaN, single element, and 17M queries past the grid extent

make verify-test-rocm goes from 5 failing targets to 4, and the whole remainder belongs to other issues: the mlxcel lib is 8200 passed with 1 failure and 3 aborts (FFT, and one [cuda_kernel] path from #1803), and mlxcel-core is 1701 passed with 2 failures from #1805 and 36 aborts that are all #1803 apart from two nvfp4 group-16 cases from #1806. No Hadamard or SearchSorted abort is left.

examples/wht_numeric_probe.rs is cherry-picked from the Metal side so both backends measure with the same code rather than trading numbers; its hardcoded host name is removed, since it mislabels every run elsewhere.

Validated: cargo fmt --all -- --check, cargo clippy --workspace --all-targets --features rocm -- -D warnings, scripts/ci/check_cross_repo_refs.py, scripts/insert_apache_header.py --check. Metal and CUDA builds copy no file in patches-rocm/.

Closes #1853. Refs #1825.

inureyes and others added 5 commits September 12, 2026 11:17
The ROCm backend left `SearchSorted` a `NO_GPU` stub, and the throw crosses the cxx bridge into a `noexcept` extern, so every hit ended in `std::terminate`. 13 tests in `speculative::distribution_tests` and `speculative::stochastic_accept` aborted the mlxcel-core test binary because of it.

mlxcel has no `searchsorted` call site of its own; TurboQuant's nearest-centroid lookup is a host-side binary search. It is reached from MLX internals on the sampling path: `residual_resample` calls `fused_sample`, which falls back to `random::categorical` on a backend with no Gumbel-max kernel, and `categorical_impl` takes its inverse-CDF branch for 1-D logits, which is mlxcel's single-row sampling case. Landing the ROCm Gumbel-max kernel under #1803 would remove the dependency from the fused path, though the graph fallback keeps it.

The kernel is ported from `mlx/backend/cuda/sort.cu` into `mlx/backend/rocm/sort.hip` and reuses this backend's `LessThan`, so NaN ordering matches the CPU without a second comparison. It differs from the CUDA original in being grid-stride: this backend caps a launch grid, so one index per thread would silently leave the tail of a large output unwritten, which is the same class of defect as #1823.

Verified against the CPU stream on both the left and right sides for duplicate runs, strictly increasing sequences, out-of-range queries, trailing NaN, a single element, and 17M queries, which is past the 65535 x 256 grid extent and so exercises the grid-stride path. The 29 previously aborting tests now pass: `speculative::stochastic_accept` 18 of 18, `speculative::distribution_tests` 16 of 16.

Refs #1825
The ROCm port of the Hadamard transform in #1825 needs a line to pass, and the two things worth checking are not the same: whether the transform is scaled the way the TurboQuant KV cache assumes, and how much a half-precision round trip drifts.

The probe reports both per head dim and dtype. The norm ratio catches a scale mistake on its own, which the round trip cannot: an unscaled Hadamard is not an involution either, but it reads as sqrt(N) immediately. The round-trip error gives the band a port has to stay inside.

Measured on an M1 Ultra at mlxcel `1f107710` with MLX pin `81ba1c6a`: the norm ratio is 1.000000 on every row in both dtypes, confirming the default 1/sqrt(N); fp16 round-trip relative RMS runs 3.8e-4 to 5.0e-4 with max absolute 1.953e-3, one row at 3.906e-3; f32 relative RMS is about 1e-7 with max absolute 3.0e-7 to 7.2e-7. The header records those numbers and the pass bands derived from them.

Refs #1825, #1809
`Hadamard` was a `NO_GPU` stub on ROCm, and the throw crosses the cxx bridge into a `noexcept` extern, so it aborted the process. That took out every TurboQuant KV cache mode and `tests/wht_op.rs`.

`mlx/backend/rocm/hadamard.hip` adds a single-stage radix-2 butterfly in shared memory, covering `m == 1` with a power-of-two last axis up to 8192. That is the whole of what mlxcel reaches: `wht()` asserts a power-of-two last axis, and the KV cache advisor demotes Turbo modes for models whose head_dim is not one, so the sizes that arrive are head dims, chiefly 64, 128 and 256. Larger `n` and the `m` in {12, 20, 28} families throw a message naming the limit instead of returning a wrong answer; covering them needs the radix-m codelet the CUDA path generates through NVRTC.

The scale is the primitive's `scale_`, which MLX defaults to `1/sqrt(n)`. That default is load-bearing: it makes the transform an involution, and mlxcel's TurboQuant rotation relies on `wht(wht(x))` returning `x`. A kernel with a hardcoded scale of 1 or `1/n` would pass a round-trip test that only checks shape or finiteness while silently corrupting KV values, so the forward norm ratio is measured rather than assumed. Accumulation is in float whatever the element type.

Like the CUDA original the grid is clamped at 65535 blocks and the kernel is grid-stride; this backend's `get_launch_args` clamps without that pairing, which is why it is not used here.

Verified with `examples/wht_numeric_probe` (cherry-picked from the Metal side so both backends run the same code) against the M1 Ultra reference at head_dim 64, 128 and 256: norm ratio 1.000000 within 1e-5, which is what separates the orthonormal scale from an unscaled transform's sqrt(N); fp16 relative RMS about 2.0e-4 against Metal's 3.8e-4 to 5.0e-4; f32 relative RMS about 1e-7, matching Metal. `tests/wht_op.rs` passes 5 of 5 on gfx1151.

The TurboQuant cache tests still do not pass. With the stub gone they reach a separate pre-existing defect: `Gather` on ROCm reads an index array narrower than 4 bytes as `int64_t`, which faults. That is filed separately.

Refs #1825
Five dispatch sites in `mlx/backend/rocm/indexing.hip` chose the kernel's index type with `int32_t` for int32 and uint32 and `int64_t` for everything else, so int8, uint8, int16, uint16 and bool index arrays reached a kernel that reads eight bytes per element out of a one or two byte buffer. On ROCm that is an `HSA_STATUS_ERROR_MEMORY_APERTURE_VIOLATION`: the GPU queue aborts, and the process then spins on the host rather than failing, which is the symptom #1804 describes. MLX accepts any integer dtype for indices.

mlxcel reaches it with uint8, because the TurboQuant 4-bit codebook lookup is a `take` with uint8 indices. That is why every TurboQuant KV cache test faulted as soon as the `Hadamard` stub was gone: they used to abort earlier and never got here.

Narrow index arrays are now widened to int32 once, before the dispatch, in `Gather`, `Scatter`, `GatherAxis` and `ScatterAxis`. The obvious alternative, giving each of the eight integer dtypes its own instantiation, was implemented and measured first. It is also correct, and it is expensive: the index type multiplies an existing product of twelve value dtypes and five to six index-count branches, and compiling this translation unit went from 26 s to 348 s. Widening keeps the two instantiations and costs one copy pass over the index array, on the paths that are broken today and nowhere else.

Verified against the CPU stream with a standalone program linked against the built MLX, for int8, uint8, int16, uint16, int32, uint32, int64 and uint64 at index shapes `[64]`, `[8, 8]`, `[1, 1, 64]` and `[1, 1, 1, 64]`. On gfx1151 the previously faulting test groups now pass: `cache::turbo` 164 of 164, `cache::sparse_v` 19 of 19, `cache::detach` 51 of 51.

The defect is fork-side, not upstream, so it is a candidate to send back through #1813. The compile-time argument-size assertion added in #1824 does not catch this class: there the mismatch was between an argument and its kernel parameter, here it is between a kernel's template parameter and the dtype of the buffer it is pointed at.

Closes #1853
Two lines in the cherry-picked probe were over the width rustfmt enforces, so `cargo fmt --all -- --check` failed on this branch. No behavior change.

Refs #1825
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:high High priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:linux Linux (CUDA / packaging) specific status:review Under review labels Sep 12, 2026
The file comment said the 1/sqrt(n) default makes the transform an involution, which is true and reads as if the round trip were a property of the transform. It is a property of that one scale: H * H = n * I, so applying scale s twice gives s^2 * n * I. A caller passing its own scale, which the quantization work in #1807 and #1808 may want in order to fold the rotation into a block scale, needs the inverse scale 1/(s * n) and the forward norm ratio s * sqrt(n) instead of a round trip. Comment only.

Refs #1825
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 platform:linux Linux (CUDA / packaging) specific priority:high High priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(rocm): Gather reads narrow index dtypes as int64 and faults the GPU queue

1 participant