cuda: AMD RDNA4 Q1_0/Q2_0 — HIP-path vec_dots (+37%/+16% decode), opt-in quant dedup, opt-in hipBLASLt prefill routes - #116
Conversation
* Optimized arm NEON(+DOTPROD) q1 dot * Implemented arm I8MM nrc==2 for q1 dot * Applied copilot advice about feature guards for Q1 Arm LUTs
* Implemented ARM NEON DP q1 4x4 repack * Hoisted out scaling by b_d in gemm * Added 4x8 NEON I8MM repack kernels * Cleanup for q1 arm repack * Added missing aliases for arch fallback * Corrected unused var statements * Extended table guard condition to account for i8mm w/o dp build Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Q1_0/Q2_0 had no x86 vec_dot path (arch-fallback routed the generic functions to a scalar loop). Add an AVX-512-VNNI/AVX-512VL fast path guarded by __AVX512VNNI__ && __AVX512VL__, scalar fallback otherwise: - helper ggml_hsum_i32_8_vnni to reduce _mm256_dpbusd_epi32 accumulators - Q1_0: build a sign mask from the bit field, blend +qy/-qy, accumulate with dpbusd(ones, sel) - Q2_0: vectorized 2-bit unpack (replicate-4 + 16-bit shift/mask + pack), then dpbusd(codes, qy) - dpbusd(ones, qy) = sum((code-1)*qy) Q2_0 prefill ~3.9x / decode ~3.0x vs scalar on EPYC 9655; Q1_0 ~parity (the +/-1 scalar loop already auto-vectorizes). Bit-exact vs scalar (test-quantize-fns + standalone unit test); KL-divergence vs FP16 unchanged between scalar and VNNI builds. Co-authored-by: Brian <brian@Brians-MacBook-Pro.local>
The Q1_0/Q2_0 VNNI work landed in the generic quants.c, but on x86 the generic Q1_0 path is dead (arch/x86 has an AVX2 ggml_vec_dot_q1_0_q8_0 that wins), and Q2_0 only reached x86 via an arch-fallback alias. - arch/x86/quants.c: add ggml_vec_dot_q2_0_q8_0 (AVX-512-VNNI + scalar fallback), reusing the existing hsum_i32_8 helper. Math is unchanged. - arch-fallback.h: drop the x86 ggml_vec_dot_q2_0_q8_0 alias so x86 uses the arch impl, mirroring how q1_0 is already wired. - quants.c: restore portable scalar for q1_0/q2_0 generic (the VNNI in the generic file was x86-only and is now in arch/x86).
Adds an experimental tensor-core path for Q1_0 mul_mat at batch >= 128: activations quantized to int8 with per-128 absmax scales, weights repacked once per tensor to dense sign-bit words, dequant-in-SMEM via branchless SIMD unpack feeding 64x64x32 int8 wgmma, exact per-block fp32 scaling on the accumulator drain. Hybrid dispatch: persistent stream-K grid for starved shapes, fixed tile grid otherwise. Opt-in at build time (-DGGML_CUDA_HOPPER_Q1=ON -DGGML_CUDA_CUTLASS_DIR=...) and at runtime (env GGML_HOPPER_Q1); falls through to stock MMQ otherwise. Measured on H100 SXM (1-bit test model): pp512 +8.3%, pp2048 +8.6% vs stock MMQ; test-backend-ops MUL_MAT q1_0 43/43; logit-KLD vs stock path 0.0048 mean (noise-level).
Kernels templated on weight width (1- or 2-bit dense fields); Q2_0 adds a per-tensor dense repack of the 2-bit (q-1) fields and a branchless SIMD unpack (per-byte q - 1 via __vsub4, all four field values handled). Same gating, dispatch, and activation-quant path as Q1_0. Measured on H100 SXM (ternary test model): pp512 +7.7%, pp2048 +8.1% vs stock MMQ; test-backend-ops MUL_MAT q1_0+q2_0 86/86; logit-KLD vs stock path 0.0013 mean (noise-level).
- repack cache: key on (device, wdata, N, K, wbits) with a mutex (ggml may dispatch from one host thread per device), and publish entries only after a one-time stream sync so consumers on other streams cannot observe uninitialized dense buffers - per-device attr_set / SM count (a second GPU previously skipped the dynamic-SMEM opt-in and inherited device 0's SM count) - CUDA_CHECK on allocations and attribute calls - defensive int8 clamp in the activation quantizer (unreachable in exact arithmetic; insurance against fp rounding at the boundary) - CMake: target-scoped compile definitions/includes/options instead of directory-wide; option help text covers Q2_0
The path's kernels are sm_90a-only fatbins (wgmma does not exist on Blackwell); cc >= 900 alone admits cc 1200+ where the launch fails. Blackwell support is a separate tcgen05 path.
… version (PrismML-Eng#50) The windows-cpu pack step hardcoded VC\Redist\MSVC\14.44.35112, which broke when the windows-2025 runner image moved to a newer MSVC (arm64 job failed, fail-fast cancelled x64). Glob the VS product and redist version directories and pick the newest match so runner-image updates stop breaking the release. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The iOS job configures with LLAMA_BUILD_TOOLS=OFF, but LLAMA_BUILD_APP defaults to ON, so the llama-app target builds without the tools include paths and fails on '#include "build-info.h"'. Upstream's build-apple.yml passes -DLLAMA_BUILD_APP=OFF for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ML-Eng#51) * kv-cache: add optional per-channel K-cache mean-centering (Q4_0 only) GGML_TYPE_Q4_0 is a symmetric quantizer, so a K channel with a real, consistent nonzero mean across tokens wastes dynamic range encoding that constant bias. This adds an opt-in mechanism that subtracts a fixed per-(kv-head, channel) bias from K right before it is written into the cache in llama_kv_cache::cpy_k(), gated strictly to k->type == GGML_TYPE_Q4_0. Subtracting the same bias from every cached key is exactly softmax-invariant: it adds the same constant (q . k_bar) to every logit in a query's row, which softmax does not see. Nothing else in attention needs to change, so this is a zero decode-time-cost quantization-fidelity improvement. llama_context_params gets a new path_kv_mean_center field (default NULL); llama_init_from_model() hard-rejects it when the K cache type isn't Q4_0, matching the existing convention for other cache-type-gated mismatches (e.g. "V cache quantization requires flash_attn"). llama_kv_cache::load_kv_mean_center() loads the bias tensors from a GGUF file and applies them; a require_q4_0 escape hatch (used only by tests) exists to validate the underlying math against an unquantized cache without confounding it with real quantization error. Also tags the K tensor right before cpy_k() with the existing cb() graph-build hook ("k_cache_in"), so calibration tooling can capture exactly the tensor that gets written into the cache regardless of what RoPE/rotation preprocessing a given architecture applies upstream. * common: add --kv-mean-center flag and GGUF bias-file writer Adds the CLI-facing side of K-cache mean-centering: common_params gains kv_mean_center_path (plumbed into llama_context_params via common_context_params_to_llama), and --kv-mean-center takes a path to a bias file generated by tools/kv-mean-center. The bias file format is a small GGUF file with one F32 tensor per layer, named "kv_bar.blk.<il>.k", holding n_embd_head_k(il) * n_head_kv(il) values laid out as [n_embd_head_k, n_head_kv]. The writer lives in common/kv-mean-center.* so it can be shared between the calibration tool and the test suite (which needs to synthesize a bias file to check the underlying math). * tools: add llama-kv-mean-center calibration tool New tool, following the tools/imatrix convention: loads a model, runs a plain text calibration corpus through it in chunks, and captures the "k_cache_in" tensor tagged in llm_graph_context::build_attn() via the same backend-scheduler eval-callback mechanism llama-imatrix uses to capture activations (params.cb_eval). The per-(head,channel) mean across all calibration tokens is written out as a bias file consumable by --kv-mean-center. * tests: add K-cache mean-centering regression + invariance tests Uses the same tiny-synthetic-model machinery as test-llama-archs.cpp (llama_model_saver + llama_model_init_from_user with a deterministic random tensor initializer), trimmed to plain LLM_ARCH_LLAMA, to check: - regression safety: two independent contexts with centering disabled produce bit-for-bit identical logits, and a Q4_0 K cache with no bias file loaded still decodes normally (cpy_k()'s new code path is a true no-op when k_bar is empty). - the --kv-mean-center gate: a non-Q4_0 K cache with a bias file is hard-rejected by llama_init_from_model(), while Q4_0 succeeds end-to-end through a real decode. - the softmax-invariance argument itself, against an unquantized F32 K cache with a synthetic nonzero bias applied through the exact same cpy_k() code path (bypassing the Q4_0 gate via load_kv_mean_center()'s require_q4_0=false test seam): output logits match the uncentered baseline to fp32 rounding (nmse ~5e-10 in practice), confirming the math without confounding it with real quantization error. * docs: document K-cache mean-centering Explains the technique, the softmax-invariance argument, usage, the bias file format, and the current scope/limitations (Q4_0-only, plain KV cache only, standard dense/GQA attention path only). * kv-mean-center: address review feedback - kv-mean-center.cpp: the K tensor captured at "k_cache_in" can be F16 or BF16 depending on backend/compute settings, not just F32. The collector was asserting F32-only and reinterpreting raw bytes as float*, which either aborts or silently computes a garbage mean on non-F32 backends. Now accepts F32/F16/BF16 and converts to F32 via ggml_fp16_to_fp32_row/ggml_bf16_to_fp32_row before accumulating. - common/arg.cpp: wire --chunks into the LLAMA_EXAMPLE_KV_MEAN_CENTER example set so the flag the tool's own README documents is actually available, instead of being silently filtered out by the shared arg parser. - docs/kv-mean-center.md: replace non-ASCII "~=" and "." characters (was U+2248 and U+00B7) with ASCII equivalents, matching the project's ASCII-only docs convention.
Generates a calibration corpus from the model itself via a temporary llama-server, removing the need for an external calibration text file. Includes a degenerate-output guard based on gzip compression ratio. Validated end to end: the resulting bias agrees with one calibrated on a standard multi-domain calibration set to within sampling noise.
Print only the header comment as help text instead of grepping every comment line, exit nonzero on unknown options, add gzip to the dependency preflight and the Requires line, and stop forcing -ngl 99: the server's own --n-gpu-layers default now applies unless -g is given.
…r-selfgen-corpus kv-mean-center: add make-calib-corpus.sh self-generated corpus helper
…composes with K rotation) (PrismML-Eng#53) * kv-cache: support mean-centering on hybrid-memory models Hybrid (recurrent + attention) models keep a standard llama_kv_cache for their attention sublayers, but llama_init_from_model only accepted path_kv_mean_center when the whole memory module was that cache, so any hybrid model failed to load a bias file. Route the load through get_mem_attn() for hybrid memory; bias tensors are matched by model layer id and layers absent from the attention cache are skipped, which the loader already handles. * kv-mean-center: document that centering must not be combined with the K-cache rotation Measured end to end, the pre-rotation bias applied post-rotation is worse than either feature alone; strengthen the README note into a warning with the measured numbers. * kv-mean-center: record the calibration basis and reject a rotation mismatch at load The bias lives in the basis the calibration run's K cache used: the collector taps the exact tensor cpy_k() writes, after any Hadamard rotation, and the rotation is gated on the K cache being quantized. A calibration run with the default F16 cache therefore measures the unrotated basis, and applying that bias to a rotated cache measurably degrades quality instead of improving it (KLD vs F16 cache 0.00144 rotation alone vs 0.0020 with the mismatched combination), while a bias calibrated with -ctk q4_0 composes (0.00111, the best of all measured configurations). The tool now detects whether the rotation was active from the captured tensor's ancestry, records it in the output file as kv_mean_center.k_rot, and load_kv_mean_center() rejects a bias whose basis does not match the inference-time rotation state (files predating the flag load with a warning). Docs updated with the calibrate-with-matching-settings rule and the measured numbers. * kv-mean-center: address review feedback Cover SWA memory layouts: the bias load now also routes through llama_kv_cache_iswa and llama_memory_hybrid_iswa (base and SWA sub-caches), so hybrid models with sliding-window attention are no longer rejected. Validate the kv_mean_center.k_rot metadata type before reading it, so a malformed bias file produces a loader error instead of an assertion abort. Run the Q4_0 cache-type validation before the basis check, so an unsupported cache type keeps its actionable error even when the file's basis would also mismatch, and hoist it out of the tensor loop. The gate test now uses a basis-matching file for the F16-cache case so it exercises the cache-type gate specifically.
The Q2_0 2-bit quant type had no entry in test-quantize-fns's per-type error-threshold tables, so it fell through to the default thresholds (MAX_QUANTIZATION_TOTAL_ERROR = 0.002, MAX_DOT_PRODUCT_ERROR = 0.02) that are tuned for 4-bit-and-up formats. A 2-bit format cannot meet those, so the test failed deterministically on every platform (absolute error 0.008678 > 0.002, dot-product error 0.141111 > 0.02). Q2_0 stores one fp16 scale per 128-element block with no zero-point, which puts its error in the same band as the ternary formats (tq1_0/tq2_0 measure 0.008681 / 0.141345 and pass at 0.01 / 0.15). Add matching Q2_0 thresholds (0.01 absolute, 0.15 dot product) rather than loosening the shared 2-bit k-quant constant, so the Q2_K / IQ2_S checks are unaffected.
…fns-q2_0-thresholds tests: add Q2_0 error thresholds to test-quantize-fns
…1_0/Q2_0 dp4a fix (PrismML-Eng#55) * speculative: dspark block-diffusion drafter + CUDA Markov resample; Q1_0/Q2_0 dp4a fix Adds the dspark speculative-decoding drafter and two low-bit/decode improvements. dspark drafter (common/speculative.cpp, src/models/dspark.cpp): - EAGLE-style block-diffusion drafter that reuses a multi-layer target-hidden-state tap (reusable capture path, also useful for EAGLE3-proper) and drafts a block of tokens per round. - Per-round sequential Markov resample: step_logits[k] = base_logits[k] + markov_w2(markov_w1(prev_token)), argmax, chaining the sampled token forward (never batched over the block). Host scalar path by default, optional host BLAS path (LLAMA_DSPARK_MARKOV_BLAS). - GGUF arch scaffolding, converter stub, and forward-graph/loop tests. See docs/dspark-scope.md for scope and the gating rationale. CUDA device-side Markov resample (common/dspark-markov.cu/.h): - Moves the sequential per-position resample onto the GPU: one H2D of the round's base logits, then a fused GEMV + add-base + argmax kernel per position that chains through a device-resident prev token, plus a final reduction. Self-contained (CUDA runtime only). Token-identical to the host scalar/BLAS path. Default when built with CUDA; opt out with LLAMA_DSPARK_MARKOV_CUDA=0. cuda: defer Q1_0/Q2_0 dp4a symbol correction (ggml/src/ggml-cuda/vecdotq.cuh): - vec_dot_q1_0_q8_1 / vec_dot_q2_0_q8_1 (the mul_mat_vec_q decode path) built a signed symbol per element before dp4a. Both now dp4a on the raw unsigned code/bit and apply one deferred affine correction at the end using Q8_1's stored real-valued activation sum (ds.y), matching the pattern vec_dot_q4_0_q8_1_impl already uses: Q1_0 dot = d*(2*sumi*ds.x - ds.y), Q2_0 dot = d*(sumi*ds.x - ds.y). Correctness: test-backend-ops MUL_MAT passes for both types. * cuda: forward-declare ggml_cuda_mul_mat_q1_hopper (fix -Werror=missing-declarations) Pre-existing on prism: the Hopper Q1 entry point is defined in mmq-hopper-q1.cu but only forward-declared locally in ggml-cuda.cu, so the definition's translation unit has no prior declaration and -Werror= missing-declarations breaks the cuda build. The full build matrix only runs on PRs (not prism pushes), so this stayed latent. One-line forward declaration; no behavior change. * tests: fix dspark test cross-platform builds (macos -Werror, windows DLL link) Surfaced by the full CI matrix (only runs on PRs, not prism pushes): - macos clang -Werror,-Wmissing-noreturn: the test-local fail() helpers never return; mark them [[noreturn]] (test-dspark-forward/loop/real-eval). - x64-windows-llvm link error: test-dspark-forward used the llama_model::get_tensor MEMBER function, which is not reliably exported across the Windows DLL boundary. Switch to the exported free function llama_internal_get_tensor_map (same pattern test-quantize-stats already uses cross-platform). No behavior change. Verified locally: all three targets build clean under clang (CPU build). * tests: use public vocab API for dspark n_vocab (windows DLL link) The prior fix swapped llama_model::get_tensor for llama_internal_get_tensor_map, but that free function is also not LLAMA_API-exported (its only other user, test-quantize-stats, is gated NOT WIN32, so it was never windows-linked) and still fails to resolve across the Windows DLL boundary. Use the exported public API instead: llama_vocab_n_tokens(llama_model_get_vocab(model)). The dspark converter's set_vocab() fills the 'none' tokenizer with dummy entries sized to the target's real vocab width, so n_tokens() reports the correct value (the old code comment claiming it is 0 predates that converter behavior). Verified locally: all three dspark test targets build clean under clang. * dspark: widen capture-copy size math to size_t (CodeQL overflow) CodeQL flagged the two dspark capture-copy sites in llama-context.cpp: 'row' was uint32_t, so n_tokens*row and n_outputs*row (feeding the byte size n_*row*sizeof(float)) were evaluated in 32-bit before widening to size_t. No overflow at real capture configs (row=n_capture_layers*n_embd is small), but latent for large captures. Make 'row' size_t so all downstream size math is 64-bit. No behavior change. libllama builds clean. * llama-context: bound capture-layer writes and zero unpopulated capture rows llama_set_capture_layers() wrote one capture_layer_idx[] slot per accepted layer id but only range-checked the id value, so a caller repeating layer ids could advance the write index past the fixed LLAMA_MAX_LAYERS array and corrupt adjacent cparams. Stop once the array is full. On any architecture whose graph does not build a capture tensor (currently every arch except qwen35), the capture copy was skipped while output_reserve() had already allocated embd_capture, so llama_get_embeddings_capture*() returned uninitialized memory. Zero the destination rows and warn once instead. * speculative: harden dspark drafter/target contract and recovery paths Validate the drafter against the target at construction: the drafter consumes the target's hidden states (each capture row is target_hidden wide, copied verbatim) and resamples over the target's vocabulary, so a drafter trained against a different target would over-read capture rows or index the wrong vocab. Fail loudly here rather than corrupt every round. When the per-round drafter cache tail cannot be cropped, do not advance n_cache past a tail that is still physically present; reset the drafter sequence and rebuild context next round. The markov-resample mask_token_id checks aborted via GGML_ASSERT, but a sampled token can legitimately equal the mask sentinel (a real vocab id) -- that only makes a poor draft the target rejects. Warn once instead of aborting a valid run; the sequential chaining is guaranteed structurally by construction. * convert: map dspark per-layer tensors under drafter./dspark. wrappers The per-layer fallthrough passed the original tensor name to map_tensor_name, so a decoder tensor nested under a drafter. or dspark. wrapper kept that unsupported prefix and failed to map (only the model. prefix map_tensor_name understands worked). Strip the dspark-specific wrapper for the fallthrough while preserving any standard model. prefix. * common: build the dspark Markov CUDA TU for ggml's architectures, not SM80 only The resample TU pinned CUDA_ARCHITECTURES to 80 whenever CMAKE_CUDA_ARCHITECTURES was not defined in this scope -- which is the common case, since ggml resolves it inside its own subdirectory and it does not propagate up. That forces a PTX JIT on Hopper/H100 and fails to build for the pre-Ampere GPUs the rest of ggml supports. Inherit the ggml-cuda target's resolved architecture list instead, falling back to native detection. * tests: gate dspark tier-2 on agreement and actually run the rs-ring test Tier 2 only failed on non-finite logits, so it passed on any finite output even with zero argmax matches. Gate on argmax-match-rate and top-5 overlap (both scale-invariant, defaulted high, CLI-overridable), and scan logits from token 0 so a bad first logit is no longer excluded from the diff/non-finite metrics. test-rs-ring-rotation was registered against the non-recurrent stories model, so it always took the self-skip path and never exercised the ring. Generate a tiny recurrent qwen35 fixture with the pure-Python generator when numpy/gguf are importable and require it; otherwise fall back to the stories model unchanged. * docs: correct dspark Markov CUDA default and drop stale scaffolding claims The Markov CUDA path is the default at runtime when the drafter has a Markov head (opt out with LLAMA_DSPARK_MARKOV_CUDA=0), not opt-in via =1 -- fix the root CMake option comment and dspark-markov.h to match the runtime behavior. Note that the device warp tree-reduction changes floating-point accumulation order versus the host scalar/BLAS paths, so the resample is functionally equivalent rather than bit-identical: a near-tie argmax can differ, which only changes the speculative proposal the target verify still arbitrates. Drop the 'no forward graph / scaffolding only' claims in the converter docstring and logging, the GGUF tensor-registry comment, and docs/dspark-scope.md -- the forward graph and block-diffusion draft loop are implemented in this branch. * tests: run rs-ring rotation on CPU (-ngl 0) The ring bit-identity check compares logits from a ring and a no-ring context and requires both to run the identical GDN compute path. On GPU backends without a fused GDN op the op falls back per context and the two diverge (observed on Metal: 'fused Gated Delta Net not supported, set to disabled'). The invariant and this test are defined on CPU, per the test header, so pin the run to CPU. * dspark: load and run drafters with GIDD log-SNR conditioning Some drafters ship a LogSnrEmbed module -- a sinusoidal featurization of a per-position log-SNR value run through a 2-layer SiLU MLP, added to the draft noise embedding before the backbone. Without loader support these drafters fail to load: their four log_snr_fc tensors are unmapped (wrong number of tensors). Add the optional GGUF metadata (dspark.log_snr_conditioning, min/max_log_snr) and the dspark.log_snr_fc1/fc2 tensors, gated on log_snr_conditioning so drafters without it load and run exactly as before. The per-position log-SNR pattern (anchor of each block at max_log_snr, mask positions at min_log_snr) and its featurization are a pure function of n_draft/block_size/min/max_log_snr, all known at graph-build time, so the feature matrix is precomputed host-side and staged via a new llm_graph_input_dspark_logsnr input; only the learned fc1/fc2 weights go through ggml. The featurization divides by (max_log_snr - min_log_snr), so when conditioning is enabled the bounds are required and validated finite and strictly ordered at load time rather than silently producing NaN embeddings. * dspark: drop unused pos from llama_set_dspark_ctx; document draft-dspark contract The pos argument to llama_set_dspark_ctx (and the v_ctx_pos it filled) was never consumed: the drafter graph only uploads the tap features, and each context row's decode position comes from the batch. Shipping a public-API parameter that has no effect is misleading, so drop it (this API is new in this branch, no external callers) along with the dead v_ctx_pos storage. Also document, at the speculative-type registry, that draft-dspark requires the driver to engage multi-layer capture (llama_set_capture_layers plus per-row logits) before drafting: the reference driver is tests/test-dspark-real-eval.cpp, and the generic CLI/server paths do not yet engage capture, so selecting it there fails at the first draft with a clear error. * speculative: validate the target's configured capture-layer count in dspark process() The dspark row copy reads n_embd_cap = n_capture * n_embd floats from each target capture row, but capture layers are engaged by the driver after the impl is constructed, so the ctor's n_embd/n_vocab checks cannot see the configured layer count. A driver that engaged fewer layers than the drafter was trained on would over-read past the end of the capture row; more layers would feed misaligned features. Check llama_get_n_capture(ctx_tgt) against the drafter's n_capture at process() time and fail loudly on mismatch. Also correct a stale ctor comment that still described the CUDA Markov resample as token-identical to the host path; it is functionally equivalent but not bit-identical (see common/dspark-markov.h).
The C++ loader requires dspark.log_snr_fc1/fc2 tensors and the log_snr_conditioning/min_log_snr/max_log_snr KV when a drafter uses log-SNR conditioning, but the Python side never mapped them, so converting any log-SNR-conditioned drafter failed with: Can not map tensor 'log_snr_embed.fc1.bias'.
…rismML-Eng#59) * metal: fix M5 device creation + add Q2_0 multi-column mul_mv kernels Two independent Metal changes: - The AGX_RELAX_CDM_CTXSTORE_TIMEOUT override (added for the long-context command-buffer timeout on M1/M2, ggml-org#20141) prevents MTLCreateSystemDefaultDevice() from returning a device on M5 / current macOS. Keep it on by default and disable it only when sysctl reports an M5 chip; GGML_METAL_RELAX_CDM_CTXSTORE_TIMEOUT=0/1 forces either way. - Add Q1_0-style multi-column mul_mv variants for Q2_0 (nr1 2/3/4): read the streamed weights once for nr1 output columns via a 2-bit weight expansion and an FMA inner loop, instead of re-reading them per column on the mul_mv_ext path. Opt-in via GGML_METAL_Q2_0_NR1 (default routing unchanged). Measured [4096,14336] on M5 Pro: nr1_2 93.2 us at ne11=2 vs 122 for the ext route. 41/41 test-backend-ops MUL_MAT q2_0 on both routings. * speculative: Metal DSpark Markov resample + quantized markov heads Adds a Metal device path for the block Markov resample, alongside the existing CUDA path. It builds one dependency-chain graph for the whole draft block (each step's GPU argmax feeds the next step's get_rows) and submits it once, reading the drafter's still-device-resident logits, so the sequential Markov dependency stays exact with a single sync. Falls back to the host path when the head type or backend is unsupported, or when DSPARK_MARKOV_CPU=1. Also teaches llama_model_dspark_get_markov to dequantize quantized markov head tensors (Q4_0/Q5_0/Q8_0) for the host/CUDA path, instead of rejecting everything but f32/f16/bf16 -- drafters that ship quantized heads previously had the correction silently disabled (has_markov=0). Validated on Metal: accept counts byte-identical to the CPU-forced path; the Metal resample runs ~1.8x the single-thread CPU Markov path.
…rismML-Eng#58) Two independent Metal changes: - The AGX_RELAX_CDM_CTXSTORE_TIMEOUT override (added for the long-context command-buffer timeout on M1/M2, ggml-org#20141) prevents MTLCreateSystemDefaultDevice() from returning a device on M5 / current macOS. Keep it on by default and disable it only when sysctl reports an M5 chip; GGML_METAL_RELAX_CDM_CTXSTORE_TIMEOUT=0/1 forces either way. - Add Q1_0-style multi-column mul_mv variants for Q2_0 (nr1 2/3/4): read the streamed weights once for nr1 output columns via a 2-bit weight expansion and an FMA inner loop, instead of re-reading them per column on the mul_mv_ext path. Opt-in via GGML_METAL_Q2_0_NR1 (default routing unchanged). Measured [4096,14336] on M5 Pro: nr1_2 93.2 us at ne11=2 vs 122 for the ext route. 41/41 test-backend-ops MUL_MAT q2_0 on both routings.
… path) (PrismML-Eng#61) * ggml: rows-indexed state read for the fused GDN op (ring decode path) On the ring-enabled decode path every GDN layer paid two extra dispatches per token just to feed the fused op its input state: a get_rows gather of the per-seq live states into a contiguous scratch, then a cpy of that gather into slot 0 of the (D, K, n_seqs) state input. Both are pure reads of the recurrent cache -- ~786k floats each way per layer on the 27B target -- serialized into a launch-bound decode graph, 96 dispatches and ~300 MB of scratch traffic per token across 48 layers. Add ggml_gated_delta_net_rows: the op takes the 2D cache view plus the per-seq row indices (inp->s_copy_main) as src[6] and reads each sequence's live state directly at cache row rows[seq]. K moves to op_params so both variants share one backend code path. The graph side gains build_rs_cache_view (rs_zero clear + extra-states relocation, no main gather) and qwen35 wires it on the ring path, with GGML_GDN_STATE_GATHER=1 restoring the legacy gathered path for A/B. Implemented on CPU and Metal (function-constant-gated read base, no kargs change). All other backends that support GATED_DELTA_NET reject src[6] in supports_op so rows-mode ops fall back instead of silently reading src[5] as a scratch. test-backend-ops gains rows-mode cases (single/multi-token, multi-seq, snapshot overflow, KDA): 38/38 OK on MTL0, CPU leg green. Real-eval gate: accept counts bit-identical to the gathered path at n_max 1..4 (alpaca x24). Measured on M5 Pro (cont6k Q1_0 x bin6l1 q4_0, ring 4): harness AR 32.0 -> 35.3 tok/s (+10.5%), spec@n3 33.5 -> 35.3 (+5.5%); ring-free llama-bench unchanged (~42), as expected. * ggml: fold recurrent GDN snapshot writes on Metal * metal gdn: always populate snapshot tail on write-fold, handle K==1 rows Two correctness fixes to the folded rows-mode GDN epilogue: - The write-fold followed the SET_ROWS view chain to prove the scatter consumes the GDN result, but not that it is the snapshot tail's sole consumer. The kernel now always writes the op's own documented output tail AND additionally scatters into the cache row, so a second consumer or an output/eval callback never observes an uninitialized region. - WRITE_ROWS scatter existed only in the K>1 branch; a rows-mode graph with K==1 suppressed the SET_ROWS but wrote only the output tail, losing the cache update. The K==1 final-state branch now scatters to the cache row as well. Gate: test-backend-ops GATED_DELTA_NET 39/39 on MTL0; e2e accept invariant 76/116 tau 2.3103 unchanged (default / fold-disabled / gathered). * qwen35: gate GDN rows mode to Metal-only GPU device sets rows mode uses the src[6] GDN variant, implemented on CPU and Metal only; other GPU backends reject it in supports_op, which would move the recurrent op (and its state traffic) to CPU. Select rows mode only when every GPU device in the model is Metal (ACCEL/BLAS devices are skipped). * ggml: disable OpenMP for Emscripten/WASM builds The WASM CI build enables OpenMP (-DGGML_USE_OPENMP -fopenmp=libomp), but Emscripten cannot emit the common symbols libomp's reduction helpers need (.gomp_critical_user_.reduction.var), so ggml-quants.c fails to compile. WASM has no host threads to benefit from OpenMP -- force it off for the Emscripten target instead of failing the build.
|
@The-Monk Thanks for the chnages, will do a more careful review this week. 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. 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). |
There was a problem hiding this comment.
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.
| 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); |
| const bool dedup_hit = dedup_quant && | ||
| ctx.mmvq_quant_cache_tensor == src1 && ctx.mmvq_quant_cache_buf; |
| int man = (int) (ax * 512.0f + 0.5f); | ||
| if (man > 7) { | ||
| man = 7; | ||
| } |
| 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; |
| 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; |
| 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; |
| // ---- 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)
Five fixes from the 2026-08-11 review pass: - vecdotq: pack the Q2_0 HIP-path qs bytes as unsigned. A byte >= 128 shifted by 24 overflows the promoted int (UB); build qs0/qs1 as uint32_t so optimized HIP/MUSA builds cannot miscompile the high byte. - common: fix fp32->E4M3 rounding at the subnormal/normal boundary. A subnormal mantissa that rounds to 8 is exactly 2^-6, the minimum normal (encoding 0x08); clamping it to mantissa 7 returned a non-nearest value. - mmvq: make the opt-in activation-quant dedup cache stream-safe. Concurrent graph regions fork sibling matmuls onto separate streams; a hit could consume the cached q8_1 before the populating quantize (enqueued on the miss stream) completed. Record an event after the populating quantize and make hits on any other stream wait on it (same treatment the hipblaslt wcache got in 65cd5bd). The event is destroyed with the context. - hipblaslt: key both weight caches by (device, addr, N, K) instead of the raw device address, which multi-GPU runs and reshaped views can alias. Invalidation matches on address across devices (a false positive only costs a redundant re-requant). - hipblaslt: give the Q1_0 route the Q2_0 route's VRAM preflights: the in-cache headroom check before hipMalloc and the op-level transient guard (weight-miss + activation + accumulator + workspace) so a near-full card falls back to mmq instead of OOM-aborting in the pool. Verified on 1x R9700 (Bonsai-27B): test-backend-ops MUL_MAT passes plain and with dedup+batch; PPL wiki.test 20 chunks identical with dedup on/off (11.5603); int8/fp8 prefill routes run with the expected uplift over the same-build no-route baseline (Q2_0 797->1178 t/s pp1024 warm, Q1_0 1022->1169) and sane PPL (int8 11.7646, fp8 11.6337). CCA (Claude Code Augmented)
… Q2_0 prefill)
Two findings from PC-sampling mul_mat_q on gfx1201, both replacing
unpack sequences on the HIP/MUSA path with borrow-free SWAR byte maps.
Outputs are bit-identical by construction in both cases -- prefill
perplexity reproduces exactly (Q1_0 11.6466, Q2_0 10.1465, wikitext
ub-default ch4) -- so the changes are pure performance.
1. unpack_q1_0_bytes HIP fallback: the per-bit `(bit ? 1 : -1)`
ternaries compile to v_cmp_eq + v_cndmask chains that serialize on
VCC (PC-sampled at 9.5% of mul_mat_q), and their live ranges push
the mmq_x=128 tile to the 256-VGPR ISA cap with scratch spills in
the hot loop (76B/lane; 188B on the need_check variant). Replaced
with bit-spread to {0,1} bytes (3 fused lshl_or + and) then a
carry-free byte map 1 -> 0x01 / 0 -> 0xFF via
((spread << 1) + 0x7F7F7F7F) ^ 0x80808080. On an equivalent tree
this also eliminated the spills outright (vgpr 256 -> 239, scratch
-> 0); the measured alternative of capping mmq_x at the spill-free
96 tile costs 8-9% pp, so the SWAR keeps the large tile AND drops
the spills.
2. load_tiles_q2_0's 4x __byte_perm crumb unpack: correct on HIP (PPL
verified), but HIP's __byte_perm emulates PRMT's nibble-selector
convention with a runtime control-word conversion before
v_perm_b32, so each call costs several VALU ops here rather than
one instruction. The SWAR equivalent (2-bit spread then c-1 per
byte, carry-free) produces the same linear crumbs-minus-one
ordering the perm/unshuffle pair computes, HIP/MUSA-guarded; the
CUDA path keeps the perms.
Measured (Bonsai-27B, 1x Radeon AI PRO R9700 gfx1201, ROCm 7.14,
same-session A/B on this branch):
- Q1_0 pp2048 1252.5 -> 1327.6 t/s (+6.0%, r=5); tg128 unchanged
- Q2_0 pp2048 1036.2 -> 1310.0 t/s (+26.4%, r=3); tg128 unchanged
- prefill PPL bit-exact both quants (gates above)
Note for CUDA reviewers: the pre-existing unguarded __byte_perm q2
unpack feeds raw crumb pairs as PRMT selector nibbles; the pair
(2,2) produces selector value 10, which on NVIDIA engages PRMT's
sign-replicate mode rather than a table lookup. Worth a quick check
on CUDA hardware that qx/qy are as intended there; the HIP path no
longer depends on it either way.
CCA (Claude Code Augmented)
|
Before the careful review starts: pushed one more commit ( 1. 2. The q2 Measured (Bonsai-27B, 1× Radeon AI PRO R9700 gfx1201, ROCm 7.14, same-session A/B on this branch):
The "our fork" column is the roc10 tree these techniques were developed on, same R9700/ROCm 7.14 — with this commit the branch's prefill is now at parity with it. The remaining decode gap (62 vs 68 Q1_0, 47 vs 52 Q2_0) comes from launch-geometry and kernel-fusion tuning on that tree; those constants are kernel-implementation-specific and don't transfer by copy (we measured nwarps=8 cratering this branch's kernel to 8 t/s in the original PR round), so porting them properly is a future round rather than part of this one. One question for anyone with NVIDIA hardware handy: the pre-existing (unguarded) q2 perm unpack feeds raw crumb pairs as PRMT selector nibbles. The ternary pair Full run provenance (stack/firmware/clock telemetry) captured as bench cards; happy to attach if useful. The same two techniques are on their way upstream in generic form for mainline's ternary types (ggml-org#27127). |
|
Follow-up on the upstream-to-mainline suggestion — we took the SWAR findings to mainline master and measured, and the case there turns out to be much bigger than on this fork. Mainline baseline on AMD is running at less than half of what the types can do. On gfx1201 (Radeon AI PRO R9700, ROCm 7.14), ggml-org/llama.cpp master with the 27B Q1_0 model:
Root cause is the same The fix is the same ~60-line, four-site SWAR patch as Our recommendation: this should go upstream under PrismML's name. You authored and upstreamed the types; the kernels are your architecture, and a doubling of AMD decode for them lands best coming from the type owners. Take the branch or the diff as-is, restructure freely, no attribution needed beyond whatever's natural. One heads-up for whoever submits: mainline's CONTRIBUTING.md AI policy requires the submitting author's own comprehensive manual review, an explicit disclosure of how AI was used (these kernels were developed and A/B-tested with AI assistance on our hardware), and a personally-written PR description. If you'd rather we submit it ourselves with you reviewing, that works too — but our preference is that it ships under the flag of the people who built the types. |
|
Completing the mainline table with Q2_0 — we requantized the 27B ternary model to mainline's group-64 Q2_0 (via F16; near-lossless for ternary values, PPL 10.1383 vs 10.1465 on this fork's g128) and ran the same A/B on
So the branch is fully characterized on both types: four bit-exact gates, |
|
One more dataset for the upstream decision, since we hold the AMD hardware in this collaboration: we measured the full ternary scale-granularity curve on Bonsai-27B — including mainline's TQ2_0, which is byte-for-byte the "g256" point — so the format question for mainline distribution can be decided on data rather than taste. Everything below is from our instrumented rig (1× Radeon AI PRO R9700 gfx1201, ROCm 7.14; methodology notes at the end). Quality vs scale granularity (same model content, requantized per format)
Exception-tensor accounting, since it matters: the g64 and TQ2_0 files carry q4_K/q6_K token-embd/output (mainline's rule), the g128 and g1024 files carry the fork's q2_0 ones. The decisive cell is therefore TQ2_0 vs g64 at identical exceptions: −0.84% for TQ2_0 — the advantage is the format/quantizer itself. (ch4 quick-pass numbers told the same ordering but sit inside their ±0.95 SE; ch20 is the number to trust. CPU-vs-GPU evaluation cross-checked on g128: within ±0.01.) Why very coarse groups lose — and why g256 doesn't. We analyzed the shipped g128 GGUF's scale structure directly (per-tensor, per-row block statistics over all 498 ternary tensors):
Amax-based coarse scales zero out survivors in low-magnitude groups (any group whose local scale sits under ~half the merged amax loses its ±1s to rounding). Merging two g128 groups (→g256) stays inside the tolerable band; merging eight (→g1024) crosses it and costs +6.4% even with an MSE-optimal magnitude second pass (d = mean |w| over survivors, codes preserved) — the within-row variance is trained signal. We built the g1024 type end-to-end (CPU+HIP kernels, two-pass quantizer) so that number is a measurement, not an extrapolation; it also ran 1.3% slower than g128 at bs=1 because the byte savings don't survive untuned launch geometry. The quality plateau runs from g64 through g256 and falls off a cliff before g1024 — and TQ2_0 sits at the cheap end of the plateau. AMD performance by format (same GPU, post-SWAR kernels everywhere)
Roofline context for the decode column: our measured pure-read ceiling on this card is 640.0 GB/s (custom streaming-kernel calibration, reproduced ×2 — we use measured, not datasheet), and the g128 decode kernel runs at ~87% of it with exact per-model byte accounting from GGUF metadata — i.e., ternary decode on RDNA4 is memory-bound, which is why bytes-per-weight dominates every row of that table and why sub-percent bpw differences show up in tg. What this means for the upstream/distribution question
Methodology: PPLs are wikitext-2, 4×512 chunks, prefill-path unless marked; quality comparisons use requants of the identical g128 source through F16 (lossless for ternary values); TQ2_0's PPL is CPU-evaluated (no GPU path exists) with a same-config CPU-vs-GPU calibration run on g128 agreeing within ±0.01 PPL. Decode A/Bs are same-session interleaved with VRAM-drain guards; perf numbers are r=3–5 llama-bench with reported ±. |
|
Follow-up from the AMD measurement series — this one is a capability proposal rather than a kernel finding. Proposal: ship a nextn/MTP head for Bonsai-27B. We validated MTP self-speculation on AMD (llama.cpp A Bonsai-distilled head (1 transformer layer + eh_proj/norms, ~400 MB bf16/fp8, trained against Bonsai hidden states) should recover the ~50%+ accept that makes speculation pay on fast ternary targets; projected ~1.5× effective decode on top of all the kernel wins, on every GPU vendor — spec decode multiplies whatever the platform baseline is. We can supply the gguf graft tooling (drop-in file format already worked out) and same-day AMD benchmarks for any candidate head: |
|
Update — measured result worth flagging: the stock Qwen3.6 MTP head is already net-positive on Bonsai-27B Q2_0, no training needed.
(RDNA4 R9700, temp 0 — greedy verify is exact-match, so output is bit-identical to plain greedy decode.) The split is informative: Q2_0 preserves the ternary master faithfully and the head recognizes it; Q1_0s binarization drifts the distribution and accept drops below breakeven on a 67 t/s target. Which sharpens the earlier proposal: a Bonsai-distilled head should lift Q1_0 into the same ~50% accept band (projected ~67 → 85+), and likely push Q2_0 further still. Meanwhile the Q2_0 +21% is available to your users today — the graft is 15 tensors (blk.64.* from any Qwen3.6-27B MTP gguf) + block_count 65 + nextn_predict_layers=1; happy to share the script. |
…xact) The existing fast path requires VNNI (Alder Lake / Zen 4+, 2021+); every older AVX2 CPU (Haswell 2013 through Zen 3) fell to the scalar loop. This adds an #elif AVX2 tier: pshufb-replicate + nibble-LUT crumb decode, maddubs with the dot(c-1,u) = dot(c,u) - sum(u) fold. Bonsai-8B Q2_0, dual E5-2699v4 (AVX2, no VNNI), t=11: scalar 1.81 t/s -> 5.90 t/s (3.26x), PPL 10.4508 bit-exact both ways. CCA (Claude Code Augmented)
|
Pushed one more commit to the branch: AVX2 tier for the Q2_0 CPU vec_dot. The current fast path is gated on VNNI ( Measured (Bonsai-8B Q2_0, dual Xeon E5-2699 v4 = AVX2-only, 11 threads): scalar 1.81 t/s → 5.90 t/s (3.26×), PPL bit-exact (10.4508 both ways, 2-chunk gate). One observation on the existing VNNI tier while we were in there (not changed — we have no VNNI hardware to validate on): it horizontal-sums both dpbusd results to scalar every 32 elements ( Thread-scaling note for CPU users: on the 2-socket test box, t=11 beat t=44 (6.0 vs 4.5 t/s) — per-op sync dominates at high thread counts on many-core machines; worth a mention in docs. |
… on AVX2
The 4x8 repack path was AVX-512-VNNI-only on x86, so every pre-2021 core
ran vec_dot with no row batching. AVX2 tier: unsigned expand ({0,1} bits /
{0..3} codes) + maddubs/madd dots (pair sums <= 768, no s16 saturation),
col-pair-packed fp32 accumulators keep the 4-row GEMM tile in 16 ymm.
Bonsai-8B Q2_0, dual E5-2699v4, same-session A/B vs vec_dot (t=11/22/44):
pp512 10.6/21.1/39.1 -> 23.0/45.7/64.9 t/s
tg128 6.5/ 7.6/ 5.8 -> 10.6/17.3/19.8 t/s (sync wall broken; was
inverse-scaling past 22 threads)
Bonsai-27B Q1_0 tg64: 2.6/2.5 -> 3.6/5.8/6.5 t/s.
Known tradeoff: Q1_0 27B prefill ~13% slower than vec_dot (q1 gemm expand
untuned); decode dominates the CPU use case, engagement kept for both types.
PPL parity both types (delta 0.003 << SE); kernel harness vs generic max
rel err 4e-6 over 54 shapes.
CCA (Claude Code Augmented)
…_MIRROR=1) Multi-socket hosts cap decode at interconnect bandwidth when weight pages interleave across nodes. Keep one copy of each repacked weight tensor per NUMA node (mmap+mbind, no libnuma dependency) and read the copy local to the cpu the thread is currently on (sched_getcpu per chunk) - locality survives thread migration, no scheduler changes. Copies made post-repack, results bit-exact; mirrors freed with the buffer. Dual E5-2699v4, Bonsai-8B Q2_0 tg128: t=22 17.3 -> 20.4, t=44 26.2 (interleave) -> 27.0. Also serves as the measurement that decode is NOT bandwidth-bound post-repack (fully-local weights ~ +3% at t=44, ~50GB/s of ~140 available): the next lever is gemv expand port pressure (IPC ~1), not data placement. CCA (Claude Code Augmented)
|
CPU follow-up to the AVX2 vec_dot tier from this morning — two more commits on the branch, closing out the x86 story for older cores. The gap: the 4x8 repack path for Q1_0/Q2_0 ( c6e2a0c — AVX2 tier for the 4x8 repack kernels (+ AVX2 engagement). Weights expand to unsigned bytes ({0,1} bits / {0..3} codes) so the u8×s8 dot runs as 4ad267e — opt-in Measured on dual E5-2699 v4 (2016 Broadwell — AVX2, no VNNI), Bonsai-8B Q2_0, same-session A/Bs:
pp512: 10.6/21.1/39.1 → 23.0/45.7/64.9. Bonsai-27B Q1_0 tg64: 2.6 / 2.5 / (collapse) → 3.6 / 5.8 / 6.5. Known tradeoff: Q1_0 prefill is ~13% slower than its (already strong) vec_dot path — the q1 gemm bit-expand is untuned; decode dominates the CPU use case so engagement is kept for both types. Gates: PPL parity for both types (Δ0.003 ≪ SE), mirror bit-exact, 54-shape kernel harness vs the generic reference (max rel err 4e-6, fp-order only). One diagnostic worth recording: with mirroring, weights are fully node-local and decode moved only +3% at t=44 (~50 GB/s of ~140 available) — post-repack, ternary CPU decode is not bandwidth-bound. Profile puts the Q2 gemv at IPC≈1 with the expand shuffle-port pressure as the likely wall; that (not data placement) is the next lever if anyone wants to chase it. Net: 5.8 → 27.0 t/s decode (4.7×) on 2016 hardware, one day of AVX2 work. HT hurts decode (t=88 craters); pin to physical cores. |
The shared_mutex read lock bounced its cacheline across all 44 threads on every chunk - 4.5% of decode in perf (pthread_rwlock_unlock + map). Writers (load-time only) rebuild an immutable snapshot; readers do one acquire-load plus a lookup on the immutable map. Old snapshots leak by design (tiny, load-rare, readers may hold them). CCA (Claude Code Augmented)
Per-sub-block the kernel paid 4 scalar fp16 lookups + mulss + broadcast (one per column) for d0[c]*d1. Accumulate per 128-value block with only the activation scale d1 (one broadcast per sub-block), fold the per-column weight scale d0[c] once per block. Changes fp summation order (harness vs generic max rel 4e-6 unchanged); Bonsai-8B Q2_0 tg128 t=44: 26.8 -> 29.8. CCA (Claude Code Augmented)
Odd vocab sizes (e.g. qwen3 151669) excluded output.weight - the largest single decode matmul - from the repack path entirely (7.7% of decode stuck on vec_dot). Aligned rows are tile-repacked as before; the <=3 tail rows stay in the original row-major block format at the end of the buffer, so a tail row lives at exactly row*row_size. forward_mul_mat_one_chunk clamps the tile kernels to the aligned range and dots tail columns with the type-generic vec_dot (deinterleaving x4-quantized src1 rows on the fly). 3D (mul_mat_id) keeps the aligned-only rule. PPL bit-identical to the vec_dot reference; Bonsai-8B Q2_0 tg128 t=44: 29.8 -> 32.0. CCA (Claude Code Augmented)
|
Follow-up with Bonsai-27B numbers, same dual E5-2699 v4 (2016 Broadwell — AVX2, no VNNI), after four more commits on the branch:
Net at 44 threads: Q1_0 2.30 → 8.23 (3.6×), Q2_0 2.27 → 6.96 (3.1×), and the dual-socket inverse-scaling is gone for both formats. Mirroring is worth +13% / +34% (Q1/Q2) here versus the ~+3% it showed on the 8B — the bigger the weight set, the more node placement matters. Two notes for anyone benchmarking multi-socket CPU decode:
|
|
Round-4 MTP heads are trained and scored — the KL + chain-unroll recipe works, and Q1_0 self-speculation now reaches 95 t/s on code. Recipe delta vs the round-2/3 heads posted earlier: 30MB domain-weighted self-generated corpus per format (code/math/technical-heavy, Bonsai-27B, 2× R9700 (numbers below from one GPU, same-session; 512-token greedy, temp 0):
Findings worth recording:
Head-only grafts (~470MB, apply with the graft tool already on the release branch) available on request — same drop-in format proposed earlier in this thread. |
|
Thanks for all of this, and for the standing offer on the R9700 box. The RDNA4 data is exactly what we were missing: our AMD work so far has been on MI300X only. To get the kernel part merged quickly, a few requests: 1. Rebase onto
2. Split the PR into three. They have very different review costs and it would be a shame for the vec_dot change to wait on hipBLASLt:
3. Address the Copilot findings before re-requesting review. Most of them are real:
4. PR description. The description carries a "Generated with Claude Code" footer. The other threads here (the scale-granularity curve, the MTP head grafts and training recipe, the AVX2 CPU tiers, NUMA mirroring) are each valuable and each deserves its own issue or PR so they can be reviewed on their own terms; folding them into this branch is what pushed it to 58 commits. The CPU AVX2 work in particular looks like a clean standalone PR. On upstreaming: agreed with @khosravipasha, the +119% Q1_0 decode on mainline master is a big deal and it is your finding, so please open that PR on ggml-org yourself. #125 is CDNA-scoped, and once the two approaches have been compared on both architectures we can point the upstream PR at whichever is faster on each. |
|
MI300X numbers for the A/B I offered above, so the arch question is settled before you rebase. Setup:
Your formulation wins on CDNA as well, on every model, and the round-to-round spread is under 0.7 tok/s so none of it is noise. Q1_0 gains more than the 2-bit types because it was issuing four permutes per 16 weights against two. C equals A, so the multiply versus shift-or spread is not where the gain is; skipping the symbol materialization is. Correctness: all three builds pass So: one AMD path, yours, for both CDNA and RDNA. When you rebase the vec_dot piece onto |
|
Thanks for running that A/B, and for including the C arm — that's the control that actually settles it, since it rules out the multiply-vs-shift-or difference and leaves skipping the symbol materialization as the cause. Your numbers plus mine point the same way on both architectures, so I'll take you up on the port. Drop it wherever is easiest (branch here, patch, or gist) and I'll rebase on top of it rather than redo the 60 lines. Plan for the first PR, matching your split:
I'll re-measure A vs B on gfx1201 after the rebase and post those numbers next to yours before I re-request review, so the RDNA4 side is on the record the same way. Dedup and hipBLASLt follow as separate PRs: dedup with the completion-event fix (event on the miss stream, wait at hit), hipBLASLt with the cache keyed on Agreed on upstream — I'll open the ggml-org PR myself, but after the vec_dot lands here so the two don't diverge while it's in review. The R9700 offer stands, and the standing invitation to run anything of yours on it does too. |
|
Ran the A/B on gfx1201, and I have to correct something before anything else: I nearly sent you a message saying my decode formulation does not win on RDNA4. That was wrong, and it was wrong because I ran a two-arm test and drew a three-arm conclusion. Measuring mine against #125 shows a wash. That is equally consistent with "mine stopped working" and with "#125 independently captured the same win," and I had no arm that separated them. So I built one: Decode (tg128, 27B,
|
| model | BYTEPERM (baseline) | PERM (#125) | SWAR (mine) | #125 vs base | mine vs base | mine vs #125 |
|---|---|---|---|---|---|---|
| 27B Q1_0 | 29.27 | 64.94 | 64.22 | +121.8% | +119.4% | -1.1% |
| 27B Q2_0 (g64) | 33.33 | 42.92 | 43.71 | +28.8% | +31.1% | +1.8% |
| 27B PQ2_0 | 33.69 | 48.85 | 48.52 | +45.0% | +44.0% | -0.7% |
Round-to-round spread 0.33-1.06 t/s. Both routes fix the same defect and both are worth 1.3x to 2.2x over the __byte_perm path. Your +119% figure for Q1_0 on mainline reproduces here at +119.4% on a different branch with different models, which is a good independent check on both our measurements.
On RDNA4 the two routes are indistinguishable: -1.1%, +1.8%, -0.7%, each inside its pair's round-to-round spread. That is a wash, not a regression -- and combined with your CDNA numbers it points where you already said it does. Yours measured +2.4% to +6.3% for the SWAR route on gfx942; mine measures no cost for it on gfx1201. So unifying on SWAR is free on RDNA4 and worth 2-6% on CDNA, while unifying on v_perm is free on RDNA4 and gives up that 2-6%. Same number of code paths either way, so "fewer paths" does not decide it -- the CDNA margin does.
Conclusion: unify on SWAR, no arch #if -- which is exactly what you proposed. I will replace the v_perm helpers from #125 rather than adding a switch, and I will take the port you offered.
One thing worth flagging whichever way this lands: #125 is not arch-gated. Every guard in vecdotq.cuh is #if defined(GGML_USE_HIP) with no arch macro, so it currently ships on RDNA2, RDNA3, RDNA3.5, RDNA4 and CDNA, and has been benchmarked on two of them (your MI300X, my gfx1201). Whatever we unify on inherits that reach, so an #if drawn on architecture would silently assign RDNA3/3.5/RDNA2 to a branch nobody has measured. That is an argument for one path, not two.
Prefill (pp2048) — this is the part that is actually mine
| model | BYTEPERM | PERM (#125) | SWAR+MMQ (mine) | #125 vs base | mine vs base |
|---|---|---|---|---|---|
| 27B Q1_0 | 1033.4 | 1026.6 | 1307.0 | -0.7% | +26.5% |
| 27B Q2_0 (g64) | 1053.6 | 1052.7 | 1292.4 | -0.1% | +22.7% |
| 27B PQ2_0 | 1052.5 | 1052.8 | 1295.4 | +0.0% | +23.1% |
#125 is flat on prefill, as expected since it only touches the decode vec_dot. The MMQ load_tiles unpack is worth +23-27% pp2048 on all three types and nothing on prism-v7 currently has it.
Mechanism, from the compiled kernel descriptor (mmq_x=128, llvm-readobj on the unbundled gfx1201 code object):
| kernel | before | after |
|---|---|---|
mul_mat_q<Q1_0,128> |
256 VGPR, 80 B/lane scratch | 212 VGPR, 0 B |
mul_mat_q<Q2_0,128> |
256 VGPR, 88 B/lane scratch | 227 VGPR, 0 B |
mul_mat_q<PQ2_0,128> |
256 VGPR, 88 B/lane scratch | 227 VGPR, 0 B |
The tiles were spilling; removing the spills is the win. Unlike the decode path this one is bit-identical, not merely close: I compared the SWAR unpack against ROCm's __byte_perm across the full 16-bit input space, 0/65536 mismatches for both the Q1_0 4-value and the 2-bit 4-value unpack. test-backend-ops MUL_MAT and MUL_MAT_ID pass with 0 failures.
Both of the obvious objections are closed by measurement. An MMQ-only arm (decode vec_dot left as #125's) reproduces the full win on all three models to within 0.05% -- the decode change contributes nothing to prefill, which static analysis confirms structurally (the vec_dots are dispatched only from mmvq.cu, never from the MMQ path). And the win is flat across pp512/1024/2048/4096 (+26.3/+27.3/+27.2/+26.3% on Q1_0; +22-23% on both 2-bit types at 2048/4096), so it is not a single tile-regime artifact.
One thing your A/B and mine cannot resolve between them
You measured on gfx942 with ROCm 6.4.1; I measured on gfx1201 with 7.14. Two variables differ, so neither of us can attribute the small decode differences to architecture. It does not change the decision -- SWAR is at worst free on RDNA4 and better on CDNA either way -- but if you ever want that question settled, re-running your A/B once on 7.x would do it -- the SWAR exists to route around __byte_perm lowering badly on HIP, and that is a compiler property that may simply have been fixed.
RDNA4 launch geometry: an additional +8% decode on the 2-bit types, through the existing arch table
Separate from the unpack question entirely: calc_nwarps/calc_rows_per_block have no RDNA4 entries for these types, so they launch on defaults. Sweeping them on gfx1201 gives PQ2_0 rpb=2/nwarps=6 and Q2_0(g64) rpb=2/nwarps=6; Q1_0's stock config is already its peak (decline confirmed monotonically through nwarps 1..8).
Measured with the tuning applied on top of the SWAR unpack (tg128, 27B, two rotated rounds):
| type | untuned | tuned | gain |
|---|---|---|---|
| PQ2_0 | 48.27 | 52.30 | +8.4% |
| Q2_0 (g64) | 44.24 | 47.85 | +8.2% |
| Q1_0 | 64.72 | 64.49 | wash |
Also verified on top of the v_perm unpack (52.32 / 48.09) -- the constants are robust to the unpack choice, so this composes with whichever dot product you keep. It goes through MMVQ_PARAMETERS_RDNA4, the arch-keyed mechanism mmvq.cu already uses, so the arithmetic stays one path.
It rides with a correctness fix you want regardless. Enabling rpb>1 exposed that the mul_mat_vec_q row-bounds guard compares row0 + i against stride_col_dst, which under MUL_MAT_ID is the expert-channel stride, not the row count. rpb>1 is already reachable today through the GENERIC/GCN/TURING/GB10 tables at ncols_dst 2-8 (ordinary batched MoE), so any expert tensor whose row count is not divisible by rpb writes out of bounds on those backends now, on your tree as it stands. Reproduced with m=517, n=1 (PQ2_0/Q2_0/Q4_K fail, then pass with the fix). Worth noting the stock test-backend-ops MUL_MAT_ID matrix never catches this -- its m values (64, 512) divide evenly by the rpb the tables pick, so I added the m=517 case alongside the fix. I would put the guard fix up as its own small PR since it is live for non-RDNA4 backends independent of any tuning.
The split, as you asked for it
- (a) HIP
vec_dotfor Q1_0 / Q2_0 / PQ2_0 replacing the hip: faster Q1_0, Q2_0 and PQ2_0 decode on AMD #125v_permhelpers, plus the MMQload_tilesunpack, plus the signed-shift UB fix. Small, default-on, and the MMQ half is bit-exact. Your offer to run the MMQ half on MI300X is what I would most like to take you up on -- register pressure does not transfer between architectures, and the spills it removes on gfx1201 may or may not exist on gfx942. - (b) activation-quant dedup, with the completion-event fix for the stream-safety issue.
- (c) hipBLASLt prefill routes, with the
(device, address, N, K)cache key, the free-VRAM preflight on the Q1_0 route, and the E4M3 boundary fix. - (d) the MUL_MAT_ID row-bounds guard fix (small, correctness, live on your tree today), and on top of it the RDNA4 mmvq table entries above.
On the Copilot signed-shift item: qs is uint8_t, so b << 21 tops out at 0x1FE00000 and the Q1_0 path and MMQ unpack are already clean. The genuine instance was the Q2_0 byte pack, which is fixed. The stride checks, cache key, VRAM preflight and E4M3 rounding are all real and will be in (b)/(c).
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 (
qwen35hybrid), 1 GPU,llama-bench -r 5(tg) /-r 3(pp).1. HIP-path
vec_dotfor Q2_0 (+37%) and Q1_0 (+16%), on by defaultThe existing Q2_0 vec_dot extracts symbols with dynamic-selector
__byte_permchains, which are built around NVIDIA'sPRMT; 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 forunpack_q1_0_bytes: bit-spread the raw 2-bit codes into bytes and use the identitydot(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 − 1⇒dot(s,u) = 2·dot(c,u) − sum(u)), replacing the select-chain fallback inunpack_q1_0_bytesthat materialized ±1 bytes on a VALU-bound kernel.Perplexity is identical (wikitext-2, 20 chunks:
11.5603 ± 0.47269both 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 OFFSibling mmvq matmuls that read the same activation tensor (wq/wk/wv, ffn_gate/ffn_up, wqkv/wqkv_gate) each launch a
quantize_row_q8_1that 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 everygraph_compute, so entries cannot survive a graph rebuild.GGML_HIP_DEDUP_MMVQ_QUANT_BATCHadditionally widens it tone11>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
_BATCHvariant 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.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=3on 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.-DCMAKE_HIP_ARCHITECTURES=gfx1201.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 costStanding 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