Repository navigation
Feat/rocm backend - #3801
Feat/rocm backend#3801xmiksay wants to merge 52 commits into
Conversation
9965a2b to
2790bee
Compare
Status update: CUDA/ROCm gap closure, plus four bugs that affect CUDALatest 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)
Gaps closed
MMQ performance
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 TestsEnvironment: RX 7800 XT (gfx1101, RDNA3), HIP 7.2.53211, rustc 1.97.0.
One caveat on earlier green reports from this branch: 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, Not implemented, with reasons
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 ( Two things worth a maintainer's eye
Verification limitsThere is no CUDA toolkit or NVIDIA GPU available here — |
Follow-up:
|
|
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 for both
That was true against whatever ROCm version this crate was developed and tested against (ROCm 7.2.4 / https://github.com/cryptomilk/Crane/tree/asn-rocm has the code and a compose.yaml: 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 :) ) I have prepared branche for Crane for ROCm as well. |
|
Oh this is awesome! We have quite some changes in the meantime :-) |
|
And if you are not aware of it yet, there is |
fe15b63 to
8a17ca0
Compare
|
It's great to see the broadening of HW support! Hope this gets to main as soon as possible. |
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.
|
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. |
|
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
Cause
Fix
Happy to share the benchmark if useful as a conv-heavy smoke/perf test. |
|
Follow-up: I tested the direct-conv fallback too, and it's not a free win. Flipping |
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.
8a17ca0 to
492cd19
Compare
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.
492cd19 to
fca7197
Compare
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.
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.
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.
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.
|
This is also working on ROCm 10. |
|
@xmiksay could you add the four commits of https://github.com/cryptomilk/candle/tree/asn-feat-rocm-backend at the top? |
|
@Christof23 @ivarflakstad @EricLBuehler gentle ping |
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.
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.
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.

ROCm/HIP backend for AMD GPUs
Continues @airpods69's #3424, which established the foundation this builds on:
rocm-rsfor the bindings, therocmfeature and its wiring throughDevice,Storageand the backend traits,rocm_backendshaped as a mirror ofcuda_backend, and a working device, allocator, RNG and matmul. Its openchecklist — 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
.hipkernels. This compilescandle-kernels/src/*.cu— thevery files the CUDA backend uses — with
hipcc, bridged by a ~360-line shim incandle-rocm-kernels/src/hip_shim/that shadows the CUDA header names thesources
#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_bfloat16typedef,__hmax_nan/__hmin_nanfor bf16, 16-bitatomicAddvia CAS,__dp4a,__vsubss4, and*_syncshuffle wrappers.Hand-written kernels drift, and the ones in #3424 already had:
utanwherecandle calls
utanh(sotanhwas broken on every dtype), abminimumthatdropped NaN handling, a
UNARY_OPmissing its in-place path, and aBINARY_OPmissing the out-type parameter comparison operators need — which is why
cmphad 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/-43across twofiles, none of which changes an existing kernel's behaviour under nvcc:
quantized.cu, +90/-43. Themul_mat_q*launchers had their tilegeometry hard-coded to the Ampere set. They now select through an
MMQ_ARCHmacro that resolves to
AMPEREunlessRDNA2/RDNA3is defined, and onlycandle-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 AMDbackend 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 thatdiff is two genuine bug fixes that apply to CUDA as well — see 5. below.
unary.cu, +65/-0. One new kernel family,transpose2d_*. Purelyadditive; no CUDA-side code calls it yet, though
cuda_backend'scopy_strided_srchas the same slow transpose and could.I have no CUDA toolchain here, so none of this is nvcc-compiled. The
MMQ_ARCHindirection is macro paste and the transpose is plain CUDA C++, butthat 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_backendis still empty.Non-invasive by design
Device::Rocm,Storage::Rocm,QStorage::Rocm,rocm_fwdandpub mod rocm_backendall sit behind#[cfg(feature = "rocm")]. There isdeliberately no dummy backend, unlike cuda/metal — so a default or
--features cudabuild gains no enum variant, no trait method and no publictype. The cost is ~40 extra
#[cfg]lines inside candle-core, which is whatstorage.rsandtensor.rsalready do.Features:
rocm(default path, im2col + rocBLAS GEMM) and opt-inmiopen = ["rocm", "rocm-rs/miopen"], which swaps convolutions onto MIOpen theway
cudnnlayers overcuda. A plainrocmbuild never links libMIOpen.Evidence it works
Every candle test suite, on the GPU, as of the head of this branch:
candle-core --lib--features miopen)tensor_testsquantized_testsbilinear_testsconv_testsgrad_testsmatmul_testspool_testslayout_testscandle-nnopscandle-nn --libcandle-rocm-kernels --libugmicro-kernels (--features "rocm ug")test_device!gained a fifth ident, so every existing suite generates its*_rocmcase 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 arebit-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 verifiedagainst 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:
CI parity, run locally:
cargo fmt --all -- --check,cargo clippy --workspace --tests --examples --benches -- -D warnings,cargo check --workspace(alsowith
+avx2), andcargo test --workspaceall pass. The arm-neon and macOSmetal jobs are unverified here for want of a cross toolchain.
Included
BackendStorage/BackendDevicesurface — 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.rocm_fwdfor softmax, rms_norm, layer_norm, sigmoid and allthree rotary-embedding variants, across BF16/F16/F32/F64.
activations), the fused DMMV decode kernel, MMQ (
mul_mat_q*) for largerbatches, and dequantize-then-GEMM. Plus
quantize_imatrixand a raw weightpointer for downstream kernels.
moe_gemm_ggufat every batch size, via the MMVQ kernelswith the expert index folded into the weight pointer.
hardware, so this is conversion-based, but it is exercised.
ugmicro-kernels (--features "rocm ug"), compiled through the sameruntime hipcc path, and
KernelCache::custom_functionso downstream cratescan compile kernels of their own in the same dialect and disk cache.
.github/workflows/ci_rocm.yaml).rocm-rsneedsROCm headers, not a device, so the job extracts seven
-devdebs (~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.rocmfeature forcandle-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 resultsare 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
dp4aceiling, and generates165–185 GB/s of weight traffic against 624 GB/s of bandwidth — bound by
neither. Raising
mmq_xfrom 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
nrows ofk; 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 withno 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
fwdprefers it over MMQ for f16 from a batch of 256 up.Worst cell over three dtypes and four shapes,
mmq_msover the dense path(above 1.0, dense wins):
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 sameshape: 1.2–2.1x, rising to 3.0x at larger batches.
The transpose kernel is not quantized-specific —
copy_strided_srcroutes anyswap 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.mdhas the full list with the reason for each, sothe gaps don't have to be found by grep. The substantial ones:
fast_mmq/fast_mmvq. They bind to sources using inline PTX,nvcuda::wmmaand__reduce_add_sync. The portablemul_mat_q*kernels arewhat this launches instead.
moe_gemm, and the prefill half ofmoe_gemm_gguf) —nvcuda::wmmacompiled into a host-sidelibmoe.a. Thequantized MoE is implemented, so
FusedMoeGGUFruns at every batch; prefillgoes through the mat-vec kernel, which is correct but not tile-optimised.
candle-flash-attn{,-v3}depend oncandle-corewithfeatures = ["cuda"]unconditionally, so there is nothing to gate.hipGraphcounterpart here.upsample_nearest1d(CUDA bails too), and i16/i32 for reduce/indexing/cast(the shared
.cunever instantiates them).Upstream bugs found
Building this surfaced five defects in existing backends, all documented in
candle-rocm-kernels/README.mdunder "Divergences fromcuda_backend". Thefirst four are left alone — each needs its own fix and tests, and this PR is
large enough:
cuda_backend::index_addpasses the source layout as the freshlyallocated accumulator's, so a non-zero
start_offsetis applied twice.metal_backenddoesn't do this andTensor::scatterusesLayout::contiguous; ROCm follows those.quantized/cuda.rs's dequantize fallback builds a typed slice over aVec<u8>viafrom_raw_parts.Vec<u8>guarantees only byte alignment, sothat's UB for any block type with stricter alignment. ROCm uses
read_unaligned.cpu_backend'sMatMul::fcollapses the batch loop whenb_skip == 0 && a_skip == m*kwithout checking that the lhs rows arecontiguous.
conv_transpose1d's col2im path hits exactly that shape, soCPU
conv_transpose1dwith batch > 1 is wrong from batch 1 onward.F8E4M3launch names a kernel that does not exist.cuda_backend::kernel_namebuilds{root}_f8e4m3fromDType::as_str(),while the shared sources spell their entry points
_f8_e4m3or_fp8_e4m3— no symbol incandle-kernels/srcends in_f8e4m3. Sincecuda_backend/utils.rsdoes dispatch theF8E4M3arm of everyMap*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 atest against the shipped
.cutext.quantized.cuare fixed here rather than onlyreported, 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), andindexed_moe_forwardstrode its weights byQK_Kinstead of the dtype's
qk(6a35ea18).Also worth flagging for review:
rocm-rsis a third-party 0.5.x bindgen craterather than something with
cudarc's track record. It's an optional dependencyreachable only via
--features rocm, and it's used withdefault-features = falsebecause its defaultgpu-sortfeature builds anamdgcn kernel through a proc macro that shells out to a nested nightly
-Zbuild-stdinvocation. Happy to discuss alternatives.