Skip to content

cuda: AMD RDNA4 Q1_0/Q2_0 — HIP-path vec_dots (+37%/+16% decode), opt-in quant dedup, opt-in hipBLASLt prefill routes - #116

Open
The-Monk wants to merge 3 commits into
PrismML-Eng:prismfrom
The-Monk:rdna4-q1q2
Open

cuda: AMD RDNA4 Q1_0/Q2_0 — HIP-path vec_dots (+37%/+16% decode), opt-in quant dedup, opt-in hipBLASLt prefill routes#116
The-Monk wants to merge 3 commits into
PrismML-Eng:prismfrom
The-Monk:rdna4-q1q2

Conversation

@The-Monk

@The-Monk The-Monk commented Aug 11, 2026

Copy link
Copy Markdown

What this is

The AMD side of the Q1_0/Q2_0 story. This fork's HIP release bundles currently run Prism's binary/ternary formats through kernels tuned for NVIDIA; on RDNA4 hardware that leaves a lot on the table. This PR contributes three self-contained pieces from our RDNA4 fork (The-Monk/llama.cpp roc8, the kernel base of The Rock8), re-measured on this tree on 2× Radeon AI PRO R9700 (gfx1201, ROCm/TheRock 7.14).

All numbers below: Bonsai-27B (qwen35 hybrid), 1 GPU, llama-bench -r 5 (tg) / -r 3 (pp).

1. HIP-path vec_dot for Q2_0 (+37%) and Q1_0 (+16%), on by default

The existing Q2_0 vec_dot extracts symbols with dynamic-selector __byte_perm chains, which are built around NVIDIA's PRMT; on HIP they lower poorly and the decode GEMV pays for it end-to-end. This PR adds an AMD path using the same split this file already uses for unpack_q1_0_bytes: bit-spread the raw 2-bit codes into bytes and use the identity dot(s,u) = dot(c,u) − sum(u), applying the offset once via the q8_1 stored sum — plain shift/mask + dp4a, no per-code arithmetic. The CUDA path is untouched.

The second commit gives Q1_0 the same treatment with the binary form of the identity (s = 2c − 1dot(s,u) = 2·dot(c,u) − sum(u)), replacing the select-chain fallback in unpack_q1_0_bytes that materialized ±1 bytes on a VALU-bound kernel.

tg128, Bonsai-27B, 1× R9700 stock this PR Δ
Q2_0 (ternary) 34.52 ± 0.08 47.20 ± 0.20 +36.7%
Q1_0 (binary) 54.53 ± 0.36 63.03 ± 0.70 +15.6%

Perplexity is identical (wikitext-2, 20 chunks: 11.5603 ± 0.47269 both builds, Q2_0), and binary now decodes faster than ternary, as its byte ratio says it should.

2. Opt-in activation-quant dedup (GGML_HIP_DEDUP_MMVQ_QUANT), default OFF

Sibling mmvq matmuls that read the same activation tensor (wq/wk/wv, ffn_gate/ffn_up, wqkv/wqkv_gate) each launch a quantize_row_q8_1 that recomputes byte-identical output. With the flag set, the quantization is computed once per activation tensor and reused; the cache lives on the backend context, is keyed by tensor pointer, and is reset at every graph_compute, so entries cannot survive a graph rebuild. GGML_HIP_DEDUP_MMVQ_QUANT_BATCH additionally widens it to ne11>1 (spec-decode verify batches).

Measured here: Q2_0 48.20 (+2.1% on top of #1), Q1_0 64.93 (+3.0% on top of the new Q1_0 dot). On our fork the same lever is worth considerably more under MTP self-speculative decode, where the verify pass multiplies the redundancy — that's why the _BATCH variant exists. Lossless: the cached bytes are the bytes the skipped launch would have produced; PPL run with both flags on is byte-identical to stock.

3. Opt-in Q1_0/Q2_0 hipBLASLt prefill routes, default OFF, optional dependency

Large-M (prefill) matmuls routed through hipBLASLt int8 GEMM (dequantize once → cached int8 weights + per-channel scale; activations int8 per call), with a self-tuning per-shape algorithm cache — gfx1201 has no hipBLASLt cost model and the heuristic picks badly. Includes the weight-cache invalidation registry (hipblaslt_wcache): caches are keyed on device addresses, so the backend drops entries when it frees a buffer — without this, multi-model runs and server model swaps can silently serve stale weights from a reused allocation.

warm tune cache pp1024 vs stock kernel
Q2_0 1453.14 ± 0.39 +37.5% (1056.63)
Q1_0 1470.94 ± 1.74 +17.8% (1248.33)

Knobs per format (<FMT>Q1_0,Q2_0): GGML_HIP_<FMT>_HIPBLASLT_PREFILL=1 (enable), _MTHRESH=<n> (default 384 — deliberately conservative because the crossover is model-shape-dependent; override per model), _TUNE_CACHE=<path>, _NOTUNE=1, _WCACHE_MB=<n>, _FP8=1 (dequantize to e4m3 instead of int8).

Build impact: find_package(hipblaslt QUIET) — optional. Not found (e.g. Windows HIP, where hipBLASLt doesn't exist) → routes compile as inert stubs and the release bundles are unaffected. CUDA builds compile the same stubs (__HIP_PLATFORM_AMD__-guarded).

What we tried and deliberately did NOT include

Our fork runs Q2_0 with nwarps=8 / rows_per_block=3 on RDNA4. Transplanting those constants onto this tree's kernel was a disaster (nwarps=8 → 8.2 t/s) to neutral (full sweep: rpb 2/3/4 × nwarps 2/4/8 all ≤ +0.7% over stock). Launch-geometry constants do not transfer between kernel implementations; this tree's geometry is already at its optimum, so no tuning changes are included.

Testing

  • test-backend-ops test -o MUL_MAT: all pass, both with default env and with both dedup flags set.
  • PPL byte-identical stock vs patched(+dedup+batch), wikitext-2 20 chunks.
  • Hardware: 2× Radeon AI PRO R9700 (gfx1201), TheRock ROCm 7.14, -DCMAKE_HIP_ARCHITECTURES=gfx1201.
  • Not tested here: nvcc compile of the new files (no NVIDIA box on hand). The non-HIP sides are plain-C++ stubs / the untouched original code paths; happy to fix any CI fallout promptly.

Reproduce:

cmake -B build -DGGML_HIP=ON -DCMAKE_HIP_ARCHITECTURES=gfx1201 -DCMAKE_BUILD_TYPE=Release
cmake --build build -j --target llama-bench test-backend-ops
./build/bin/llama-bench -m Ternary-Bonsai-27B-Q2_0.gguf -ngl 999 -p 0 -n 128 -r 5
GGML_HIP_DEDUP_MMVQ_QUANT=1 ./build/bin/llama-bench -m ... 
GGML_HIP_Q2_0_HIPBLASLT_PREFILL=1 GGML_HIP_Q2_0_HIPBLASLT_TUNE_CACHE=/tmp/t.bin \
  ./build/bin/llama-bench -m ... -p 1024 -n 0   # run twice; first run pays the tuning cost

Standing offer

The dual-R9700 box these numbers come from is available for validating HIP release candidates or future AMD-touching PRs — happy to run your release CI artifacts against real gfx1201 before they ship.

🤖 Generated with Claude Code

…in hipBLASLt prefill routes

HIP-guarded vec_dot_q2_0_q8_1 (dynamic-selector __byte_perm lowers poorly on
AMD): Bonsai-27B Q2_0 decode 34.52 -> 47.20 t/s on gfx1201, PPL byte-identical.
Opt-in GGML_HIP_DEDUP_MMVQ_QUANT skips redundant sibling activation quantize
launches (+2-3% decode here, more under spec-decode verify). Opt-in
GGML_HIP_{Q1_0,Q2_0}_HIPBLASLT_PREFILL routes large-M matmuls to hipBLASLt
int8 GEMM with a self-tuning algo cache and buffer-free weight-cache
invalidation: pp1024 +37.5% (Q2_0) / +17.8% (Q1_0) warm. hipBLASLt is an
optional dependency; without it (Windows HIP) and on CUDA builds the routes
compile to inert stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ode)

Same treatment as the Q2_0 HIP path: bit-spread raw code bits c in {0,1} into
{0,1}-byte dp4a operands and fold dot(s,u) = 2*dot(c,u) - sum(u) through the
q8_1 stored sum, replacing the select-chain fallback in unpack_q1_0_bytes.
Bonsai-27B Q1_0 tg128 on 1x R9700: 54.53 -> 63.03 t/s (64.93 with
GGML_HIP_DEDUP_MMVQ_QUANT=1). test-backend-ops MUL_MAT: all pass. CUDA path
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@The-Monk The-Monk changed the title cuda: AMD RDNA4 Q1_0/Q2_0 — HIP-path Q2_0 vec_dot (+37% decode), opt-in quant dedup, opt-in hipBLASLt prefill routes cuda: AMD RDNA4 Q1_0/Q2_0 — HIP-path vec_dots (+37%/+16% decode), opt-in quant dedup, opt-in hipBLASLt prefill routes Aug 11, 2026
@khosravipasha
khosravipasha requested a balanced review from Copilot August 11, 2026 17:41
@khosravipasha

Copy link
Copy Markdown
Collaborator

@The-Monk Thanks for the chnages, will do a more careful review this week.
Impressive changes, good to have better support for AMD.

By the way Q1_0 is fully upstreamed to llama.cpp so migth be worth also trying to upstream it there, so check whetehr they have added better AMD support since we merged things.
Similar for Q2_0, we fully upstreamed it but only caveat is that there group size is 64 so a bit different from our group size 128. More info here: https://github.com/PrismML-Eng/Bonsai-demo#upstream-status-for-ternary

Happy to merge these here but also worst trying to upstream to main llama.cpp as well since most things are already there too (with the caveat of Q2_0 being slightly different there).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds RDNA4 optimizations for Q1_0/Q2_0 inference in the shared CUDA/HIP backend.

Changes:

  • Optimizes HIP vector-dot decoding.
  • Adds optional activation-quantization deduplication.
  • Adds optional hipBLASLt prefill routes, tuning, and weight caches.

Policy note: The PR description declares Claude Code generation, conflicting with AGENTS.md:36-40, which prohibits AI-written PR descriptions.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
ggml/src/ggml-hip/CMakeLists.txt Adds optional hipBLASLt discovery.
ggml/src/ggml-cuda/vecdotq.cuh Adds HIP/MUSA low-bit vector-dot paths.
ggml/src/ggml-cuda/mul_mat_q2_0_hipblaslt.cuh Declares the Q2_0 prefill route.
ggml/src/ggml-cuda/mul_mat_q2_0_hipblaslt.cu Implements Q2_0 hipBLASLt prefill.
ggml/src/ggml-cuda/mul_mat_q1_0_hipblaslt.cuh Declares the Q1_0 prefill route.
ggml/src/ggml-cuda/mul_mat_q1_0_hipblaslt.cu Implements Q1_0 hipBLASLt prefill.
ggml/src/ggml-cuda/mmvq.cu Adds activation-quantization deduplication.
ggml/src/ggml-cuda/hipblaslt_wcache.cuh Declares cache invalidation APIs.
ggml/src/ggml-cuda/hipblaslt_wcache.cu Implements invalidator registration.
ggml/src/ggml-cuda/ggml-cuda.cu Integrates routes and cache lifecycle.
ggml/src/ggml-cuda/common.cuh Adds FP8 conversion and cache state.
Suppressed comments (2)

ggml/src/ggml-cuda/mul_mat_q1_0_hipblaslt.cu:415

  • The claim that all uses share one stream is false: the backend dispatches concurrent branches and devices independently. Both cache builders publish the entry immediately after enqueueing requantization, so another stream can hit and read it before initialization completes. Publish a completion event and wait on hits, or synchronize before insertion as the existing Hopper cache does at mmq-hopper-q1.cu:149-153.
// Returns cached int8 weight (building it on first miss if within budget), or
// nullptr -> caller must requant on-the-fly. Build + all uses share the stream,
// so the one-time requant is correctly ordered before any GEMM that reads it.

ggml/src/ggml-cuda/mul_mat_q2_0_hipblaslt.cu:425

  • The cache entry is published immediately after requantization is enqueued. A concurrent branch or request on another stream can hit this entry and launch GEMM before the converted weights are initialized. Publish a completion event and wait on hits, or synchronize before insertion as the existing Hopper cache does at mmq-hopper-q1.cu:149-153.
    g_wcache_bytes += need;
    auto res = g_wcache.emplace(key, c);
    return &res.first->second;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +786 to +789
const int qs0 = bq2_0->qs[offset + 0] | (bq2_0->qs[offset + 1] << 8) |
(bq2_0->qs[offset + 2] << 16) | (bq2_0->qs[offset + 3] << 24);
const int qs1 = bq2_0->qs[offset + 4] | (bq2_0->qs[offset + 5] << 8) |
(bq2_0->qs[offset + 6] << 16) | (bq2_0->qs[offset + 7] << 24);
Comment on lines +1230 to +1231
const bool dedup_hit = dedup_quant &&
ctx.mmvq_quant_cache_tensor == src1 && ctx.mmvq_quant_cache_buf;
Comment on lines +900 to +903
int man = (int) (ax * 512.0f + 0.5f);
if (man > 7) {
man = 7;
}
Comment on lines +487 to +491
if (src0->type != GGML_TYPE_Q1_0) return false;
if (src1->type != GGML_TYPE_F32 || dst->type != GGML_TYPE_F32) return false;
if (src0->ne[2] != 1 || src0->ne[3] != 1) return false;
if (src1->ne[2] != 1 || src1->ne[3] != 1) return false;
if (src0->ne[0] != src1->ne[0] || src0->ne[0] % Q1K != 0) return false;
Comment on lines +452 to +456
if (src0->type != GGML_TYPE_Q2_0) return false;
if (src1->type != GGML_TYPE_F32 || dst->type != GGML_TYPE_F32) return false;
if (src0->ne[2] != 1 || src0->ne[3] != 1) return false;
if (src1->ne[2] != 1 || src1->ne[3] != 1) return false;
if (src0->ne[0] != src1->ne[0] || src0->ne[0] % Q2K != 0) return false;
Comment on lines +398 to +399
std::map<const void *, cached_w> g_wcache_i8;
std::map<const void *, cached_w_f8> g_wcache_f8;
// cached (requant paid once), the rest fall back to on-the-fly pool requant.
// hipMalloc failure also falls back -- never OOM-crash (the Stage-3 lesson).
struct cached_w { int8_t * q8 = nullptr; float * wscale = nullptr; size_t bytes = 0; };
std::map<const void *, cached_w> g_wcache;
Comment on lines +560 to +563
// ---- activation int8/e4m3 (per-token) + 4-byte accumulator, from the pool ----
ggml_cuda_pool_alloc<int8_t> x8 (ctx.pool(), (size_t)K * M);
ggml_cuda_pool_alloc<float> asc (ctx.pool(), (size_t)M);
ggml_cuda_pool_alloc<int32_t> acc (ctx.pool(), (size_t)N * M); // i32 (int8) or reinterpreted f32 (fp8)
Review hardening (Copilot flag on the converted-weight caches): entries
are published under the mutex right after the requant kernel is launched,
so ordering vs the GEMM that reads them was guaranteed only for
same-stream consumers. Record a build_done event on the build stream and
make any consumer on a different stream hipStreamWaitEvent on it before
using the entry (falling back to on-the-fly requant if the wait cannot
be issued). Events are destroyed with their entries in the invalidators.

No behavior change on the current single-compute-stream-per-device
backend (the wait never fires); the previously documented invariant is
now enforced. Verified: build clean, both routes smoke-tested on 1x
R9700 (Q1_0 pp1024 1500 t/s, Q2_0 1487 t/s, build+hit paths exercised).

CCA (Claude Code Augmented)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants