Skip to content

Feat/rocm backend - #3801

Open
xmiksay wants to merge 52 commits into
huggingface:mainfrom
xmiksay:feat/rocm-backend
Open

xmiksay wants to merge 52 commits into
huggingface:mainfrom
xmiksay:feat/rocm-backend

Conversation

@xmiksay

@xmiksay xmiksay commented Jul 30, 2026 •

Copy link
Copy Markdown

ROCm/HIP backend for AMD GPUs

Continues @airpods69's #3424, which established the foundation this builds on:
rocm-rs for the bindings, the rocm feature and its wiring through Device,
Storage and the backend traits, rocm_backend shaped as a mirror of
cuda_backend, and a working device, allocator, RNG and matmul. Its open
checklist — convolutions, training a model, and "update the API to make more
sense" — is what this finishes. Thanks also to #3186 for opening the topic.

Tested on a Radeon RX 7800 XT (gfx1101, RDNA3, wave32), ROCm 7.2.4 — the same
architecture as #3424's RX 7700 XT, one ROCm major version newer. Everything
below is measured on that card; nothing is extrapolated from another vendor's
numbers or another architecture's.

The main design decision: share the CUDA kernel sources

#3424 hand-wrote .hip kernels. This compiles candle-kernels/src/*.cu — the
very files the CUDA backend uses — with hipcc, bridged by a ~360-line shim in
candle-rocm-kernels/src/hip_shim/ that shadows the CUDA header names the
sources #include (cuda_fp16.h, cuda_bf16.h, cuda_fp8.h, cuda.h,
cuda/std/limits) from its own include directory.

All eleven modules compile unmodified, including the 4,845-line quantized.cu.
The shim supplies only what HIP genuinely lacks: a __nv_bfloat16 typedef,
__hmax_nan/__hmin_nan for bf16, 16-bit atomicAdd via CAS, __dp4a,
__vsubss4, and *_sync shuffle wrappers.

Hand-written kernels drift, and the ones in #3424 already had: utan where
candle calls utanh (so tanh was broken on every dtype), a bminimum that
dropped NaN handling, a UNARY_OP missing its in-place path, and a BINARY_OP
missing the out-type parameter comparison operators need — which is why cmp
had no kernels to call. Sharing the sources makes that class of bug
structurally impossible.

What this costs the CUDA path

An earlier revision of this description claimed candle-kernels was untouched.
That is no longer true, so here is the exact diff — +155/-43 across two
files, none of which changes an existing kernel's behaviour under nvcc:

  • quantized.cu, +90/-43. The mul_mat_q* launchers had their tile
    geometry hard-coded to the Ampere set. They now select through an MMQ_ARCH
    macro that resolves to AMPERE unless RDNA2/RDNA3 is defined, and only
    candle-rocm-kernels ever defines those — nvcc resolves to the same
    constants the file used before.
    The RDNA arms additionally carry
    __launch_bounds__, which expands to nothing off RDNA. Without it the AMD
    backend budgets registers for a 1024-thread block, eight times what these
    launch, and spills 1748 bytes per lane on mul_mat_q3_K. The rest of that
    diff is two genuine bug fixes that apply to CUDA as well — see 5. below.
  • unary.cu, +65/-0. One new kernel family, transpose2d_*. Purely
    additive; no CUDA-side code calls it yet, though cuda_backend's
    copy_strided_src has the same slow transpose and could.

I have no CUDA toolchain here, so none of this is nvcc-compiled. The
MMQ_ARCH indirection is macro paste and the transpose is plain CUDA C++, but
that is reasoning, not evidence, and it is the one part of this PR that CI has
to check rather than me. git diff origin/main -- candle-core/src/cuda_backend candle-core/src/metal_backend candle-core/src/cpu_backend is still empty.

Non-invasive by design

Device::Rocm, Storage::Rocm, QStorage::Rocm, rocm_fwd and
pub mod rocm_backend all sit behind #[cfg(feature = "rocm")]. There is
deliberately no dummy backend, unlike cuda/metal — so a default or
--features cuda build gains no enum variant, no trait method and no public
type. The cost is ~40 extra #[cfg] lines inside candle-core, which is what
storage.rs and tensor.rs already do.

Features: rocm (default path, im2col + rocBLAS GEMM) and opt-in
miopen = ["rocm", "rocm-rs/miopen"], which swaps convolutions onto MIOpen the
way cudnn layers over cuda. A plain rocm build never links libMIOpen.

Evidence it works

Every candle test suite, on the GPU, as of the head of this branch:

Suite Result
candle-core --lib 136 (140 with --features miopen)
tensor_tests 35
quantized_tests 26
bilinear_tests 12
conv_tests 9
grad_tests 6
matmul_tests 5
pool_tests 4
layout_tests 1
candle-nn ops 10
candle-nn --lib 9
candle-rocm-kernels --lib 19
ug micro-kernels (--features "rocm ug") 6

test_device! gained a fifth ident, so every existing suite generates its
*_rocm case rather than ROCm getting a bespoke harness.

Numerical agreement is checked against the other backends, not just
self-consistency. In quantized_tests, the ROCm expectations are
bit-identical to CUDA's — ROCm runs the same MMVQ kernels from the same
.cu, so it lands on the same numbers. The DMMV decode path is verified
against a dequantize-then-matmul reference across all ten dtypes at k=256 and
k=4096, and MMQ against the same reference for all ten.

End to end:

$ cargo run --release --example bert --features rocm
score: 0.89 'The new movie is awesome' 'The new movie is so great'
score: 0.68 'The cat sits outside' 'The cat plays in the garden'

$ cargo run --release --example quantized --features rocm -- \
    --which SmoLM2-360M-Instruct --sample-len 100
loaded 290 tensors (384.62MB) in 0.33s
  6 prompt tokens processed: 151.35 token/s
 99 tokens generated: 143.96 token/s

CI parity, run locally: cargo fmt --all -- --check, cargo clippy --workspace --tests --examples --benches -- -D warnings, cargo check --workspace (also
with +avx2), and cargo test --workspace all pass. The arm-neon and macOS
metal jobs are unverified here for want of a cross toolchain.

Included

  • Ops: the full BackendStorage/BackendDevice surface — elementwise,
    cast, affine/powf/elu, reduce (with u32 argmin/argmax), cmp, where_cond,
    matmul via rocBLAS, conv1d/2d and transposes via im2col, pooling, upsample,
    the gather/scatter/index_add family, arg_sort, and RNG including f16/bf16.
  • candle-nn: rocm_fwd for softmax, rms_norm, layer_norm, sigmoid and all
    three rotary-embedding variants, across BF16/F16/F32/F64.
  • Quantized/GGUF: four matmul paths — MMVQ (batches 1–8, f32/f16/bf16
    activations), the fused DMMV decode kernel, MMQ (mul_mat_q*) for larger
    batches, and dequantize-then-GEMM. Plus quantize_imatrix and a raw weight
    pointer for downstream kernels.
  • Quantized MoE: moe_gemm_gguf at every batch size, via the MMVQ kernels
    with the expert index folded into the weight pointer.
  • F8E4M3 arithmetic — compiled and reachable now. gfx1101 has no fp8
    hardware, so this is conversion-based, but it is exercised.
  • ug micro-kernels (--features "rocm ug"), compiled through the same
    runtime hipcc path, and KernelCache::custom_function so downstream crates
    can compile kernels of their own in the same dialect and disk cache.
  • A GPU-free CI job (.github/workflows/ci_rocm.yaml). rocm-rs needs
    ROCm headers, not a device, so the job extracts seven -dev debs (~14 MB)
    rather than apt-installing the ~9.5 GB runtime. A self-hosted GPU job is
    present but workflow_dispatch-only, inert until a runner exists.
  • rocm feature for candle-pyo3; docs in the README and the book.

Quantized matmul: how the dispatch was chosen

Every threshold in quantized/rocm/ is a measurement, not a shape heuristic,
and quantized::rocm::bench* (--ignored) reprints the sweeps. Two results
are worth surfacing because they are counter-intuitive:

MMQ is not near either roofline at prefill. At Qwen3.5-2B's shapes it
reaches 20–22 TOP/s against gfx1101's ~75 TOPS dp4a ceiling, and generates
165–185 GB/s of weight traffic against 624 GB/s of bandwidth — bound by
neither. Raising mmq_x from 64 to 128 made Q8_0 and Q4_0 worse, so the 28%
is the kernel design, not the tile geometry.

rocBLAS on gfx1101 is erratic with a transposed operand. The dequantize
kernels write weights as n rows of k; describing that buffer as (k, n)
with swapped strides is free on the host but reaches rocBLAS as
Operation::Transpose, where the same f16 GEMM ranges over 6–45 TFLOP/s with
no monotonicity in the batch — against 25–57 for the plain form. So the dense
path now materialises the other orientation first, through a new tiled
transpose kernel, and fwd prefers it over MMQ for f16 from a batch of 256 up.
Worst cell over three dtypes and four shapes, mmq_ms over the dense path
(above 1.0, dense wins):

batch 128 256 384 512 1024 2048
transposed rhs 1.21 0.51 1.62 1.46 0.71 0.72
reoriented rhs 0.85 1.19 1.38 1.55 1.36 1.90

Reorienting is often the slower of the two, by up to 1.8x. What it buys is a
path that is predictable: the transposed row dips under 1.0 at three of six
batches with no pattern, so no threshold makes it safe to dispatch on. End to
end through QMatMul::forward, batch 255 (MMQ) against 256 (dense) on the same
shape: 1.2–2.1x, rising to 3.0x at larger batches.

The transpose kernel is not quantized-specific — copy_strided_src routes any
swap of the last two dimensions to it, and it moves 292–662 GB/s where the
generic strided copy managed about 20.

An earlier round of this was equally counter-intuitive in the other direction:
preferring MMVQ everywhere looked better in microbenchmarks and regressed real
generation by 28%.

Deliberately not included

candle-rocm-kernels/README.md has the full list with the reason for each, so
the gaps don't have to be found by grep. The substantial ones:

  • fast_mmq / fast_mmvq. They bind to sources using inline PTX,
    nvcuda::wmma and __reduce_add_sync. The portable mul_mat_q* kernels are
    what this launches instead.
  • The dense fused MoE GEMM (moe_gemm, and the prefill half of
    moe_gemm_gguf) — nvcuda::wmma compiled into a host-side libmoe.a. The
    quantized MoE is implemented, so FusedMoeGGUF runs at every batch; prefill
    goes through the mat-vec kernel, which is correct but not tile-optimised.
  • Flash attention. candle-flash-attn{,-v3} depend on candle-core with
    features = ["cuda"] unconditionally, so there is nothing to gate.
  • NCCL/RCCL, and so multi-GPU collectives.
  • CUDA graphs. No hipGraph counterpart here.
  • upsample_nearest1d (CUDA bails too), and i16/i32 for reduce/indexing/cast
    (the shared .cu never instantiates them).

Upstream bugs found

Building this surfaced five defects in existing backends, all documented in
candle-rocm-kernels/README.md under "Divergences from cuda_backend". The
first four are left alone — each needs its own fix and tests, and this PR is
large enough:

  1. cuda_backend::index_add passes the source layout as the freshly
    allocated accumulator's, so a non-zero start_offset is applied twice.
    metal_backend doesn't do this and Tensor::scatter uses
    Layout::contiguous; ROCm follows those.
  2. quantized/cuda.rs's dequantize fallback builds a typed slice over a
    Vec<u8> via from_raw_parts. Vec<u8> guarantees only byte alignment, so
    that's UB for any block type with stricter alignment. ROCm uses
    read_unaligned.
  3. cpu_backend's MatMul::f collapses the batch loop when
    b_skip == 0 && a_skip == m*k without checking that the lhs rows are
    contiguous. conv_transpose1d's col2im path hits exactly that shape, so
    CPU conv_transpose1d with batch > 1 is wrong from batch 1 onward.
  4. Every CUDA F8E4M3 launch names a kernel that does not exist.
    cuda_backend::kernel_name builds {root}_f8e4m3 from DType::as_str(),
    while the shared sources spell their entry points _f8_e4m3 or
    _fp8_e4m3 — no symbol in candle-kernels/src ends in _f8e4m3. Since
    cuda_backend/utils.rs does dispatch the F8E4M3 arm of every Map*
    trait, those ops resolve a missing function at run time rather than failing
    to compile. This backend keeps both spellings in FP8_SPELLED_ROOTS, with a
    test against the shipped .cu text.
  5. Two bugs in the shared quantized.cu are fixed here rather than only
    reported, because they are in code this backend had to touch anyway and are
    wrong on CUDA too: the MMVQ store ran one row past the output for an odd row
    count (4f7eff3d), and indexed_moe_forward strode its weights by QK_K
    instead of the dtype's qk (6a35ea18).

Also worth flagging for review: rocm-rs is a third-party 0.5.x bindgen crate
rather than something with cudarc's track record. It's an optional dependency
reachable only via --features rocm, and it's used with
default-features = false because its default gpu-sort feature builds an
amdgcn kernel through a proc macro that shells out to a nested nightly
-Zbuild-std invocation. Happy to discuss alternatives.

@xmiksay
xmiksay force-pushed the feat/rocm-backend branch from 9965a2b to 2790bee Compare July 30, 2026 17:58
@xmiksay xmiksay mentioned this pull request Jul 31, 2026
4 of 7 tasks
@xmiksay

xmiksay commented Aug 1, 2026

Copy link
Copy Markdown
Author

Status update: CUDA/ROCm gap closure, plus four bugs that affect CUDA

Latest round of work on this branch closes most of the remaining functional gaps between the CUDA and ROCm backends, and fixes several defects found along the way. Four of those defects are in shared code and affect the CUDA backend too — those are called out first, since they matter independently of whether this PR lands.

Pre-existing bugs fixed in shared code (affect CUDA)

Commit Bug
4f7eff3d MMVQ wrote one row past the end of each output column. mul_mat_vec_q guarded its store on threadIdx.x < rows_per_cuda_block only, missing upstream llama.cpp's row0 + threadIdx.x < nrows_dst. rows_per_cuda_block is 2 for ncols_y >= 2 on both backends (the 1-row arm requires RDNA2/RDNA3, which neither build defines), the grid is nrows.div_ceil(2), and dst is allocated at exactly nrows * b_size floats. Every odd nrows with a batch of 2..8 therefore wrote into the next batch row's first output — silent corruption, not a fault. Measured on gfx1101 with the fix reverted: n=4097, m=2, k=512, Q4_0 returns 207.7 against a reference of -214.6.
6a35ea18 indexed_moe_forward strided between experts by QK_K in a template over qk. The five K-quant instantiations pass QK_K as qk and are unaffected, but the q8_0 instantiation passes QK8_0 = 32, so it strode by an eighth of the expert matrix and every routed pair not going to expert 0 read the wrong weights.
f4e6ef83 WithDType::to_f64 for F8E4M3 recursed into itself. The macro was passed `
678088f5 Custom kernel modules were cached in memory by name while the disk cache keyed on source, so a revised source silently resolved against the module it replaced. ROCm-only, but the same name-only keying exists in CudaDevice::get_or_load_custom_func.

Gaps closed

Gap Result
fp8 (F8E4M3) Unary, binary, comparison, affine/powf/elu, where_cond, gather/index_select/index_add/scatter_add, both cast directions, strided copy, const_set. Two causes: the compile flags pinned __CUDA_ARCH__=800 while the fp8 kernels sit behind 890, and RocmStorageSlice::F8E4M3 held SendSyncDeviceMemory<u8>, so generic dispatch resolved *_u8 kernels.
Quantized MoE QRocmStorage::indexed_moe_forward and candle_nn::moe::moe_gemm_gguf, built on indexed_moe_forward_*_q8_1 in quantized.cu with no new device code. FusedMoeGGUF, and so quantized_qwen3_moe, runs on ROCm.
ug codegen ug emits CUDA C, not PTX, so hipcc consumes it. HIP code generator added to candle-ug behind a rocm feature (no ug-hip exists upstream, and ug-cuda cannot be built without nvcc). RocmDevice::compile + UgIOp1::rocm_fwd.
imatrix quantization quantize_imatrix/_onto, byte-identical to CPU for Q2K..Q6K. No device dependency — the CUDA path also round-trips through host k_quants.
QStorage::device_ptr Plus QRocmPtrGuard, which the ROCm allocator requires: freed blocks go to a free list rather than back to the driver, so an escaped pointer aliases the next tensor rather than merely dangling.
rocBLAS handle, stream constructors RocmBlas carries its stream binding and deliberately does not deref to rocm_rs::rocblas::Handle, whose set_stream takes &self. new_with_stream was a naming gap: hipStreamCreate already gives every ROCm device a private stream.

MMQ performance

quantized.cu carries one MMQ tile set per architecture, but all ten launchers named MMQ_*_AMPERE unconditionally, so the *_RDNA* constants were dead text. With no __launch_bounds__ the AMD backend budgets registers for the default 1024-thread block — 8x what these launch — capping every MMQ kernel at 192 VGPRs and spilling accumulators to scratch (mul_mat_q3_K: 557 spills, 1748 bytes per lane; now 243 VGPRs, zero spills).

After re-benchmarking (9 weight sizes x 4 batches x 10 dtypes, three independent runs, each cell a median of five repeats), all ten quantized dtypes now beat the dense dequantize+rocBLAS path; four had previously been excluded from MMQ dispatch entirely. Q3K went from 0.20-0.54x to 1.04-1.70x.

CUDA is unaffected: the RDNA define is set only on the hipcc command line, and preprocessing quantized.cu without it is byte-identical to preprocessing the original.

Tests

Environment: RX 7800 XT (gfx1101, RDNA3), HIP 7.2.53211, rustc 1.97.0.

Target Result
make test-rocm-core 226 passed, 0 failed, 3 ignored
make test-rocm-nn 19 passed, 0 failed
make test-rocm-ug 6 passed, 0 failed
cargo test -p candle-rocm-kernels 19 passed, 0 failed
make rocm-shim-test 11 modules compiled under hipcc, 0 failures
make check-rocm / make check pass
make clippy-rocm (-D warnings) / make fmt-check pass

One caveat on earlier green reports from this branch: ROCM_FILTER defaulted to _rocm, which matches the integration suites' ..._rocm suffix but not the backend module tests, whose names carry rocm only in the module path (quantized::rocm::tests_mmvq::…). make test-rocm-core was running 5 tests and skipping 92 — the entire mmvq/mmq hardware suite had never executed under make. Fixed in a2af1863; the counts above are post-fix.

New GPU coverage this round: MMVQ odd-row regression, MMQ geometry/threshold policy, fp8 against CPU references, MoE against CPU references for all six dtypes, imatrix byte-equality with CPU, device_ptr guard ordering, ug kernels executing on device, and a downstream rocblas_saxpy call through the exposed handle.

Not implemented, with reasons

  • Flash attention — candle-flash-attn, candle-flash-attn-v3 are CUDA-only crates.
  • NCCL/RCCL — no multi-GPU collectives.
  • CUDA graphs — no ROCm counterpart to enable_cuda_graph_htod_cache.
  • External-stream device construction — declined. An audit found ~120 sites assuming one stream per device: the free-list allocator recycling blocks with no event query, every launcher dropping a scratch buffer while its kernel is in flight, rocBLAS/rocRAND/MIOpen handles bound once, and the MIOpen shared workspace. Admitting a second stream needs the allocator reworked first.
  • moe_gemm (dense f16/bf16) and the WMMA prefill kernel — nvcuda::wmma, and compiled into a static library rather than a loadable module. ROCm takes the mat-vec path at every batch size: correct, not tile-optimised. No tensor-expression fallback was written on purpose; the options were a per-row weight gather (3.2 GB at prefill) or one matmul per expert.
  • fast_mmq / fast_mmvq — inline PTX, nvcuda::wmma, __reduce_add_sync.
  • set_gemm_reduced_precision_bf16 / _f32 — accepted and stored but inert; rocBLAS has no bf16 compute type and the xf32 analogue is CDNA-only. The f16 knob is live.

fp8 reduce/argmin/argmax/softmax/rmsnorm/rope, scatter-set, and sort/conv/pool/matmul are shared limitations, not ROCm gaps — those instantiations are commented out in the shared sources (reduce.cu:657-664, sort.cu:78-79, conv.cu:817-826) and indexing.cu has SA_OP_F8 with no S_OP, so CUDA lacks them too. The README now separates the two categories, which it previously conflated.

Two things worth a maintainer's eye

  1. CUDA's fp8 dispatch appears to resolve names that do not exist. kernel_name builds {root}_{dtype} from DType::as_str, which is "f8e4m3" (dtype.rs:87), but every fp8 symbol in candle-kernels/src is spelled _f8_e4m3 or _fp8_e4m3. cuda_backend/utils.rs does dispatch the F8E4M3 arm of each Map* trait, and cuda_backend/mod.rs:2498 hardcodes "ucopy_f8e4m3". Not touched here — there is no NVIDIA hardware on this machine to confirm the runtime failure or validate a fix.

  2. storage.rs performs no same_device check on the ROCm copy paths, so a cross-RocmDevice copy is reachable and would be ordered on the wrong stream. Not addressed in this round.

Verification limits

There is no CUDA toolkit or NVIDIA GPU available here — cudarc's build script hard-fails without nvcc — so make check-cuda could not be run. Where shared sources changed, CUDA safety rests on source review, and for the MMQ tile change on a demonstration that preprocessing without the RDNA define is byte-identical to the original. The CUDA-affecting fixes above are unverified on NVIDIA hardware and would benefit from a second pair of eyes.

@xmiksay

xmiksay commented Aug 1, 2026

Copy link
Copy Markdown
Author

Follow-up: moe_gemm_gguf now dispatches on the device rather than the feature set (7bcfb9d6)

A review of how candle_nn::moe reaches the ROCm backend turned up a wiring defect in the commit above (b59d346a). Worth flagging separately since it is a Cargo-feature footgun rather than a kernel issue.

The ROCm implementation was gated:

#[cfg(all(not(feature = "cuda"), feature = "rocm"))]
mod rocm;

Cargo features are additive and nothing in the tree forbids cuda + rocm together — one dependent asking for each is enough to unify them. In that build:

  • mod rocm is not compiled, so rocm::moe_gemm_gguf and its tests disappear silently
  • the CUDA variant becomes the only moe_gemm_gguf, and a ROCm tensor reaches input.device().as_cuda_device()? and errors at runtime

candle-core already answers this question the other way — matching the storage at runtime with both arms compiled in, e.g. QStorage::device_ptr at quantized/mod.rs:310-312. candle-nn/src/moe.rs was the only place selecting a backend at compile time.

It now matches on input.device() under any(cuda, rocm), and the CUDA entry points move to moe/cuda.rs mirroring moe/rocm.rs, so moe.rs is just the dispatcher. The move is verbatim — only the quantized::{self, QTensor} import narrows to QTensor, self having been unused in the moved code.

moe_gemm (dense f16/bf16) is untouched: it has no ROCm implementation either way, so its existing cuda/not-cuda split already covers every build.

Verified on gfx1101: make test-rocm-nn 9 passed / 0 failed with all five moe::rocm tests green, plus make check-rocm, make clippy-rocm (-D warnings), make fmt-check and the workspace make check. The cuda + rocm combination itself could not be built here — no nvcc — so that half rests on reading the cfg logic.

@cryptomilk

Copy link
Copy Markdown

Thank you very much for your work. As we have a day of learning in the company today. I though I would give this a try.

I'm working on Crane. So I thought lets try to wire it up to run Qwen 3.5 on my GPU (Radeon RX 9070 XT). However I couldn't run it:

JIT-compiling the quantized kernel module (via candle_core::rocm_backend's hipcc-at-runtime pipeline) fails on ROCm 7.14.0 with:

error: redefinition of 'atomicAdd'

for both __half and __hip_bfloat16.

candle-rocm-kernels/src/hip_shim/hip_compat.h's CANDLE_HIP_ATOMIC_ADD_16 macro is built on the documented assumption:

HIP has atomicAdd for float/double/int, but not for the 16-bit float types.

That was true against whatever ROCm version this crate was developed and tested against (ROCm 7.2.4 / gfx1101, per the crate's own README), but is no longer true on ROCm 7.14: /opt/rocm/include/hip/amd_detail/amd_hip_fp16.h
and amd_hip_bf16.h already define atomicAdd for __half and __hip_bfloat16 themselves.

https://github.com/cryptomilk/Crane/tree/asn-rocm has the code and a compose.yaml:

podman compose up

would reproduce it.

@xmiksay

xmiksay commented Aug 7, 2026 •

Copy link
Copy Markdown
Author

Thank you very much for your work. As we have a day of learning in the company today. I though I would give this a try.

I'm working on Crane. So I thought lets try to wire it up to run Qwen 3.5 on my GPU (Radeon RX 9070 XT). However I couldn't run it:

JIT-compiling the quantized kernel module (via candle_core::rocm_backend's hipcc-at-runtime pipeline) fails on ROCm 7.14.0 with:

error: redefinition of 'atomicAdd'

for both __half and __hip_bfloat16.

candle-rocm-kernels/src/hip_shim/hip_compat.h's CANDLE_HIP_ATOMIC_ADD_16 macro is built on the documented assumption:

HIP has atomicAdd for float/double/int, but not for the 16-bit float types.

That was true against whatever ROCm version this crate was developed and tested against (ROCm 7.2.4 / gfx1101, per the crate's own README), but is no longer true on ROCm 7.14: /opt/rocm/include/hip/amd_detail/amd_hip_fp16.h and amd_hip_bf16.h already define atomicAdd for __half and __hip_bfloat16 themselves.

https://github.com/cryptomilk/Crane/tree/asn-rocm has the code and a compose.yaml:

podman compose up

would reproduce it.

Hi, when I wrote it, I tested it with Crane as well, and I was able to run it (this was a reason why I wrote it :) )
Thx for your test; I will rebase it and adopt a new version of ROCm + test it with Crane as well one more time. The goal was to be as fast as Ollama...

I have prepared branche for Crane for ROCm as well.

@cryptomilk

Copy link
Copy Markdown

Oh this is awesome! We have quite some changes in the meantime :-)

@cryptomilk

Copy link
Copy Markdown

And if you are not aware of it yet, there is data/crane-model-download for downloading models now :-)

@xmiksay
xmiksay force-pushed the feat/rocm-backend branch from fe15b63 to 8a17ca0 Compare August 7, 2026 15:48
@cryptomilk

cryptomilk commented Aug 7, 2026 •

Copy link
Copy Markdown

Awesome, I was able to load the model now. But with my first request, I already ran out of memory :-)

image

model=qwen3.5-9b-gguf quant=Q5_K_M

@Hahihula

Hahihula commented Aug 8, 2026

Copy link
Copy Markdown

It's great to see the broadening of HW support! Hope this gets to main as soon as possible.

cryptomilk added a commit to cryptomilk/Crane that referenced this pull request Aug 11, 2026
Crane needs ROCm/HIP support to run on AMD GPUs,
which candle only has on an unmerged fork (huggingface/candle#3801,
xmiksay/candle branch feat/rocm-backend) rather than in any released
version.
@cryptomilk

Copy link
Copy Markdown

The last days I've tested Crane build against this ROCm branch with LLM (Qwen 3.5), TTS (Voxtral) and ASR (Qwen3 ASR) and it is running smooth.

@zachelnet

Copy link
Copy Markdown

Hi @xmiksay,

thanks for your PR. I found the following:

ROCm conv path: f32 im2col blows up at ~640p and is a blocker for Stable Diffusion / FLUX

I benchmarked it with a real conv-heavy model (Real-CUGAN 2x super-resolution, ~30 conv/conv_transpose layers) on my Radeon RX 9070 XT (gfx1201, RDNA4), ROCm 7.14.0. Correctness is fine — outputs match CPU to ~1e-5.

Impact

  • f32 convs hit a scaling cliff at large spatial sizes. USE_IM2COL_CONV2D/1D = true (rocm_backend/ops_conv.rs) always materializes (b·h·w, c_in·kh·kw):
    • CUGAN: 480p→640p is 1.78× the pixels but 4.2× the time (167→700 ms); 720p = 1107 ms. f16 stays linear throughout (73→127→229→290 ms), so this is memory-bandwidth pathology, not fp32 throughput. burn/Vulkan runs the same model at 720p in 313 ms (f32) / 46 ms (fp16).
    • On a display-driven GPU the multi-GB allocations can starve the driver: amdgpu_dm ... Failed to pin framebuffer with error -12 → GNOME/GDM restarts.
    • SD/FLUX are affected, not just SR — on the SD-VAE-decoder shapes (3×3, 512ch): 40 ms at 256², 210 ms at 512² (≈1 s+ per VAE decode), and 19.3 GB im2col at 1024² → hipErrorOutOfMemory, so SDXL/FLUX are effectively unrunnable on 16 GB cards.

Cause

  • im2col materializes h·w × c_in·kh·kw f32; past a few hundred MB it's memory-bound and eventually OOMs. A direct-conv backend (no im2col) I also tested scales linearly to 1080p and allocates only O(input).

Fix

  • Dispatch threshold: route to a fallback non-im2col (direct) conv path when the estimated im2col size in bytes — h_out·w_out·c_in·kh·kw·sizeof(dtype), so the threshold naturally differs for f32 vs f16 — would exceed ~256–512 MB. Small change; removes the cliff, the desktop crash, and the SD blocker.
  • Longer term: a shared-memory-tiled direct conv would eliminate the im2col round-trip entirely and get f32 toward burn/MIOpen numbers.

Happy to share the benchmark if useful as a conv-heavy smoke/perf test.

@zachelnet

Copy link
Copy Markdown

Follow-up: I tested the direct-conv fallback too, and it's not a free win. Flipping USE_IM2COL_CONV2D = false (the existing ops_conv_direct path) fixes the OOM/launch failures — 1024² now runs in both f32 and f16 with only ~O(input) memory. But the naive direct conv is ~18× slower than im2col+GEMM at 512² (3.8 s vs 210 ms) and 17 s per layer at 1024² — unusable in practice. So a dispatch threshold only makes sense together with a tiled / implicit-GEMM direct conv (or MIOpen); the naive direct path alone just trades OOM for unusable latency.

xmiksay added a commit to xmiksay/candle that referenced this pull request Aug 31, 2026
The im2col conv path unfolded the whole (b*h_out*w_out, c_in*k_h*k_w)
matrix before its single GEMM. Past a few hundred MB that allocation is
a memory-bandwidth cliff, and at Stable Diffusion decoder shapes it
does not fit at all: a 3x3 over 512 channels at 1024x1024 wants a
19.3 GB buffer and dies with hipErrorOutOfMemory -- reported on PR
huggingface#3801 (gfx1201, Real-CUGAN and SD/FLUX workloads), along with the
multi-GB allocations starving the display driver on a desktop GPU.

conv1d/conv2d now estimate the buffer up front and, above
IM2COL_MAX_BYTES (256 MB), run the identical im2col + GEMM over row
chunks: unfold at most the cap's worth of output rows, GEMM each chunk,
and write the rows into one pre-allocated result. GEMM throughput is
what it was -- only the unfolded buffer is bounded. Below the cap
nothing changes; the reporter's fallback suggestion (the direct
kernels) was measured by them at ~18x slower, so it stays untouched.

Measured on gfx1101, f32 3x3 over 512 channels: 68 ms at 256x256,
346 ms at 512x512, 1635 ms at 1024x1024 -- near-linear in pixel count
where the un-chunked path went superlinear and then OOMed; 1024x1024
did not previously run at all.

Chunk assembly is pinned against the single-shot path over caps of one
row, a non-dividing step and a single covering chunk, on contiguous
and transposed inputs, plus an end-to-end test that the public entry
point routes above the cap.
@xmiksay
xmiksay force-pushed the feat/rocm-backend branch from 8a17ca0 to 492cd19 Compare August 31, 2026 20:30
Adds the HIP kernel crate: kernel sources for unary, binary, cast, affine,
fill, indexing, reduce and conv, plus the runtime hipcc compiler and the
on-disk code-object cache.

rocm-rs is pulled with default-features off. Its default 'gpu-sort' feature
builds an amdgcn kernel through a proc-macro that shells out to a nested
nightly -Zbuild-std cargo invocation, which fails on a stable toolchain.
Candle never calls rocm-rs' sort, so the feature is left off.
Adds candle-core/src/rocm_backend: RocmDevice, RocmStorage, the MIOpen
convolution bridge, error types and the Map1/Map2/Map3 dispatch helpers,
and routes Device, Storage, Tensor, custom ops and safetensors through it.

The backend is reachable only via --features rocm: Device::Rocm,
Storage::Rocm, rocm_fwd and the rocm_backend module are all cfg-gated, so a
default or --features cuda build gains no enum variant, trait method or
public type.

Device::is_rocm is unconditional to match is_cuda/is_metal, which lets
supports_bf16 collapse back to a single match. The quantized ROCm arms
return an error instead of todo!(), since panicking out of a Result-typed
path is not acceptable in library code.
Adds rocm_fwd for the four fused custom ops and declares the rocm feature
on candle-nn and candle-transformers.
The ROCm branch of device() is cfg-gated. Device::new_rocm only exists
under the feature, so calling it unconditionally broke the default build.
Adds check/check-cuda/check-rocm, clippy/clippy-rocm, test-rocm and
test-rocm-suite, plus rocm-info and rocm-cache-clean. CARGO_BUILD_JOBS
defaults to 4. The ROCm suites run single-threaded until the on-disk
kernel cache is race-free.
Adds a shim include directory that lets candle-kernels/src/*.cu compile with
hipcc unmodified. It shadows the CUDA header names the sources include
(cuda_fp16.h, cuda_bf16.h, cuda_fp8.h, cuda.h, cuda/std/limits) and supplies
the handful of intrinsics HIP lacks: 16-bit atomicAdd, __dp4a, __vsubss4 and
the *_sync shuffle wrappers.

Because the shim shadows headers from its own -I directory, not one line of
the shared sources is edited and the CUDA path cannot regress.

All eleven modules build for gfx1101, yielding 698 kernels including the
families the hand-written .hip files never had: comparison ops, where_cond,
gather/scatter/index_add, sort, rope, pooling/upsample and quantized, plus
f16/bf16 across the board. It also resolves the utanh/utan mismatch that
broke tanh on every dtype.

fp8 shares the __CUDA_ARCH__ >= 800 guard with bf16 and so cannot be excluded
without losing bf16; it maps to HIP's OCP __hip_fp8_e4m3, which uses the same
encoding as NVIDIA's type. The ROCm backend never launches those kernels.

make rocm-shim-test compiles every module and validates the hand-written
device code on the GPU.
…itten HIP

Replaces the eight hand-written .hip files with the shared candle-kernels
sources, compiled through the HIP shim. The crate now exposes the same
Id/Module shape as candle-kernels, so a call site reads
get_or_load_func(&kernel_name::<T>("rope"), &kernels::REDUCE) on both
backends.

The hand-written kernels covered roughly a third of the CUDA surface and had
drifted semantically: utan vs utanh broke tanh on every dtype, bminimum
dropped NaN handling, UNARY_OP lost its in-place path, and BINARY_OP lost
OUT_TYPENAME, which is why cmp had no kernels to call. All of that is now
moot.

Also fixes three defects in the runtime compiler:
  - the module cache was keyed by kernel name, recompiling a whole
    translation unit once per kernel per dtype
  - cache writes were non-atomic with predictable names in a shared temp
    directory, racing across threads and processes
  - an undetected architecture silently fell back to gfx908, producing code
    objects that cannot load on the target GPU; it is now an error

get_or_load_func borrows its kernel name, which removes the Box::leak that
leaked a String on every kernel launch in candle-nn.

Dropping objcopy in favour of --genco plus clang-offload-bundler removes the
binutils dependency, and the bundler is now taken from ROCM_PATH rather than
whichever clang is first on PATH.

candle-nn's four ROCm tests pass on gfx1101: 26s cold, 0.94s cached.
Adds a fifth ident to test_device! and threads it through quantized_matmul!,
so every existing suite gains a *_rocm case. Names follow the metal ident
with the suffix swapped, which keeps the -- _rocm filter meaningful.

Drops the four hand-written ROCm tests in candle-nn, which the macro now
generates under the same names.
`min_work`'s table was measured against the spilling Ampere geometry
and is stale as of 4aba3c3. Re-swept on gfx1101 over 9 weight sizes x
4 batches x 10 dtypes, three independent runs, each cell the median of
five timing repeats.

Every dtype now earns MMQ somewhere, so the only `None` left means "no
`mul_mat_q*` kernel". The four that were disabled outright all win:
Q3K went from 0.20-0.54x to 1.04-1.70x. Seven dtypes win at every size
swept and are floored at the sweep's smallest, 0.25 Mi; Q6K crosses at
1 Mi, Q3K at 2 Mi, Q2K at 4 Mi.

Individual timings spread up to 47% at 0.25-1 Mi, where a call is
~35 microseconds and mostly launch overhead, but the 5-rep median
reproduces to 0.7% mean across runs and only 2 of 360 cells flip
across 1.0. Two crossings are soft and called for MMQ deliberately,
because a wall-clock wash is not a wash overall: the dense path also
allocates the dequantized matrix, 524 MB for a 4096x32000 `lm_head`,
and MMQ allocates none of it. That tips ties; it does not license
extrapolating below the measured floor.

The sweep also falsifies "the product alone decides" for Q3K, which
differs by 0.11-0.18 between 1024x1024 and 512x2048 at one product.
Recorded; it changes nothing, since Q3K waits for 2 Mi regardless.

The policy test no longer restates the constants -- it bisects
`is_faster` for each threshold and asserts properties of the policy.
`rocm.rs` and the README both claimed dtypes that never win; corrected.
The macro was passed `|v: f8e4m3| v.to_f64()`. `f8e4m3`'s inherent
`to_f64` takes `&self` while `WithDType::to_f64` takes `self`, so a
by-value receiver resolved to the trait method the macro was defining
and called itself until the stack was gone.

Any `to_dtype(F64)` on an fp8 tensor therefore aborted the process,
on every backend including CPU. The other float dtypes pass a path
rather than a closure and were never affected.
Two things kept fp8 off ROCm, and neither was the shim -- it already
typedefs `__nv_fp8_e4m3` onto HIP's OCP `__hip_fp8_e4m3`.

The compile flags pinned `__CUDA_ARCH__=800`, and the fp8 kernels in
`unary.cu`, `affine.cu`, `ternary.cu` and `indexing.cu` sit behind
890, so they were never instantiated. Nothing else in the shared
sources is gated between 801 and 890, so raising it adds 50 fp8
symbols and loses none; `quantized`, `conv`, `reduce`, `sort`,
`binary`, `fill` and `cast` come out symbol-identical. `make
rocm-shim-test` now reads the value from `cache.rs` instead of
repeating it, so the two cannot drift.

`RocmStorageSlice::F8E4M3` also held `SendSyncDeviceMemory<u8>`, so
the generic dispatch in the `Map*` traits resolved to the `*_u8`
kernels. It carries `F8E4M3` now, and the six traits plus
`Affine`/`Powf`/`Elu` dispatch rather than bailing.

Working: unary, binary, comparison, affine/powf/elu, where_cond,
gather/index_select/index_add/scatter_add, casts both ways, strided
copy, const_set. Not implemented, each with an error naming the
reason: reduce and its dependants (`reduce.cu` has every fp8
instantiation commented out upstream), scatter-set (`indexing.cu` has
`SA_OP_F8` but no `S_OP`), and sort/conv/pool/upsample/matmul, which
have no fp8 kernels on CUDA either.

RDNA3 has no fp8 hardware, so the conversions run in software through
f32. One divergence from CUDA is pinned by a test rather than hidden:
HIP encodes an infinite f32 as NaN where NVIDIA's SATFINITE and the
CPU backend clamp to 448.

The shared `.cu` sources are unchanged -- `git diff candle-kernels/`
is empty -- so CUDA cannot regress.
`indexed_moe_forward` is a template over `qk` but computed its
per-expert stride with a hard-coded `QK_K`. The five K-quant
instantiations pass `QK_K` as `qk`, so they are unaffected, but the
q8_0 instantiation passes `QK8_0` = 32 and strode by an eighth of the
expert matrix -- every routed pair not going to expert 0 read the
wrong weights.

Affects CUDA as well as ROCm. Left unguarded rather than scoped to
HIP: for the K-quants the two spellings are the same value, so those
five kernels stay bit-identical, and guarding it would have left CUDA
with the arithmetic still wrong.

Covered by the ROCm MoE tests in the following commit, which are the
first callers able to reach this kernel on that backend.
MoE has two separate kernel supply chains, and only one of them is
portable.

`indexed_moe_forward_*_q8_1` lives in `quantized.cu`, which ROCm
already compiles, and uses nothing but `WARP_SIZE`, `warp_reduce_sum`
and `vec_dot_q*_q8_1`. Both `QRocmStorage::indexed_moe_forward` and
`candle_nn::moe::moe_gemm_gguf` are implemented on top of it, with no
new device code. That makes `FusedMoeGGUF`, and so
`quantized_qwen3_moe`, run on ROCm.

The two APIs disagree on routing order: the candle-nn one takes it
pre-sorted by expert, the kernel takes it unsorted. `sorted_token_ids`
is a permutation of `0..size_m`, so arg-sorting it ascending yields
the inverse and the conventions carry identical information.
`is_prefill` and `dtype` are ignored -- CUDA uses them to choose a
WMMA kernel.

`moe_gemm` (dense f16/bf16) and `moe_gemm_gguf`'s prefill kernel stay
unimplemented: both are `nvcuda::wmma`, and they are built into a
static library rather than a loadable module, which ROCm has no
pipeline for. They now error naming the kernel and the file. No
fallback was written on purpose -- the only pure-tensor expressions
are a per-row weight gather (3.2 GB at prefill) or one matmul per
expert, either of which would pass a test while being useless in the
model it exists for. ROCm therefore takes the mat-vec path at every
batch size: correct, but not tile-optimised for prefill.

Four `panic!`s in `quantized/mod.rs` become typed errors; the ROCm arm
could not previously be reached, since `mod.rs` panicked ahead of
`rocm.rs`'s bail.
Two gaps in the quantized storage layer, neither of which needed a
kernel.

`quantize_imatrix`/`_onto` turned out to have no device dependency at
all: the CUDA counterpart copies the f32 weights to the host, calls
the same `k_quants` `from_float_imatrix` the CPU backend calls, and
uploads the packed bytes. There is no imatrix kernel in the tree for
either backend. ROCm now does the same, byte-identical to CPU for
Q2K..Q6K, with the quantization block moved out of `rocm.rs` into
`rocm/quantize.rs`. Any other dtype is rejected up front, because
`from_float_imatrix`'s default body is a `panic!` and the dtype comes
from a config file -- CUDA aborts there.

`device_ptr` was left open for a reason worth recording: the ROCm
allocator does not return freed blocks to the driver, it puts them on
a free list, so a pointer outliving its storage does not merely dangle
-- it aliases whatever tensor takes the block next. `QRocmPtrGuard`
therefore gives two guarantees, mirroring cudarc's wait-on-write and
record-read pair. The guard borrows the storage, so the borrow checker
rejects a pointer that outlives the allocation; and it drains the
device stream on acquire and the consumer stream on drop, so in-flight
reads finish before the block can be recycled. A same-stream consumer
skips both, the single-stream invariant already ordering it.

`QStorage::device_ptr_with_guard` still declines on the ROCm arm, now
saying why: its guard is a `SyncOnDrop` over a `CudaStream`, and a HIP
allocation cannot be ordered against one. The ROCm equivalent is a
separate method taking a HIP stream.
`RocmBlas` carries the handle together with the stream it is bound to,
and deliberately does not deref to `rocm_rs::rocblas::Handle`: that
type's `set_stream`, `set_math_mode` and `set_atomics_mode` all take
`&self`, so handing it out would let safe code re-point the device's
shared handle at another stream. That would invalidate both the `Sync`
justification on the wrapper and the allocator's ordering invariant.
Only `as_raw()` and `stream()` are exposed -- the same line cudarc
draws with `&mut self` plus `unsafe`.

`new_with_stream` turns out to be a naming gap rather than a
behavioural one. `HipDevice::get_stream` is `hipStreamCreate`, so
every ROCm device has always had its own private stream, which is
exactly what `CudaDevice::new_with_stream` provides over `new` (the
latter adopts the process-wide per-thread default). `Device::new_rocm_
with_stream` mirrors the candle-level entry point.

`is_event_tracking` returns false and `disable_event_tracking` is a
no-op, truthfully: cudarc's events exist to order a buffer's readers
and writers across streams, and this backend records none. It stays
`unsafe` for signature parity and so a future implementation can start
relying on caller synchronisation without a breaking change.

Construction from an externally-owned stream is declined. CUDA does
not offer it either, and an audit found roughly 120 sites assuming one
stream per device -- the free-list allocator recycling blocks with no
event query, every launcher dropping a scratch buffer while its kernel
is in flight, the rocBLAS/rocRAND/MIOpen handles bound once, and the
MIOpen shared workspace. Admitting a second stream needs the allocator
reworked first, so the API stops where it is still sound.
The compile flag is 890, not 800, and the arch define selecting the
MMQ tile geometry was undocumented entirely. The fp8 rows claimed
nothing but `const_set` is ever launched; every kernel that exists is
now dispatched. The MMVQ odd-row divergence is fixed rather than
worked around, so it moves to past tense.

The gap list is split into ROCm gaps and limitations shared with CUDA,
which the document previously conflated -- fp8 reduce, sort, conv and
scatter-set are commented out in the shared sources and missing on
both backends, so listing them as ROCm gaps overstated the difference.
`ug`, CUDA graphs, multi-stream and NCCL are documented for the first
time.

Also records two hazards found while auditing. `quantized.cu`'s
1-row MMVQ arm is gated on `GGML_USE_HIPBLAS && (RDNA2 || RDNA3)`;
since 4aba3c3 defines RDNA3, only the never-defined first term keeps
it off, and `mmvq.rs` hard-codes the 2-row grid. And CUDA's fp8 is
broken where ROCm's is not: `kernel_name` builds `{root}_f8e4m3` from
`DType::as_str`, but every symbol in `candle-kernels/src` is spelled
`_f8_e4m3` or `_fp8_e4m3`, so each CUDA fp8 op resolves a name that
does not exist.
The ROCm implementation was gated `all(not(feature = "cuda"), feature
= "rocm")`, so a build with both features compiled only the CUDA one.
Cargo features are additive and nothing forbids the combination -- one
dependent asking for each is enough -- and in that build `mod rocm`
and its tests vanish silently while a ROCm tensor falls into the CUDA
path and fails at `as_cuda_device()`.

`candle-core` already answers this by matching the storage at runtime
with both arms compiled in, e.g. `QStorage::device_ptr`. `moe.rs` was
the only place choosing the backend at compile time. It now matches on
`input.device()` under `any(cuda, rocm)`.

The CUDA entry points move to `moe/cuda.rs`, mirroring `moe/rocm.rs`,
which keeps `moe.rs` a dispatcher and all three files under the
400-line cap. The move is verbatim; only the `quantized::{self, ..}`
import narrows, `self` having been unused there.

`moe_gemm` is untouched: it has no ROCm implementation either way, so
its cuda/not-cuda split already covers every build.
Materialising a transposed view goes through `ucopy_*`, which strides one
of its two sides by a whole row and so spends a cache line per element:
`t()?.contiguous()` on a 8192x2048 f16 matrix measured 3.4 ms on gfx1101,
about 20 GB/s against the card's 624.

`transpose2d_*` stages a 32x32 tile through shared memory so both sides
stream, and walks the grid diagonally so that concurrent blocks do not all
address one row pitch apart — at a weight matrix's pitches those land on
the same few memory channels, which alone cost 6x on the 2048x8192 f32
case. It moves 292-662 GB/s over the shapes now benchmarked.

Additive: no existing kernel changes, and nothing calls this yet.
`copy_strided_src` sent every non-contiguous source to the generic
`ucopy_*`, including the one shape that has a good kernel: a swap of the
last two dimensions. `as_transpose2d` recognises exactly the layouts one
`transpose2d_*` launch can serve — the last two dims swapped over a
contiguous stack of matrices — and declines the rest, including a
broadcast batch and a narrowed matrix, which leave gaps a single launch
cannot express.

It also declines when either allocation is shorter than the copy: callers
over-request, and the generic path below applies that clamp.

f16 8192x2048 goes from 3.4 ms to 0.12 ms; f32 2048x8192 from 47.6 GB/s
to 292.
MMQ is not near roofline at a prompt-sized batch: swept at Qwen3.5-2B's
shapes it reaches 20-22 TOP/s against gfx1101's ~75 TOPS dp4a ceiling and
165-185 GB/s of weight traffic against 624 GB/s of bandwidth, so it is
bound by neither. Raising mmq_x from 64 to 128 made Q8_0 and Q4_0 worse,
so the 28% is the kernel design rather than the tile geometry.

The cost was instead in the fallback MMQ pre-empts. The dequantize kernels
write the weights as `n` rows of `k`, and describing that buffer as
`(k, n)` with swapped strides reaches `gemm_config` as
`Operation::Transpose` — a form rocBLAS is erratic in on gfx1101, ranging
6-45 TFLOP/s with no monotonicity in the batch against 25-57 for the plain
form. `dense::forward` reorients the weights first and `fwd` prefers it
for f16 from a batch of 256 up.

Reorienting is often the slower of the two, by up to 1.8x; what it buys is
a path that is predictable, and the transposed form dips below MMQ at
three of six batches, so no threshold makes it safe to dispatch on. 256 is
where the reoriented path wins everywhere. End to end through
QMatMul::forward, 255 against 256 on the same shape: 1.2-2.1x, and
1.4-3.0x at larger batches.

The 32 Mi weight cap keeps a vocabulary-sized matrix on MMQ — this path
holds n*k twice, 512 MB of f16 transient for a 4096x32000 lm_head. f32 and
bf16 stay on MMQ as measured.

bench_prefill_roofline is the sweep both numbers come from; the timing
helpers move to pub(super) and bench.rs splits to stay under the file cap.
The quantized dispatch table gained a row, and the README gained the
measurements behind it: MMQ's distance from both rooflines, the rocBLAS
transposed/plain asymmetry the dense path works around, and the transpose
kernel's own throughput against the strided copy it pre-empts.
ROCm >= 7.14 defines atomicAdd(__half*, __half) and the __hip_bfloat16
counterpart in amd_hip_fp16.h / amd_hip_bf16.h, so the shim's plain
function definitions became redefinitions and every JIT compile failed
(reported on a Radeon RX 9070 XT running ROCm 7.14.0).

Turn the fallbacks into constrained function templates: a template may
coexist with a same-signature non-template, and overload resolution
prefers the non-template on an otherwise equal match, so newer
toolchains use their own (hardware) atomics and older ones instantiate
the CAS loop as before. No version sniffing, which would have to guess
the exact release the header overloads appeared in.

shim_coexist_test.hip recreates the >= 7.14 header layering with
sentinel overloads and asserts the non-template wins; rocm-shim-test
runs it wherever the real headers lack the overloads (on >= 7.14 the
main shim test already exercises the real coexistence).
im2col_chunk / im2col1d_chunk are the plain im2col kernels plus a base
offset: they compute the same (b, h_out, w_out, c_in, h_k, w_k) mapping
for a slice of the full matrix and write it into a chunk-local buffer,
so a caller can bound how much of the unfolded input ever exists at
once. The un-chunked kernels now delegate with a base of zero -- same
arithmetic, one definition.

Additive for CUDA: the new symbols are instantiated but nothing on the
CUDA side calls them, following the tiled transpose precedent. The
delegation refactor keeps the original entry points' signatures and
semantics unchanged.
The im2col conv path unfolded the whole (b*h_out*w_out, c_in*k_h*k_w)
matrix before its single GEMM. Past a few hundred MB that allocation is
a memory-bandwidth cliff, and at Stable Diffusion decoder shapes it
does not fit at all: a 3x3 over 512 channels at 1024x1024 wants a
19.3 GB buffer and dies with hipErrorOutOfMemory -- reported on PR
huggingface#3801 (gfx1201, Real-CUGAN and SD/FLUX workloads), along with the
multi-GB allocations starving the display driver on a desktop GPU.

conv1d/conv2d now estimate the buffer up front and, above
IM2COL_MAX_BYTES (256 MB), run the identical im2col + GEMM over row
chunks: unfold at most the cap's worth of output rows, GEMM each chunk,
and write the rows into one pre-allocated result. GEMM throughput is
what it was -- only the unfolded buffer is bounded. Below the cap
nothing changes; the reporter's fallback suggestion (the direct
kernels) was measured by them at ~18x slower, so it stays untouched.

Measured on gfx1101, f32 3x3 over 512 channels: 68 ms at 256x256,
346 ms at 512x512, 1635 ms at 1024x1024 -- near-linear in pixel count
where the un-chunked path went superlinear and then OOMed; 1024x1024
did not previously run at all.

Chunk assembly is pinned against the single-shot path over caps of one
row, a non-dividing step and a single covering chunk, on contiguous
and transposed inputs, plus an end-to-end test that the public entry
point routes above the cap.
…hoard VRAM

Reported against Crane (lucasjinreal/Crane#120): a long Qwen3-Coder MoE
decode ran the card out of memory until rocrand failed with status 107.
The caching allocator parked every freed block on a per-size free list
and never returned memory to the driver, and decode is not shape-stable:
each step's attention and KV buffers are a few KB larger than the last,
so with a fixed 512 B / 1 MiB granularity nearly every step opened a
fresh bucket and parked a block nothing would ever request again. The
release-and-retry on OOM only guards this allocator's own hipMalloc --
rocRAND, rocBLAS workspaces, MIOpen and the display driver allocate
outside it and hit the hard OOM the hoard caused.

Two changes, each addressing one half:

- Bucket granularity becomes relative -- an eighth of the request's
  power-of-two floor, clamped to [512 B, 32 MiB]. A growing buffer now
  steps through at most eight buckets per octave instead of one per
  step, so consecutive decode steps actually reuse their blocks.
  Overshoot is bounded at 12.5%, and at 32 MiB absolute for the
  multi-GiB weight tensors.

- The parked bytes are capped -- an eighth of the card's VRAM by
  default, CANDLE_ROCM_CACHE_LIMIT_MB overrides, 0 disables. Parking a
  block above the cap evicts back to the driver, largest bucket first:
  the outgrown blocks are exactly the largest ones, while the small hot
  buckets survive. hipFree runs outside the free-list lock; its device
  synchronisation is also what makes freeing a possibly-still-referenced
  block sound. A shape-stable workload never reaches the cap and keeps
  the old behaviour.

Verified on gfx1101: make test-rocm-core (144 passed, 5 ignored),
make test-rocm-nn (19 passed), the new eviction and growth tests,
make check-rocm, make clippy-rocm and make fmt-check all pass.
cryptomilk added a commit to cryptomilk/Crane that referenced this pull request Sep 11, 2026
Crane needs ROCm/HIP support to run on AMD GPUs,
which candle only has on an unmerged fork (huggingface/candle#3801,
xmiksay/candle branch feat/rocm-backend) rather than in any released
version.
cryptomilk added a commit to cryptomilk/Crane that referenced this pull request Sep 13, 2026
Crane needs ROCm/HIP support to run on AMD GPUs,
which candle only has on an unmerged fork (huggingface/candle#3801,
xmiksay/candle branch feat/rocm-backend) rather than in any released
version.
cryptomilk added a commit to cryptomilk/Crane that referenced this pull request Sep 14, 2026
Crane needs ROCm/HIP support to run on AMD GPUs,
which candle only has on an unmerged fork (huggingface/candle#3801,
xmiksay/candle branch feat/rocm-backend) rather than in any released
version.
cryptomilk added a commit to cryptomilk/Crane that referenced this pull request Sep 15, 2026
Crane needs ROCm/HIP support to run on AMD GPUs,
which candle only has on an unmerged fork (huggingface/candle#3801,
xmiksay/candle branch feat/rocm-backend) rather than in any released
version.
@cryptomilk

Copy link
Copy Markdown

This is also working on ROCm 10.

@cryptomilk

cryptomilk commented Sep 27, 2026 •

Copy link
Copy Markdown

@xmiksay could you add the four commits of https://github.com/cryptomilk/candle/tree/asn-feat-rocm-backend at the top?

@cryptomilk

Copy link
Copy Markdown

@Christof23 @ivarflakstad @EricLBuehler gentle ping

cryptomilk pushed a commit to cryptomilk/candle-rocm that referenced this pull request Sep 28, 2026
The im2col conv path unfolded the whole (b*h_out*w_out, c_in*k_h*k_w)
matrix before its single GEMM. Past a few hundred MB that allocation is
a memory-bandwidth cliff, and at Stable Diffusion decoder shapes it
does not fit at all: a 3x3 over 512 channels at 1024x1024 wants a
19.3 GB buffer and dies with hipErrorOutOfMemory -- reported on PR
huggingface#3801 (gfx1201, Real-CUGAN and SD/FLUX workloads), along with the
multi-GB allocations starving the display driver on a desktop GPU.

conv1d/conv2d now estimate the buffer up front and, above
IM2COL_MAX_BYTES (256 MB), run the identical im2col + GEMM over row
chunks: unfold at most the cap's worth of output rows, GEMM each chunk,
and write the rows into one pre-allocated result. GEMM throughput is
what it was -- only the unfolded buffer is bounded. Below the cap
nothing changes; the reporter's fallback suggestion (the direct
kernels) was measured by them at ~18x slower, so it stays untouched.

Measured on gfx1101, f32 3x3 over 512 channels: 68 ms at 256x256,
346 ms at 512x512, 1635 ms at 1024x1024 -- near-linear in pixel count
where the un-chunked path went superlinear and then OOMed; 1024x1024
did not previously run at all.

Chunk assembly is pinned against the single-shot path over caps of one
row, a non-dividing step and a single covering chunk, on contiguous
and transposed inputs, plus an end-to-end test that the public entry
point routes above the cap.
cryptomilk pushed a commit to cryptomilk/candle-rocm that referenced this pull request Oct 3, 2026
The im2col conv path unfolded the whole (b*h_out*w_out, c_in*k_h*k_w)
matrix before its single GEMM. Past a few hundred MB that allocation is
a memory-bandwidth cliff, and at Stable Diffusion decoder shapes it
does not fit at all: a 3x3 over 512 channels at 1024x1024 wants a
19.3 GB buffer and dies with hipErrorOutOfMemory -- reported on PR
huggingface#3801 (gfx1201, Real-CUGAN and SD/FLUX workloads), along with the
multi-GB allocations starving the display driver on a desktop GPU.

conv1d/conv2d now estimate the buffer up front and, above
IM2COL_MAX_BYTES (256 MB), run the identical im2col + GEMM over row
chunks: unfold at most the cap's worth of output rows, GEMM each chunk,
and write the rows into one pre-allocated result. GEMM throughput is
what it was -- only the unfolded buffer is bounded. Below the cap
nothing changes; the reporter's fallback suggestion (the direct
kernels) was measured by them at ~18x slower, so it stays untouched.

Measured on gfx1101, f32 3x3 over 512 channels: 68 ms at 256x256,
346 ms at 512x512, 1635 ms at 1024x1024 -- near-linear in pixel count
where the un-chunked path went superlinear and then OOMed; 1024x1024
did not previously run at all.

Chunk assembly is pinned against the single-shot path over caps of one
row, a non-dividing step and a single covering chunk, on contiguous
and transposed inputs, plus an end-to-end test that the public entry
point routes above the cap.
cryptomilk pushed a commit to cryptomilk/candle-rocm that referenced this pull request Oct 17, 2026
The im2col conv path unfolded the whole (b*h_out*w_out, c_in*k_h*k_w)
matrix before its single GEMM. Past a few hundred MB that allocation is
a memory-bandwidth cliff, and at Stable Diffusion decoder shapes it
does not fit at all: a 3x3 over 512 channels at 1024x1024 wants a
19.3 GB buffer and dies with hipErrorOutOfMemory -- reported on PR
huggingface#3801 (gfx1201, Real-CUGAN and SD/FLUX workloads), along with the
multi-GB allocations starving the display driver on a desktop GPU.

conv1d/conv2d now estimate the buffer up front and, above
IM2COL_MAX_BYTES (256 MB), run the identical im2col + GEMM over row
chunks: unfold at most the cap's worth of output rows, GEMM each chunk,
and write the rows into one pre-allocated result. GEMM throughput is
what it was -- only the unfolded buffer is bounded. Below the cap
nothing changes; the reporter's fallback suggestion (the direct
kernels) was measured by them at ~18x slower, so it stays untouched.

Measured on gfx1101, f32 3x3 over 512 channels: 68 ms at 256x256,
346 ms at 512x512, 1635 ms at 1024x1024 -- near-linear in pixel count
where the un-chunked path went superlinear and then OOMed; 1024x1024
did not previously run at all.

Chunk assembly is pinned against the single-shot path over caps of one
row, a non-dividing step and a single covering chunk, on contiguous
and transposed inputs, plus an end-to-end test that the public entry
point routes above the cap.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants