Skip to content

rocm: AMD Instinct (CDNA/gfx90a) support with multi-GPU device placement - #602

Open
ewindisch wants to merge 26 commits into
antirez:mainfrom
ewindisch:ewindisch/mi210
Open

rocm: AMD Instinct (CDNA/gfx90a) support with multi-GPU device placement#602
ewindisch wants to merge 26 commits into
antirez:mainfrom
ewindisch:ewindisch/mi210

Conversation

@ewindisch

Copy link
Copy Markdown

Adds AMD Instinct (CDNA2, gfx90a — tested on dual MI210) support to the existing ROCm backend, which previously targeted RDNA3/Strix Halo only, plus native multi-GPU device placement and a set of wave64-specific performance fixes. Squashed history below is preserved as a series of well-scoped commits (each originally developed/tested as its own branch); happy to split into separate PRs if preferred, though GitHub doesn't support true cross-fork stacking so this landed as one PR — see commit-by-commit history for incremental review.

What's included

  1. CDNA/gfx90a build supportmake mi200/make mi300 targets, DS4_ROCM_HAS_WMMA_W32/DS4_ROCM_HAS_MFMA_W64 arch macros, link fixes for this toolchain.
  2. CDNA MFMA Q8_0 batched matmul kernel — the RDNA-only WMMA kernel didn't compile on gfx90a at all; adds the MFMA counterpart. Validated against a CPU f16 reference (make test-rocm-matmul-q8).
  3. Wave64 shuffle-mask/width fix__shfl_*_sync masks built from threadIdx.x were wrong on wave64 (right by coincidence on wave32); fixes the actual HSA_STATUS_ERROR_EXCEPTION crash on real hardware.
  4. Wave64 indexer/MoE hotlist port — ports the indexer WMMA kernel's per-wave tile assignment to wave64 (was silently leaving half the output tiles unwritten on CDNA); gates wave32-shaped hotlist MoE kernels off on wave64 in favor of the already-correct portable kernels.
  5. Batch graph-prefill correctness fix — root cause of a long-standing, hard-to-characterize multi-GPU/batch-prefill correctness bug: ROCm's multi-row fallback in the batched HC-normalization path only normalized row 0, leaving other prompt rows zero. Deterministic once isolated from sampling-temperature noise (which had made it look like GPU-scheduling nondeterminism across many earlier debugging passes). Fixed by porting the row-aware normalization the CUDA backend already had. Verified with 20+ independent multi-GPU runs, byte-identical across runs, coherent prompt-dependent generation.
  6. Native multi-GPU device placement — in-process pipeline/layer placement across GPU tiers for ROCm (mirrors the CUDA backend's existing multi-tier scaffolding), including cross-device tensor copy with peer-access validation and a pinned-host-bounce fallback for GPUs without P2P/XGMI.
  7. Per-tier q8 f16 dequant-cache preload fix — a preload-sweep "give up after first denial" latch was a single global instead of per-GPU-tier, so a budget denial on one tier permanently stopped the preload sweep from ever trying a less-constrained tier.
  8. Wave64-native kernel rewrites — two of the hottest decode-path kernels (matmul_q8_0_pair_f32_..., moe_gate_up_mid_decode_lut_...) were tiled for 32-lane wavefronts (inherited from the RDNA3-only codebase) and, while correct on wave64 via commit 3's fix, left roughly half of CDNA's per-wavefront throughput unused. Adds true 64-lane-native counterparts, dispatched via a runtime wave-size check, with the original kernels left completely untouched (zero behavior change on RDNA3/Strix Halo). 46% and 24.5% reductions in average per-call kernel time respectively, measured via rocprofv3 kernel-level profiling on a 100-token decode run. Both validated against a CPU double-precision reference with non-block-aligned dimensions to stress tail handling.

Testing

  • make test-rocm-matmul-q8, make test-rocm-matmul-pair-q8, make test-rocm-moe-gate-up-mid-hwarp32 all pass on gfx90a.
  • 20+ independent multi-GPU generation runs, byte-identical outputs across runs, no garbling.
  • Real end-to-end generation verified on a dual-MI210 box running an 80GB IQ2-quantized MoE model with --gpu-devices 0,1 --gpu-vram auto.
  • All changes are additive/gated (new arch macros, new kernels behind runtime wave-size checks, new code paths behind g_n_gpus/device-count checks) — RDNA3/Strix Halo and single-GPU code paths are unmodified.

Known gap, not addressed by this PR

Quality mode + SSD streaming hits a pre-existing "missing compact selected experts" decode error under ROCm. This reproduces independently of everything in this PR and is being tracked separately.

birdetta and others added 26 commits July 23, 2026 01:47
Adds `make mi200` / `make mi300` build aliases and DS4_ROCM_HAS_WMMA_W32 /
DS4_ROCM_HAS_MFMA_W64 capability macros so device code can tell RDNA
(wave32, WMMA) apart from CDNA (wave64, MFMA) at compile time, since host
code has no target-arch macros to test.

Also fixes the ROCm link itself on this toolchain: hipcc links PIE by
default while this gcc compiles non-PIE by default, which fails at link
time with R_X86_64_32 relocation errors unless the C objects are also
built -fPIE. And since not every distro puts the ROCm lib directory on
the default loader path, an rpath is added so the binaries don't require
LD_LIBRARY_PATH.

`make test-rocm-matmul-q8` is wired in here; its source lands in the
next commit in this stack.
The batched Q8_0 GEMM used for large prefill chunks was built directly
on __builtin_amdgcn_wmma_f32_16x16x16_f16_w32, an RDNA3/RDNA4 wave32
intrinsic that CDNA parts don't have (they use MFMA instead, and run
wave64). This is why the plain `make rocm ROCM_ARCH=gfx90a` build failed
outright with "needs target feature wmma-256b-insts,wavefrontsize32".

Adds matmul_q8_0_f32_batch_mfma_4w_kernel: same 64x64 output tile and
K_TILE=32 activation staging as the WMMA kernel, rebuilt on
v_mfma_f32_16x16x16f16 with 4 waves of 64 threads instead of 4 of 32,
each lane now dequantizing a quarter of the K range instead of all of
it. The MFMA fragment layout (A/B/D lane mapping) was verified against
a CPU reference on real gfx90a hardware before writing the kernel, not
taken from documentation.

Host dispatch (ds4_gpu_matmul_q8_0_tensor) picks between the two at
runtime via cuda_device_wave_size(), since host-side code has no
target-arch macro to test at compile time -- only the device passes do.
An unrecognized wave size falls through to the existing portable
sharedx kernel rather than launching a matrix-core kernel that would
have compiled to a no-op for that target.

tests/test_rocm_matmul_q8.c checks the new kernel against both the
portable kernel and a CPU reference that models the f16 rounding the
matrix core applies to its operands, run via `make test-rocm-matmul-q8
ROCM_ARCH=gfx90a`. On an MI210 this kernel matches the CPU f16
reference to 0.000% of output rms, and the portable kernel to 0.137%
(the portable path stays in f32 activations, so that residual is
expected f16 quantization, not a bug). Confirmed the test actually
catches a layout error, not just quiet correctness, by deliberately
perturbing one epilogue index and rerunning it: 491% error, immediately
caught.

RDNA (gfx1151) is unaffected; `make rocm ROCM_ARCH=gfx1151` still
builds and takes the unchanged WMMA path.
The backend built its __shfl_*_sync masks from threadIdx.x, e.g.
`0xffu << (threadIdx.x & 24u)` for an 8-lane group. On wave32 that
happens to line up with the lane's position, but on wave64 threads
32..39 compute the same mask as lanes 0..7: the mask names the wrong
half of the wave. HIP validates a shuffle's mask against the set of
lanes actually reaching it, so this reliably aborted the queue with
"HSA_STATUS_ERROR_EXCEPTION: An HSAIL operation resulted in a hardware
exception" as soon as a kernel using one of these reductions ran (first
hit: moe_gate_up_mid_decode_lut_qwarp32_kernel during decode, confirmed
via rocgdb).

Separately, five call sites did `__shfl_sync(FULL_WARP_MASK, x, 0)` with
no explicit width, which defaults to `warpSize` -- 64 on CDNA -- so
those broadcasts silently pulled from the wrong lane instead of lane 0
of the intended 32-wide group.

Adds ds4_lane_in_wave() (wraps __lane_id() so it's correct regardless
of hardware wave size) and ds4_subwave_mask<WIDTH>(), which builds the
mask from the true lane position. DS4_WARP32_MASK replaces the old
FULL_WARP_MASK constant at every one of these sites, and the five
bare broadcasts get an explicit width of 32. On wave32 and on CUDA
ds4_subwave_mask<32u>() reduces to the same all-ones constant as
before, so this is a no-op there; the fix only changes wave64 behavior.

Verified with rocgdb that the prior state crashed on `hi -n 4`
mid-decode, and that the same run now completes cleanly through
generation on an MI210 after this change.
…t kernels

indexer_scores_wmma128_kernel assigns one 16x16 output tile per hardware
wave (8 waves of 32 threads, one tile each). On a wave64 part the same
256-thread block only has 4 waves, so half the c_sh output tiles were
silently left unwritten -- wrong scores, not a crash, since nothing
about the wmma calls themselves is wave-size-specific once the tile
loop covers the right range. Ports it to cover TILES_PER_WAVE = 8 /
N_WAVES tiles per wave, computed from DS4_ROCM_HAS_MFMA_W64 (4 waves of
64, 2 tiles each on CDNA; unchanged 8 waves of 32, 1 tile each on RDNA).

Separately, the hot-expert WMMA kernels in ds4_rocm_moe.cuh (used when
a small number of experts see most of the batch) launch 32*MT-thread
blocks and hand one matrix fragment to each 32-thread group -- a
wave32-specific shape that would need reworking for CDNA's 64-wide
waves, not just a launch-config tweak. Rather than port those now,
gates use_wmma_hot and use_iq2_gate_wmma off when
cuda_device_wave_size() != 32, so CDNA takes the portable expert-batch
kernels already used in quality mode. Correct, if not yet the fastest
possible path for the hot-expert case on CDNA.
…ing prefill

The SSD-streaming batched graph-prefill path (metal_graph_prefill_layer_major's
split_commands branch, the "gpu prefill layer N/M" progress messages) produces
output that is almost entirely independent of the actual prompt on wave64
hardware. Confirmed on an MI210 with --dump-logits and with real generation:
every broken run -- 2, 9, 10, 11, 15, 23, and 151 real tokens, several totally
different prompts -- converges on nearly the same top-5 vocab logits
(~token ids 1453/2435/3934/16291/57168), which is the signature of a
computation that stopped depending on its input, not a subtle numerical
divergence. Bisected the exact trigger against
metal_graph_use_streaming_decode_prefill's existing token-count cutoff: below
it, prefill runs one token at a time through the per-token decode-style path
(the "gpu streaming prefill token N/M" messages) and matches the CPU backend;
above it, prefill switches to the batched path and breaks, with the crossover
landing exactly on that existing cutoff (confirmed at both its default value
and by overriding it).

What this rules out, checked directly rather than assumed:
  - Not the wave64 shuffle-mask fixes from the previous commit in this stack --
    those made the crash go away; this is a different, silent bug that
    predates them and survives them.
  - Not the new MFMA matmul kernel -- gated to n_tok >= 256, never reached by
    any of these reproductions.
  - Not attention kernel choice -- forcing the batched path off the fast
    attention_static_mixed_heads8_online_kernel and onto the hipBLAS GEMM
    fallback (by disabling its dispatch condition and rebuilding) produces the
    same broken output to three decimal places.
  - Not g_quality_mode -- quality mode disables most of the opt-in fast
    kernels and still produces the same broken logits on the same prompt.
  - Not a wave-size assumption newly found in the "batch-only" kernel set
    (compressor_*, hc_expand4*, head_rms_norm, the MoE tile-building kernels,
    dequant/f32_to_f16) -- audited each for hardcoded 32-lane assumptions the
    way the indexer WMMA kernel had; found none.

That leaves the ROCm-specific SSD-streaming expert-paging machinery unique to
that branch (rocm_graph_stream_layer_expert_load and the
layer_pagein/layer_readahead/layer_pread/batch_selected_addr machinery around
ds4.c:33096 onward) as the untested remainder. Isolating further needs
either a model that fits in one GPU's VRAM without SSD streaming (to test
whether the plain non-streaming batch path is also broken, which would move
suspicion back to something arch-generic) or per-layer intermediate-state
dumps compared against the CPU backend -- both out of reach on this hardware
in the time available.

The per-token streaming decode-prefill path is proven correct at every length
tested, including a 151-token prompt, so rather than ship a known-wrong batch
path, this makes the ROCm backend prefer streaming unconditionally on wave64
hardware: metal_graph_streaming_decode_prefill_max_tokens() returns
UINT32_MAX when ds4_gpu_wave_size() == 64, after the existing env-var override
(which still works, and still lets DS4_ROCM_DISABLE_STREAMING_DECODE_PREFILL=1
force the broken batch path back on for whoever picks up the real fix).
metal_graph_use_streaming_decode_prefill's separate quality-mode exclusion is
also lifted specifically on wave64, since quality mode goes through the same
broken batch path and produces the same garbage. Adds ds4_gpu_wave_size() to
the shared GPU API (ds4_gpu.h) with a real implementation in
ds4_rocm_runtime.cuh and a Metal stub returning 0, since ds4.c is compiled
once per backend and needs the symbol regardless of which backend provides it.

This costs prefill throughput on long prompts on CDNA -- streaming is
sequential per token where the batch path would parallelize -- but wrong
output is a worse failure mode than slow output. RDNA (Strix Halo, the
original ROCm target) is untouched: ds4_gpu_wave_size() returns 32 there, so
it keeps the batch path's original 18/64-token cutoff exactly as before.

Known gaps this does not cover, both unverified either way rather than
confirmed broken:
  - Quality mode combined with SSD streaming hits an unrelated pre-existing
    error during decode ("ROCm SSD streaming routed MoE missing compact
    selected experts") that reproduces identically on the pre-this-commit
    tree, so it is not new here, but it does mean quality mode + SSD
    streaming does not fully work on this hardware regardless of this fix.
  - The plain GPU-resident batch path without SSD streaming was never
    exercised -- the only available model (80.76 GiB) does not fit in one
    MI210's 64 GiB without streaming -- so whether the underlying bug is
    SSD-streaming-specific or more general is genuinely unknown.

Validated end to end: real generation (not just --dump-logits) at 2, 10, 21,
and 151 real prompt tokens all produce coherent, on-task output matching the
CPU backend's register; make test-rocm-matmul-q8 still passes; make rocm
ROCM_ARCH=gfx1151 and make cpu both still build clean.
The ROCm multi-row fallback in ds4_gpu_hc_split_weighted_sum_norm_tensor collapsed every HC row, then passed the full output to the single-row rms_norm_weight_tensor entry point. Only row zero was normalized; prompt rows 1..N-1 remained zero in norm_out. Batched graph prefill therefore stopped depending correctly on most prompt tokens, producing attenuated, largely prompt-independent logits in both multi-GPU mode and the SSD batch path.

Use ds4_gpu_rms_norm_weight_rows_tensor with the row count derived from the output tensor, matching the correction already present in the CUDA implementation. The layer-0 last-prompt-row attn_norm changed from all-zero to bit-identical with the streaming reference; greedy output changed from 特 to We.

Verification on two MI210s (gfx90a): make mi200 and test-rocm-matmul-q8 pass. Twenty independent multi-GPU runs all exited 0, produced all 43 requested dumps, generated the same 'We need' continuation, and were byte-identical at every layer. Worst norm ratio versus the single-GPU reference was 0.277 at layer 17; layer 40 was 0.258, versus the prior 340-1500x failures.
metal_graph_debug_dump_tensor's dump hook always read row 0 of a
[n_tokens][per_row] batch tensor. Batched graph prefill evaluates all
prompt rows in one call at position 0, so comparing a specific token's
state (e.g. the final prompt row) required this row selector -- without
it, only row 0 was ever inspectable, masking exactly the kind of
last-row-specific corruption this PR's root cause turned out to be.

Adds DS4_ROCM_GRAPH_DUMP_ROW (byte offset = row * n_f32 * 4), env-gated,
no effect when unset.
The CDNA SSD-streaming batch-prefill workaround was masking the same batched HC normalization bug fixed in the preceding commit. With every row now normalized, forced batch-prefill logits are prompt-dependent and correct-shaped again for 14-, 16-, and 99-token prompts, so remove the wave64 always-streaming override and restore the original token cutoff and quality selection.

This also fixes a separate hang exposed after correct batch prefill. The final layer may leave an asynchronous selected-expert read in the shared read-job pool because no next layer exists to consume it. Hotlist seeding immediately reuses that pool and previously waited forever on the stale 147-job batch. Drain both batch and single selected-read state when releasing the temporary layer cache.

Validated on MI210/gfx90a with the 80.76 GiB Flash model: three forced batch-prefill dump-logits cases (14, 16, and 99 prompt tokens) all exit successfully; the first-token argmaxes are prompt-dependent (We, I, We), rather than the old content-independent token 1453 signature. After restoring the default cutoff, two >64-token layer-major SSD-prefill runs complete real greedy generation with coherent prompt-specific continuations at 2.17-2.29 prefill tok/s and 1.26-1.28 generation tok/s. make mi200 succeeds.

Known gap retained honestly: --quality with SSD streaming still reaches the pre-existing decode-time 'missing compact selected experts' error after successful batch prefill. This commit does not claim to repair that independent quality-mode cache-selection issue.
Ports ROCm's single-device-only limitation away, mirroring ds4_cuda.cu's
existing "GPU-only multi-tier" pipeline-placement scaffolding: per-device
streams/hipBLAS/hipBLASLt handles, a per-device selective weight cache
wired into cuda_model_range_ptr (the single chokepoint every existing
kernel launcher already funnels weight lookups through, so none of the
~200 launchers needed per-call edits), and cross-device tensor copy with
peer-access validation plus a pinned-host-bounce fallback (peer access
between the two MI210s on this box is unavailable -- PCIe-only, no
bridge/XGMI). Does not implement --cuda-tensor-parallel (row/col-sharded
compute split live across two tiers within one layer); that stays
CUDA-only exactly as before. New file rocm/ds4_rocm_mgpu.cuh; struct
ds4_gpu_tensor gained a device_id field (see that file's header comment
for why the layout has to stay byte-identical across ds4_rocm.cu and
ds4_rocm_compat.cu, which reach it via different routes).

Six real bugs found and fixed this session, each confirmed against a live
dual-GPU run of an 80GB model split across both cards (--gpu-devices 0,1
--gpu-vram auto, no --ssd-streaming; GPU0: layers 0-28+embedding, GPU1:
layers 29-42+output head):

1. System prereq, not a code bug: this box's ulimit -l (locked memory)
   was 8 MiB, far too small for ROCm's DMA staging buffers across two
   devices -- caused a driver-level pin-buffer exhaustion flood
   ("DmaBlitManager::getBuffer failed to pin a resource") followed by a
   hard crash in libamdhip64 on the next unrelated kernel launch. Fixed
   by raising memlock to unlimited via /etc/security/limits.d/ (requires
   a fresh login session; PAM limits apply at login, not retroactively).

2. ds4_gpu_register_model_map_no_copy was hipHostRegister-ing the whole
   ~80 GiB model with hipHostRegisterMapped (SVM/zero-copy). With two GPU
   agents registered this overran the driver's resident-SVM working-set
   limit ("amdgpu: SVM mapping failed, exceeds resident system memory
   limit" flood, then a crash). Removed entirely -- not needed, since the
   per-device selective cache uploads every GPU-placed tensor before any
   layer runs, so the "uncached fallback" zero-copy pointer this
   registration existed for is never on the hot path here.

3. Performance, not correctness: after fix #2, weight loading fell back
   to demand-paged cudaMemcpy straight off the mmap -- ~15 MiB/s despite
   this box's NVMe RAID1 doing ~2 GiB/s on a plain sequential dd (and
   PCIe genuinely running x16 Gen4, confirmed independently via
   rocm-validation-suite's PEBB module at ~25.8 GB/s). Fixed with a
   synchronous bulk sequential pread() to warm the page cache before the
   per-tensor copy loop, plus a single reusable pinned staging buffer for
   the H2D copies instead of letting HIP re-pin a bounce buffer on every
   one of ~1300 calls per tier. Full 80 GiB load: 20+ minutes -> well
   under a minute.

4. g_cuda_tmp (cuda_tmp_alloc's scratch buffer, used e.g. for F32->F16
   activation conversion before a cuBLAS/hipBLASLt GEMM) was a single
   global buffer shared across both tiers -- a tier-1 kernel could get
   handed a tier-0-resident pointer and fault ("Memory Fault Error ...
   kernel: f32_to_f16_kernel"). Fixed by making it per-tier.

5. cuda_q8_f16_ptr / cuda_q8_f16_transpose_ptr (the Q8->F16 weight-dequant
   cache backing the cuBLAS/hipBLASLt fast matmul path) had the same bug
   class: cached purely by (model_map, offset, weight_bytes, in_dim,
   out_dim) with no device dimension, so it could hand back another
   tier's device-resident dequant buffer -- silently wrong matmul output
   rather than a fault, since the returned pointer is a valid-looking
   VRAM address just on the wrong device. Added a device_id field to both
   cache-entry structs and filter every lookup on the active device.

6. Added a same-device guard before launching f32_to_f16_kernel in
   ds4_gpu_tensor_copy_f32_to_f16, matching CUDA's own reference
   (ds4_cuda.cu already refuses rather than launching cross-device here).

Net result: the dual-GPU run now completes end-to-end with no crash
(prefill + generation both finish, ~2-3 tok/s). Generation output is
still wrong, though: the first token is garbled, later decode tokens read
as locally-plausible English continuations -- traced live with a new
DS4_MGPU_TRACE=1 env var in metal_graph_set_active_tier_decode/_batch
(ds4.c), no failed cross-tier copies, tier-switch pattern looks correct,
pointing away from the decode-to-decode hidden-state handoff and toward
the prefill-stage output-head/final-logits computation instead. Also
notable: --ssd-streaming refuses to combine with multi-GPU placement, so
this run exercises the plain (non-streaming) batch graph-prefill path,
which single-GPU builds never reached for this 80GB model (always forced
onto the streaming decode-prefill workaround from
ewindisch/mi210-05-prefill-correctness to fit in one card's VRAM). Still
open whether this is the same defect PR5 worked around or a new one in
code ROCm had simply never exercised before. See
project_ds4_rocm_cdna_port.md memory for full debugging notes and next
steps (a smaller model that fits one MI210 without streaming would let
the two be compared directly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same bug class as the g_cuda_tmp and q8_f16-dequant-cache fixes in the
previous commit: g_shared_gate_up_stream/_ready_event/_tmp/_pending were
single global instances reused across tiers. The stream and event are
device-bound (created while one tier's device was active, invalid to
launch/sync against once a different tier's device is current), and the
scratch buffer + "pending" flag didn't distinguish "tier 0 has an
outstanding async op" from "tier 1 does" -- a tier-1 call could reuse
tier 0's stream (invalid cross-device stream use) and/or race a
wait/reuse against the wrong tier's in-flight op. Indexed all five by
g_current_logical_tier, matching g_cuda_tmp.

Confirmed via a live dual-GPU prefill run (--dump-logits) that this
specific function isn't exercised during prefill (bit-identical argmax
before/after: id 1453 "特", logit 12.522047) -- it's evidently
decode-only, and prefill's shared-expert gate/up goes through the
already-tier-safe synchronous ds4_gpu_shared_gate_up_swiglu_q8_0_batch_tensor
instead (default stream, no cross-call caching). Keeping the fix anyway:
it's a real latent bug for decode once the placement work in the
previous commit's multi-tier prefill correctness issue is resolved, and
this file is otherwise dead code with respect to that investigation.

The multi-tier batch-prefill correctness bug documented in the previous
commit remains open: exhaustively audited every static/global cache in
the rocm/*.cuh kernel launchers (this was the last of three: g_cuda_tmp,
cuda_q8_f16_ptr/_transpose_ptr, and this file -- no others exist outside
the SSD-streaming-specific machinery, which isn't active in this
scenario). Also directly ruled out the "portable q8 kernels" matmul
fallback path being buggy in isolation: forcing single-GPU onto that
exact code path via DS4_CUDA_NO_Q8_F16_CACHE=1 still produces the correct
top token ("We", logit 34.13 vs the cached path's 36.64 -- an expected/
normal precision difference, not a bug). The prefill logits correlation
between single-GPU and multi-GPU stays weak (~0.21) with a compressed
dynamic range on multi-GPU (max logit 12.5 vs 36.6), suggesting an
attenuated/partially-missing contribution rather than pure corruption --
but the source of that attenuation is still unlocated. See
project_ds4_rocm_cdna_port.md for full notes; next productive step is
likely per-layer intermediate-state comparison or a smaller model that
fits one MI210 without SSD streaming, to get a single-GPU baseline on
the exact same non-streaming batch-prefill code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same bug class as the previous two commits' g_cuda_tmp and
g_shared_gate_up_* fixes: g_q8_f16_bytes and g_q8_f16_disabled_after_oom
were single global instances shared across tiers.
cuda_q8_f16_cache_release_all() freed every cached range's device_ptr
without cudaSetDevice()'ing to that range's owning device first --
cudaFree on a pointer belonging to a different device than the current
one doesn't actually free it, leaking VRAM and desyncing the accounting.
And because the disable-after-OOM flag and byte counter were global, one
tier hitting its (much tighter, since multi-GPU weights already consume
most of that tier's budget) cache ceiling would disable and release the
OTHER tier's still-valid, still-in-use cache too. Added
cuda_q8_f16_cache_release_tier(tier) so an OOM on one tier only tears
down that tier's own entries, and indexed the byte counter and disable
flag by tier via a new cuda_q8_f16_tier() helper (mirrors
cuda_shared_gate_up_tier() from the previous commit).

Also added DS4_Q8F16_TRACE (env-gated, matches the existing
DS4_MGPU_TRACE convention): logs every q8_f16_ptr/_transpose_ptr cache
store (label, offset, dims, active device, tier) and every
cuda_model_range_ptr strict-lookup miss that falls through to the legacy
non-device-aware range cache.

This does NOT fix the multi-tier batch-prefill correctness bug from the
previous two commits -- confirmed via a live dual-GPU per-layer
hc_ffn_post dump comparison against the known-good single-GPU baseline.
Investigated and ruled out as the cause of that bug specifically:
  - Cross-device q8_f16 cache hits: the per-device identity check
    (device_id == active_device) never missed in a traced run (0 STRICT
    MISS events), and the lazy caching that happens during real
    inference (ds4_rocm_matmul.cuh etc. call cuda_q8_f16_ptr directly,
    not just the ds4.c preload sweep) correctly picks up each tier's own
    device context via the existing ds4_gpu_set_current_device plumbing.
  - Dequant content corruption: dumped the first cached tensor's
    dequantized f16 values and the raw q8_0 source bytes and compared
    against a manual CPU-side Q8_0 decode -- bit-exact match.
  - The q8_f16 cache as sole cause, full stop: forcing
    cuda_q8_f16_cache_allowed() to always return 0 under g_n_gpus > 1
    (i.e. fully disabling this cache, not just letting budget deny it)
    still reproduced the same layer 5->6 divergence blowup in the
    hc_ffn_post comparison, so this commit does not attempt that -- it
    would cost the acceleration for no correctness benefit.
  - A real but separate bug found along the way: the one-shot
    "attention output warmup" GEMM (cuda_q8_f16_warmup_attention_output_
    a/b_gemm) and the preload sweep's optional_q8_preload_disabled latch
    in ds4_gpu_cache_q8_f16_range are both plain (non-per-tier) static
    locals -- the latter means the FIRST tensor the preload sweep is
    denied on (tier 0, budget-limited almost immediately under
    multi-GPU) permanently stops the sweep from even trying the rest of
    tier 0 or any of tier 1, though tier 1 still gets cached lazily
    during real inference via the matmul-launcher call sites. Neither
    explains the bug on its own (see above).

New finding worth recording for the next session: the divergence is
NOT deterministic. Two back-to-back runs of the identical binary,
config, and prompt produced different generated tokens and different
per-layer divergence magnitudes/onset (layer 5 norm-ratio 2.4x then
8.9x vs. baseline across two runs; layer 0-4 stayed stable and
reproducible both times). That points at a race or scheduling-order
sensitivity somewhere in the multi-tier dispatch rather than a static
data/logic bug, which the extensive static-cache auditing across this
and the previous two commits was aimed at. Worth auditing next:
function-local "run once" static latches other than the two named above
(grep for `static int ... = 0;` inside rocm/*.cuh), and whether any
async op related to first-use-per-layer state (MoE routing/hotlist,
indexer, attn_compressor -- all introduced or reintroduced within
layers 2-6 of this model's architecture, unlike layers 0-1) is missing a
synchronization point that only sometimes loses the race.

See project_ds4_rocm_cdna_port.md for full session notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Accumulated debugging instrumentation from the multi-GPU correctness
investigation, kept for future ROCm debugging since it's genuinely useful
and costs nothing by default:

- DS4_FFN_FORCE_SYNC: forces a full device sync immediately after routed-MoE
  and after shared-expert computation, every layer. Used to test for a
  same-GPU kernel-ordering race at the routed-MoE/shared-expert boundary
  (result: negative -- ruled that out as a contributing factor).
- DS4_FORCE_SYNC_LAYER / ds4_moe_layer_force_sync(): scopes a full device
  sync to a specific layer index, used to bracket every kernel in
  routed-MoE's live dispatch path at layer 40 specifically (result: negative
  -- ruled out a kernel-ordering race within routed-MoE at that layer).
- ds4_moe_integrity_hash64 / ds4_moe_integrity_dump_device /
  ds4_moe_integrity_compare_selected_weights: dumps and FNV-1a-hashes
  layer-scoped intermediate tensors and cross-checks device-resident
  selected-expert weight ranges against the model mmap, to distinguish a
  kernel receiving a discrete stale/wrong input or weight image from a
  genuine compute bug. Used during the final integrity-verification pass
  that helped confirm the row-normalization root cause.

All gated behind env vars, no effect when unset. None of this is the fix
itself -- see the row-normalization and SSD-prefill-drain fixes for that.
make mi200/make mi300 targets, DS4_ROCM_HAS_WMMA_W32/DS4_ROCM_HAS_MFMA_W64
arch macros, -fPIE + rpath link fixes for this gcc/hipcc combo.
MFMA counterpart of the RDNA-only WMMA batched Q8_0 matmul kernel -- the
thing that made gfx90a fail to compile at all originally. Validated by
tests/test_rocm_matmul_q8.c against a CPU f16 reference.
Fixes __shfl_*_sync masks built from threadIdx.x (wrong on wave64, right
by coincidence on wave32) and 5 sites with an implicit warpSize-width
broadcast. Fixed an actual HSA_STATUS_ERROR_EXCEPTION hardware-exception
crash.
…otlist kernels

Ports indexer_scores_wmma128_kernel's per-wave tile assignment to wave64
(was silently leaving half the output tiles unwritten on CDNA). Gates two
wave32-shaped 'hotlist' MoE WMMA kernels off on wave64, falling back to
the portable kernels quality mode already used.
Root cause: ROCm's batched HC fallback (ds4_gpu_hc_split_weighted_sum_norm_tensor)
collapsed every prompt row's HC contribution correctly, but then normalized only
row 0 via the single-row rms_norm_weight_tensor entry point, leaving norm_out
zero for every other row -- so batched graph prefill produced attenuated,
largely prompt-independent logits on wave64, in both the SSD-streaming batch
path and (as later confirmed) native multi-GPU placement.

Fix: use ds4_gpu_rms_norm_weight_rows_tensor with the row count derived from
the output tensor, matching the correction already present in the CUDA
implementation. Also restores full batched SSD-streaming prefill throughput
by removing the prior wave64 always-streaming workaround now that the actual
defect is fixed, and drains a final-prefill async selected-expert read state
that could otherwise hang after correct batch prefill.

Verified: 20 independent multi-GPU runs byte-identical at every layer; forced
batch-prefill dump-logits correct and prompt-dependent at 14/16/99 tokens;
two real >64-token generations complete coherently at ~2.2 prefill tok/s
(vs. ~1.0 under the old workaround) and ~1.27 generation tok/s.

Known separate gap, not claimed fixed here: --quality --ssd-streaming still
hits a pre-existing 'missing compact selected experts' decode-time error.
Splits pipeline/layer placement across GPU tiers for ROCm (mirroring
ds4_cuda.cu's existing CUDA-only multi-tier scaffolding), letting an 80GB
model that doesn't fit on one MI210 run split across two without needing
--ssd-streaming. New rocm/ds4_rocm_mgpu.cuh: per-device streams/hipBLAS/
hipBLASLt handles, a per-device selective weight cache wired into the one
chokepoint every kernel launcher already used, and cross-device tensor copy
with peer-access validation + pinned-host-bounce fallback (no P2P bridge
between these two MI210s).

Three real per-tier-globals bugs found and fixed along the way -- global
scratch/cache/stream state shared across tiers instead of indexed per
device, silently handing one tier's device-resident pointer to the other:
the F32->F16 conversion scratch buffer, the shared-expert async gate/up
state, and the Q8->F16 dequant cache's disable/free accounting.

The actual multi-GPU correctness bug (nondeterministic-looking garbled
output, ~40-60% of runs) turned out to share PR5's root cause exactly --
see that merge for the fix. An extensive but ultimately unnecessary
mitigation (gating 9 cuBLAS GEMM call sites off under multi-GPU due to
suspected runtime algorithm nondeterminism) was tried, found to not
measurably affect the failure rate either way once properly A/B tested,
and has been dropped from this branch's history now that the real cause
is fixed, to avoid paying its performance cost for no benefit.

Verified: 20/20 repeated multi-GPU runs deterministic and byte-identical
at every layer, correct 'We need' continuation matching the single-GPU
reference.
optional_q8_preload_disabled was a single static int shared across
tiers, so a tier-0 budget denial (tier 0 is VRAM-tight under multi-GPU)
permanently stopped the preload sweep from ever trying tier 1, which
may still have headroom. Made it per-tier (g_q8_f16_preload_disabled[
DS4_MAX_GPUS]), matching the existing per-tier convention used by
g_q8_f16_disabled_after_oom.
Both matmul_q8_0_pair_f32_sharedx_warp_rows_w32_kernel and
moe_gate_up_mid_decode_lut_qwarp32_kernel were tiled for 32-lane
wavefronts (inherited from this project's original RDNA3/Strix-Halo-
only codebase), made correct on wave64/CDNA via PR3's sub-wave
shuffle-mask partitioning but never reshaped for wave64 efficiency --
each 64-lane wavefront ran two time-multiplexed 32-wide passes instead
of one native 64-wide one. Kernel-level profiling on a 100-token
decode run showed these two kernels alone accounted for 32.1% of all
GPU kernel execution time.

Adds a wave64-native counterpart of each, dispatched via the existing
cuda_device_wave_size()==64 gating idiom (same pattern PR2 used for
the CDNA MFMA batched Q8_0 kernel). The original w32/qwarp32 kernels
are completely untouched, so RDNA3/Strix Halo sees zero behavior
change.

matmul_q8_0_pair_f32_sharedx_warp_rows_w64_kernel: instead of two
independent 32-lane sub-groups each computing one row (PR3's
correctness fix), all 64 lanes cooperate on a single row -- lanes 0-31
consume even Q8_0 blocks, lanes 32-63 consume odd blocks in the same
iteration, combined by one native 64-wide shuffle-reduction. Rows per
block halved (32->16) with grid doubled to compensate. 46% reduction
in average per-call kernel time (1166.9us -> 630.1us) on a 100-token
decode profile.

moe_gate_up_mid_decode_lut_hwarp32_kernel: widens the cooperating-lane
group per row from 8 lanes (quarter_warp_sum_f32) to 16 lanes
(half_warp_sum_f32) for this model's confirmed xq_blocks==16 decode
shape, again halving rows-per-block and doubling the grid to preserve
CU occupancy. 24.5% reduction in average per-call kernel time
(1599.2us -> 1207.6us).

Both validated against a CPU double-precision reference and
cross-checked bit-exact/near-bit-exact against their untouched w32
counterparts (tests/test_rocm_matmul_pair_q8.c,
tests/test_rocm_moe_gate_up_mid_hwarp32.c), using non-block-aligned
dimensions to stress tail handling.

Three other w32-shaped hotspot kernels from the same profiling pass
(matmul_q8_0_hc_expand, matmul_q8_0_f32_sharedx_warp_rows,
grouped_q8_0_a) were attempted with the same treatment and did not
pan out -- two showed clear regressions on independent structural
retries (root cause: the originals already achieve full wavefront
occupancy via two independent parallel rows, or the win doesn't
transfer to their extra per-row scalar epilogue), and one showed only
noise-level improvement. None of those are included here.
moe_down_sum6_qwarp32_kernel used 8-lane cooperation (quarter_warp_sum_f32)
per output row -- structurally identical in shape to
moe_gate_up_mid_decode_lut_qwarp32_kernel before its own wave64 fix (also
8-lane originally), which won 24.5% after widening to 16-lane cooperation
and halving rows-per-block / doubling the grid to preserve CU occupancy.
This kernel has a trivial single-scalar-store epilogue and does not already
use both wavefront halves for two independent full-width rows, so it matches
neither regression pattern this session established.

Adds moe_down_sum6_hwarp32_kernel applying the same fix: 16-lane cooperation
(half_warp_sum_f32), rows-per-block halved 32->16 with the grid doubled to
match. Dispatched via the existing cuda_device_wave_size()==64 gating idiom
at both routed-MoE down-projection call sites, behind an opt-out
(DS4_ROCM_DISABLE_MOE_DOWN_SUM6_HWARP32); the original qwarp32 kernel is
completely untouched, so RDNA3/Strix Halo sees zero behavior change. Unlike
the gate/up/mid fix this kernel has no "% 16 == 0" dispatch gate, so it must
remain correct for any midq block count.

6.5% reduction in average per-call kernel time (579.1us -> 541.5us) on a
100-token decode profile, measured via rocprofv3. Validated against a CPU
f64 reference and cross-checked near-bit-exact against the untouched qwarp32
kernel (tests/test_rocm_moe_down_sum6.c), using non-16/32-aligned dimensions
(out_dim=777, midq_blocks=22) to stress the tail of the per-lane strided
accumulation loop. Adds a test-only direct entry point
(ds4_gpu_test_moe_down_sum6_tensor) to drive either kernel in isolation.

Co-Authored-By: Claude <noreply@anthropic.com>
ds4_gpu_matmul_f16_tensor()'s ordered_decode path dispatches
matmul_f16_ordered_chunks_kernel, which the w32 build launches as a
32-thread block. On a native-64-lane wavefront (CDNA/gfx9xx) that leaves
the other 32 lanes of every dispatched wavefront completely idle -- not a
two-independent-rows tradeoff like the other w32 kernels this session
touched, just unused capacity.

Adds matmul_f16_ordered_chunks_w64_kernel, which divides the same row's
dot product across all 64 threads instead of 32, halving the per-lane
chunk length. The reduction is still done by shared-memory store plus a
single-thread sequential sum (matching the original's approach, not a
shuffle reduction) to keep this a minimal, low-risk change isolated to the
launch width. Dispatched via the existing cuda_device_wave_size()==64
gating idiom, behind an opt-out (DS4_ROCM_DISABLE_F16_ORDERED_W64); the
original w32 kernel is completely untouched, so RDNA3/Strix Halo sees zero
behavior change.

19.3% reduction in average per-call kernel time (346.9us -> 280.0us) on a
100-token decode profile, measured via rocprofv3. Validated against a CPU
f64 reference and cross-checked near-bit-exact against the untouched w32
kernel (tests/test_rocm_matmul_f16_ordered.c), using the real router
projection shape (in_dim=4096, out_dim=256). Adds the
test-rocm-matmul-f16-ordered Makefile target to build/run it.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant