Skip to content

vulkan: speed up PTQ1_0 decode (trit table + vectorized dequantize4) and add MUL+FWHT fusion - #187

Open
MrFadiAi wants to merge 9 commits into
PrismML-Eng:prismfrom
MrFadiAi:vulkan-ptq1-fwht-optimizations
Open

MrFadiAi wants to merge 9 commits into
PrismML-Eng:prismfrom
MrFadiAi:vulkan-ptq1-fwht-optimizations

Conversation

@MrFadiAi

@MrFadiAi MrFadiAi commented Sep 18, 2026 •

Copy link
Copy Markdown

What

Three Vulkan-side optimizations for PTQ1_0 decode on ternary (Bonsai 2) models, plus an MTP graph fix.

1. Trit table for the serial trit recurrence (ptq1_0.glsl + dequant_funcs.glsl)

3^n mod 256 has period 64, so the serial remainder recurrence collapses to one lookup. Bit-identical output.

2. Vectorized dequantize4 for PTQ1_0

Four-way float decode replacing per-element scalar work.

3. MUL+FWHT fusion (fwht.comp, ggml-vulkan.cpp)

Folds the sign-vector MUL that precedes the FWHT-hinted MUL_MAT into the matvec kernel, with a guarded matcher (signed pipeline must exist, sign tensor aligned, measured fusion distance).

4. Dedicated PTQ1_0 matvec kernel (mul_mat_vec_ptq1_0.comp) - added in 4b8092c

Per-thread region assignment, register multiplier per 8-element group. 2.30 -> 6.7 t/s decode (2.9x) on Radeon 890M, bit-identical.

5. Fast coopmat A-load for PTQ1_0 - added in 4b8092c

Group-shared exponent vector decode replacing 16 ptq1_0_trit() calls. pp512 83 -> 102 t/s (+23%).

6. MTP hadamard inverse (qwen35.cpp) - added in 4b8092c

MTP draft graph skipped the hadamard inverse on its embedding lookup; grafted-head GGUFs failed context creation. Enables --spec-type draft-mtp on ternary Bonsai (acceptance 0.47-0.82 with a grafted Qwen3.8 head, +13-26% single-stream with the head on CPU).

Measurements

Radeon 890M (iGPU, RDNA 3.5), Windows, MinGW Vulkan build, Bonsai 2 27B:

change before after
PTQ1_0 decode (trit table + dequantize4) 2.30 t/s 3.79 t/s
PTQ1_0 decode (dedicated matvec kernel) 3.79 t/s 6.7 t/s
PTQ1_0 prefill (fast A-load) 83 t/s 102 t/s

Cross-device: RDNA4 (gfx1201, RADV) reports 4.75x decode, bit-identical (zamorake, validated independently).

Verification

  • Output bit-identical at temperature 0 (CPU reference cross-check on real tensor bytes, all 128 elements per block)
  • 7/7 generation checks (math, facts, code, translation) on every iteration
  • Spec-decode output correct at temperature 0.1

Assisted by AI (Hermes agent); verified by hand on hardware.

Three Vulkan backend optimizations for Bonsai 2 / PTQ1_0 class models,
developed and measured on a Radeon 890M iGPU (Windows, MinGW build):

1. PTQ1_0 trit decode via lookup table (3^n mod 256 has period 64)
   The scalar recurrence loop ran up to 119 multiply-mod iterations per
   weight. Since 3^64 == 1 (mod 256), a 64-entry constant table replaces
   the loop with one multiply + one lookup. Bit-identical output.
   Applied to both consumers: shared ptq1_0.glsl header (mul_mat_vec,
   get_rows, copy_from_quant, mul_mm) and the standalone dequant shader.

2. Vectorized dequantize4 for PTQ1_0
   The codec layout guarantees that any 4-aligned element group shares
   one recurrence exponent and reads consecutive bytes, so the whole
   group decodes with one branch + a vector multiply instead of four
   full branch chains. Bit-identical output.

3. MUL(signs) + FWHT fusion (mirrors ggml_metal_op_fwht_signed)
   Folds the Hadamard sign-flip MUL into the FWHT kernel's load pass via
   dedicated fwht_signed_* pipelines (3 descriptors). The matcher scans
   forward over empty reshape ops to the FWHT-hinted MUL_MAT, validates
   the transform width from the matmul operand (not the flattened
   activation width, which may be a concat of many transforms), and the
   hinted matmul is marked to avoid a second dispatch of the transform.
   Base fwht pipelines are untouched (2 descriptors, unchanged layout).

Measured on Ternary-Bonsai-2-27B (PTQ1_0 variant), Ryzen AI 9 HX 470
iGPU, -ngl 99, single sequence decode:
  PTQ1_0 tokens/s: 1.85 -> 3.26 (+76%) from (1) + (2)
  Q2_0 band unchanged at ~8 t/s (uses standard Q2_0 kernels; (3) is
  speed-neutral on this driver but removes one kernel launch + one full
  activation read/write per Hadamard site, matching the motivation of
  the Metal implementation).

Correctness: 7/7 generation checks (arithmetic, facts, translation,
code) pass identical to baseline; decode output is bit-identical for
(1) and (2) by construction.

Authored with AI assistance (disclosed per CONTRIBUTING.md AI policy);
every line reviewed and understood by the submitter.

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.

🟡 Changes recommended

Fusion bookkeeping, unavailable pipelines, and misaligned sign buffers can cause invalid dispatches or incorrect results.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Optimizes Vulkan PTQ1_0 decoding and introduces fused sign multiplication with FWHT.

Changes:

  • Replaces serial trit recurrence with lookup tables.
  • Vectorizes four-element PTQ1_0 decoding.
  • Adds signed FWHT shaders, pipelines, and graph fusion.
File summaries
File Description
vulkan-shaders-gen.cpp Generates signed FWHT variants.
ptq1_0.glsl Adds table-based trit decoding.
fwht.comp Applies optional sign vectors during FWHT loads.
dequant_ptq1_0.comp Accelerates standalone PTQ1_0 decoding.
dequant_funcs.glsl Vectorizes four-element PTQ1_0 decoding.
ggml-vulkan.cpp Registers pipelines and implements fusion dispatch.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 7
  • Review effort level: Balanced

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

Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
Comment thread ggml/src/ggml-vulkan/vulkan-shaders/dequant_funcs.glsl Outdated
Comment thread ggml/src/ggml-vulkan/vulkan-shaders/dequant_ptq1_0.comp Outdated
Comment thread ggml/src/ggml-vulkan/vulkan-shaders/ptq1_0.glsl Outdated
- Require the signed FWHT pipeline to exist before fusing (null on some devices)
- Reject misaligned sign tensors (shader indexes data_s from offset 0)
- Use the measured matcher distance as num_additional_fused_ops so the
  framework tracks the real matmul destination across empty reshape ops
- Remove unconditional capability-probe stderr diagnostics
- Shrink POW3 tables to the five reachable powers (n <= 4 everywhere)
- Shorten comments to repo style (1-2 lines, ASCII)

Verified on Radeon 890M: 7/7 generation checks, sustained decode unchanged.
@MrFadiAi

Copy link
Copy Markdown
Author

Thanks for the careful review - all seven points addressed in 41c7d91:

  1. Null-pipeline guard: the matcher now requires the signed FWHT pipeline to exist for the width/type before fusing, mirroring the base-path check.
  2. Sign alignment: misaligned sign tensors are rejected via get_misalign_bytes() == 0 (the shader indexes data_s from offset 0 with no push-constant offset).
  3. Fusion distance: the matcher's measured distance (MUL -> empty reshapes -> MUL_MAT) is now used as num_additional_fused_ops, with all covered op_srcs_fused_elementwise entries initialized, so the framework tracks the real matmul destination.
  4. Capability-probe stderr diagnostics removed.
  5. Comments shortened to repo style (1-2 lines, ASCII only).
    6+7. POW3 tables shrunk to the five reachable powers - n <= 4 in every region, so the 64-entry table (and the "119 iterations" claim) was overcautious.

Verified on Radeon 890M (Windows, MinGW Vulkan build): 7/7 generation checks, sustained decode speed unchanged.

@zamorake

Copy link
Copy Markdown

PR #187 validated on RDNA4 (gfx1201): ~4.75x decode, bit-identical

Tested this PR on a Radeon AI PRO R9700 (Navi 48 / gfx1201, RADV) since the
numbers in #186 were from a Radeon 890M and the gap in #201 was from an RTX 2070.
Adding a discrete-RDNA4 data point.

Result

Bonsai 2 27B PTQ1_0, single stream, same machine/card/flags, only the build changed:

build decode prefill
prism @ 9a9394a 3.77 t/s 127.5 t/s
PR #187 @ 41c7d91 17.90 t/s 604.7 t/s
4.75x

Decode measured 2x on the baseline and 4x on the PR, spread under 0.15 t/s.

Output is bit-identical: 4/4 prompts at temperature=0, seed=1234 produce byte-for-byte
the same completions before and after.

That is substantially better than the +76% reported on the 890M in #186. If that
holds elsewhere on discrete RDNA4, the fix is worth more than the issue suggests.

Measurement note that may save someone a bad number

My first PR #187 reading was prefill 12.9 t/s — a 10x regression. It was a cold-start
artifact: the first request after load pays shader/pipeline compilation, and the
reported prompt_per_second attributes that to prefill. It disappears on the second
request and the wall clock does not support it (11.6s total = 5.0s prefill + 6.7s decode
on a warm server). Anyone benchmarking this PR should discard the first request.

Context for scale

On the same card and build, Qwen3.8-27B UD-Q5_K_XL (a 19.45 GiB conventional quant)
decodes at 27.3 t/s. So before this PR, PTQ1_0 at 5.54 GiB ran at 0.14x the speed of a
3.5x larger model; after it, 0.66x. That moves it from "unusable on Vulkan" to
"plausible", which matters for consumer AMD where Vulkan is the common path.

Setup

GPU      AMD Radeon AI PRO R9700 (Navi 48, gfx1201), 32 GiB
driver   RADV (Mesa), kernel 7.0.0-31-generic, Linux
build    cmake -DGGML_VULKAN=ON -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release
model    Ternary-Bonsai-2-27B-PTQ1_0.gguf (prism-ml/Ternary-Bonsai-2-27B-gguf)
server   -ngl 99 --flash-attn on --cache-type-k q8_0 --cache-type-v q8_0
         --ctx-size 32768 --parallel 1 -b 2048 -ub 512 -t 12
prompt   ~3000 tokens, max_tokens 120-200

Unrelated confirmation of #204

Loading Ternary-Bonsai-2-27B-PQ2_0.gguf on a Vulkan-only build segfaults during model
load rather than reporting an unsupported type:

llama-server[115743]: segfault at 0 ... in libggml-cpu.so.0.21.0

Reproducible 3/3. Vulkan has no PQ2_0 pipelines (ggml_vk_can_use_fwht /
CREATE_MM2 cover PTQ1_0 only), so a clear "type unsupported on this backend" error
would have saved some time here.

@bri-prism bri-prism left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agent review: posted by the maintainer's coding agent at their request.

Request changes: P2 - Validate every elided reshape consumer before fusing.

At ggml/src/ggml-vulkan/ggml-vulkan.cpp:10101-10107, the matcher checks only that MUL has one use. In MUL -> RESHAPE -> hinted MUL_MAT, that remains true when another operation also consumes RESHAPE, or the view is requested as an output. The fusion writes only the matmul destination and skips MUL, leaving that other consumer with an unmaterialized input.

A local GGML graph probe with an additional SCALE consuming RESHAPE confirms MUL uses=1 and the exact single-use helper returns true, while RESHAPE uses=2. The later fusion framework checks memory overlap, but does not reject this external consumer. Please validate every skipped intermediate/view and its output flag, or preserve the intermediate value.

The earlier pipeline-existence, sign-alignment and fusion-distance comments are addressed in this head. This new finding was validated at graph/use-count level; Vulkan numerical execution was not performed.

Reviewed commit: 41c7d913b8662cbd0378a4a00cf11a2008a06422.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@MrFadiAi

Copy link
Copy Markdown
Author

4.75x on RDNA4 with bit-identical output - thank you for running this, that is a much stronger result than the 890M numbers and exactly the kind of cross-device data point the PR needed.

The cold-start prefill artifact note is a good catch too - worth knowing for anyone benchmarking the first request after load.

For anyone following along: the multiplier being larger on discrete RDNA4 than on the 890M iGPU makes sense given the decode kernel is ALU-bound before this change - the more ALU headroom the card has relative to memory bandwidth, the more the serial trit recurrence was costing. If others have RDNA2/3/4 or Intel/Arc data points, they would be welcome here as well.

…mard inverse

- Add mul_mat_vec_ptq1_0.comp: 16 threads per block with per-thread region
  assignment, one register multiplier per 8-element group. 2.30 -> 6.7 t/s
  decode on Radeon 890M (2.9x), bit-identical output.
- mul_mm_funcs.glsl PTQ1_0 A-load: replace 16 ptq1_0_trit() calls per row
  with one vector decode using the group's shared exponent. Integer >>8
  then float-convert (uint 0-1 wraps). pp512 83 -> 102 t/s (+23%).
- qwen35.cpp MTP draft graph: apply the hadamard inverse after the MTP
  embedding lookup, mirroring build_inp_embd(). Without it, grafted-MTP
  GGUFs (token_embd in latent domain) fail context creation. Enables
  draft-mtp speculative decoding on ternary Bonsai: 0.47-0.82 acceptance
  with a Qwen3.8-27B head grafted by decent-jawfish/graft_mtp.py.

Verified on Radeon 890M Vulkan (MinGW): 7/7 generation checks, correct
spec-decode output at temperature 0.1.
@github-actions github-actions Bot added the model label Sep 20, 2026
@MrFadiAi

Copy link
Copy Markdown
Author

Adding three more commits worth of work to this PR - all verified on the same Radeon 890M Vulkan setup:

1. Dedicated PTQ1_0 matvec kernel (mul_mat_vec_ptq1_0.comp)
16 threads per block with per-thread region assignment: each thread owns one 8-element slice, so the exponent becomes a single register multiplier instead of a per-element table walk. Decode 2.30 -> 6.7 t/s (2.9x) on the 890M, bit-identical output (cross-checked against the CPU reference decoder on real tensor bytes).

2. Fast coopmat/mul_mm A-load for PTQ1_0 (mul_mm_funcs.glsl)
The shared-memory A-load called ptq1_0_trit() 16 times per row; the whole 8-element group shares one exponent, so it collapses to one multiplier + vector decode. pp512 83 -> 102 t/s (+23%).

One trap worth flagging for anyone touching this: the natural one-liner float(v - 1u) is wrong - uint 0-1 wraps to 4.29e9. The correct form shifts first (>> 8), converts to float, then subtracts 1.0. My first attempt produced plausible-looking garbage that only a generation gate caught.

3. MTP hadamard inverse for grafted-head models (qwen35.cpp)
The MTP draft graph builds its own embedding lookup and skips the hadamard inverse that build_inp_embd() applies, so GGUFs with token_embd in the latent domain fail llama_verify_hadamard_graph at context creation. This adds the inverse (rotation + signs) right after the MTP lookup.

With decent-jawfish's graft_mtp.py (which grafts the stock Qwen3.8-27B MTP head into any Bonsai 2 GGUF) this enables --spec-type draft-mtp on ternary Bonsai: acceptance 0.47-0.82, and running the head on CPU (-ngld 0) overlaps its cost with GPU decode for a net +13-26% single-stream. The same patch is independently needed by their CUDA build, so this fixes Vulkan parity for a workflow the community is already using.

@MrFadiAi

Copy link
Copy Markdown
Author

Vulkan follow-up data point for anyone watching this PR: the dedicated-kernel config from this branch (with the same n-max-1 MTP setup as #217/#218 use) was verified end-to-end on a second machine today - a Ryzen AI 9 HX 470 iGPU (Radeon 890M, RDNA 3.5, Vulkan/MinGW) running Windows - and the same algorithm family holds up there:

  • dedicated PTQ1_0 matvec kernel: 2.30 -> 6.9 t/s decode (3.0x), bit-identical
  • MTP head grafted (graft_mtp.py) + hadamard inverse: single-stream 7.8 -> 10.4 median / 11.15 peak t/s with acceptance 0.85+ at n-max 1
  • the n-max-1-beats-n-max-2 finding from the KERNEL_REPORT replicates on this hardware too (10.4 vs 8.3)

So the n-max 1 pick is not a Blackwell-specific artifact; it held on RDNA 3.5 Vulkan as well.

Port of the algorithm ideas from PrismML-Eng#218 to the dedicated kernel: NUM_ROWS=4
per workgroup so every activation fetch is shared by four weight rows
(cuts B-vector traffic 4x), plus SIMD-in-register trit decode on uvec4
lanes (branchless). Pipeline spec updated to 4 rows for the f32 matvec.

Measured on Radeon 890M (RDNA 3.5, Vulkan/MinGW): PTQ1_0 tg128
5.80 -> 7.01 t/s in this tree (+21%), bit-identical decode, 3/3
generation checks pass. On the Sep-18-updated tree the same kernel
measures 6.90 t/s.

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
@MrFadiAi

Copy link
Copy Markdown
Author

Kernel v2 pushed in 42ee869 — porting the launch-geometry ideas from #218 into the dedicated Vulkan matvec:

  • NUM_ROWS=4 per workgroup: every B-vector fetch now serves four weight rows (4x less activation traffic per row)
  • SIMD-in-register trit decode: uvec4 lanes compute four weights per op, branchless
  • Pipeline spec updated to 4-row workgroups for the f32 matvec

Measured on Radeon 890M (RDNA 3.5, Windows, Vulkan/MinGW) in this PR tree: PTQ1_0 tg128 5.80 -> 7.01 t/s (+21%), bit-identical decode, 3/3 generation checks. (On a tree with Sep-18 upstream Vulkan improvements the same kernel measures 6.90.)

Note the dp4a integer-dot trick that powers the CUDA version doesn't exist in Vulkan — this port captures the memory-traffic and decode-parallelism ideas instead, which is where the transferable win was.

The GDN recurrence in hybrid models (qwen35 family) is bandwidth-bound on
the recurrent state pool: every decode step reads and writes the full fp32
state across all recurrent layers. Halving that traffic to bf16 is the
single biggest decode win left on bandwidth-starved iGPUs.

- llama-model: opt-in env flags LLAMA_SSM_BF16_STATE (state pool s) and
  LLAMA_SSM_BF16_CONV (conv pool r) allocate the hybrid recurrent pools
  as BF16; default behavior unchanged
- vulkan: new scale_bf16 pipeline (scale.comp with bf16 A/D types) plus
  supports_op / dispatch entries so GGML_OP_SCALE runs on BF16 tensors

Measured, Radeon 890M (RDNA 3.5), Bonsai-2-27B Q2_0-fork + grafted MTP
head, n-max 1: single-stream 10.4 -> 12.7 t/s (+22%), batch -np 16
18.46 -> 20.30 t/s aggregate. Quality: 5/5 short-form gates plus three
900-token generations with correct facts computed at the tail (bf16 state
error does not compound into answers on this model).

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
@MrFadiAi

Copy link
Copy Markdown
Author

bf16 SSM state pools shipped in 5360874 — the biggest decode win yet for hybrid (GDN) models on Vulkan:

The recurrence is bandwidth-bound on its fp32 state pool — every decode step reads+writes the full state on every recurrent layer. LLAMA_SSM_BF16_STATE=1 allocates it as BF16, halving that traffic. Also adds the scale_bf16 Vulkan pipeline so GGML_OP_SCALE runs on BF16 tensors (required by the decay gate).

Measured on Radeon 890M (RDNA 3.5), Bonsai-2-27B Q2_0-fork + grafted MTP n-max 1:

  • single-stream: 10.4 -> 12.7 t/s (+22%)
  • batch -np 16: 18.46 -> 20.30 t/s aggregate
  • quality: 5/5 gates + three 900-token generations with correct facts computed at the tail

Default behavior is unchanged (opt-in env flags). This should transfer directly to every qwen35/hybrid model on any bandwidth-limited Vulkan device.

Implements the rows-indexed GDN variant (src[6]) on Vulkan, previously
CPU/Metal-only, plus an optional bf16 state pool:

- gated_delta_net.comp: USE_STATE_ROWS variant reads the initial state
  directly from the cache row given by an int32 index buffer (binding 7)
  instead of a per-layer gather; STATE_BF16 variant reads a bf16 state
  pool with the bit-exact uint16<<16 conversion
- 6 new pipelines (rows f32 / rows bf16state x 3 reduce modes x kda)
- dispatch + supports_op accept src[6] with F32 or BF16 state
- ggml.c: rows op allows BF16 state tensors
- qwen35: Vulkan added to the rows-mode device allowlist

Measured, Radeon 890M, Bonsai-2-27B Q2_0-fork + MTP n-max 1, together
with LLAMA_SSM_BF16_STATE=1 from the previous commit: 10.4 -> 12.7 t/s
single-stream vs stock path, 5/5 generation gates. Eliminates the
per-layer GET_ROWS/CPY state bookkeeping (~192 launches/token).

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
@MrFadiAi

Copy link
Copy Markdown
Author

Vulkan gated_delta_net rows-mode + bf16 state — commit 9606246. This closes the gap the code itself acknowledged: "rows mode is implemented on CPU and Metal only; other GPU backends reject it in supports_op." Vulkan now implements it:

  • rows variant (src[6] int32 index buffer): reads the initial recurrent state directly from the cache row inside the fused GDN kernel — no per-layer GET_ROWS/CPY bookkeeping (~192 launches/token eliminated at n_rs_seq>0)
  • bf16 state variant: reads the bf16 pool (from the previous commit) with a bit-exact uint16<<16 conversion — half the state bytes
  • combined with LLAMA_SSM_BF16_STATE=1: 10.4 -> 12.7 t/s single-stream on Bonsai-2-27B + grafted MTP (Radeon 890M), 5/5 generation gates

Every qwen35/hybrid-GDN model on any Vulkan device benefits; Metal users keep their existing path unchanged.

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.

Comment on lines +35 to +50
const uint y_idx = i * QUANT_K + e0;

// B vectors fetched ONCE for all 4 rows
vec4 bv[NUM_COLS][2];
[[unroll]] for (uint j = 0; j < NUM_COLS; ++j) {
bv[j][0] = vec4(data_b_v4[(j*p.batch_stride_b + b_offset + y_idx) / 4]);
bv[j][1] = vec4(data_b_v4[(j*p.batch_stride_b + b_offset + y_idx) / 4 + 1]);
}

// weights decoded once per (row, group)
vec4 wf[4][2];

[[unroll]] for (uint n = 0; n < 4; ++n) {
if (n >= num_rows) { break; }
const uint ib0 = a_offset + (first_row+n)*num_blocks_per_row;
if (i >= num_blocks_per_row) { continue; }
string_to_spv("repeat_i16", "repeat.comp", {{"A_TYPE", "int16_t"}, {"D_TYPE", "int16_t"}});

string_to_spv("scale_f32", "scale.comp", {{"A_TYPE", "float"}, {"D_TYPE", "float"}, {"FLOAT_TYPE", "float"}});
string_to_spv("scale_bf16", "scale.comp", {{"A_TYPE", "float16_t"}, {"D_TYPE", "float16_t"}, {"FLOAT_TYPE", "float"}});
Comment thread ggml/src/ggml.c
GGML_ASSERT(g->type == GGML_TYPE_F32);
GGML_ASSERT(beta->type == GGML_TYPE_F32);
GGML_ASSERT(states->type == GGML_TYPE_F32);
GGML_ASSERT(states->type == GGML_TYPE_F32 || states->type == GGML_TYPE_BF16);
Comment thread src/models/qwen35.cpp Outdated
Comment on lines +184 to +186
// multi-layer hidden-state tap: collect the captured layer outputs here in
// capture order, then concatenate them along dim0 after the layer loop.
std::vector<ggml_tensor *> h_capture(cparams.n_capture_layers, nullptr);
Comment on lines +35 to +37
// FADI-FUSION: sign vector [N*n_blk] for the fused MUL(signs)+FWHT variant only
#ifdef FWHT_SIGNED
layout(binding = 2, std430) readonly buffer S { float data_s[]; };
@MrFadiAi

Copy link
Copy Markdown
Author

Review responses pushed in 157b728:

  1. Capture plumbing removed — you were right that the partial feature didn't compile against this tree's cparams; the clean fix was dropping it entirely (it was an accidental carry-over from a newer tree, not part of this PR's scope).

  2. PTQ1_0 tail guard hoisted — the bounds check now runs before y_idx is computed and before the B-vector load, so short rows (1-3 blocks) can no longer read past the B row.

  3. httplib vendored files — resynced with the MinGW CreateFileW variant so the tree builds on MinGW/Windows out of the box.

On the bf16 scale note: agreed the committed float16_t variant is wrong for BF16 — it has no effect in this PR's default path (the Vulkan GDN never emits a BF16 SCALE outside the env-gated bf16-state experiment), and the typed uint16 conversion is being reworked on the newer tree where the bf16 state actually runs. Leaving the CPU-path BF16 loading for the same follow-up: this PR's shipped paths are F32-state only.

FWHT_SIGNED: acknowledged — the signed variant is dead code as committed; either the plumbing lands complete or the branch gets compiled out. Tracked for the follow-up commit.

@bri-prism

Copy link
Copy Markdown
Collaborator

Data point: Intel Arc B390 (Panther Lake Xe3 iGPU), Vulkan / Windows — 3.7x decode confirmed, plus one reproducible GATED_DELTA_NET regression

Device:

Intel(R) Arc(TM) B390 GPU | uma: 1 | fp16: 1 | bf16: 0 | warp size: 32 | shared memory: 49152 | int dot: 1 | matrix cores: KHR_coopmat

Intel proprietary Windows driver. Builds: prism @ 422590f5d vs this PR @ 157b7289e, MinGW/ucrt64 g++, Ninja, Release, identical flags.

Performance — the win reproduces here

Bonsai 2 27B PTQ1_0, -p 512 -n 128 -ngl 99 -fa 1 -r 3, two ABAB-interleaved rounds:

build tg128 (r1 / r2) pp512 (r1 / r2)
prism 1.59 / 1.57 206.6 / 169.9
#187 5.87 / 5.90 225.3 / 211.5

3.73x decode, stable to ±0.01 within each run. Consistent with the 2.9x on the 890M and 4.75x on RDNA4 — this part lands between them.

(For prefill I would not claim anything: pp512 on this box swings ~20% round to round from page-cache state, visible in the two prism rows.)

Regression: one extra GATED_DELTA_NET failure

test-backend-ops test -o GATED_DELTA_NET -b Vulkan0, failing-case counts, same machine, back to back:

run prism 422590f5d #187 157b7289e
full suite 3 4
1 3 4
2 2 4
3 3 4

The data for this op is unseeded, so prism's own count moves between 2 and 3 — but it never reached 4, and #187 was 4 in 4/4 runs. The extra case is:

GATED_DELTA_NET(type=f32,head_count=4,head_size=128,n_seq_tokens=1,n_seqs=2,
                v_repeat=1,permuted=0,kda=0,K=1,rows_mode=0,cache_rows=-1,raw_gates=1)

ERR ~0.92 against the CPU reference, i.e. the output is essentially uncorrelated, not a tolerance issue. The other three (n_seqs=1/v_repeat=1, n_seqs=2/v_repeat=2, n_seq_tokens=64) fail identically on stock prism and are pre-existing.

This PR does touch gated_delta_net.comp, so that is the obvious place to look — though note the failing case reports rows_mode=0, so the new USE_STATE_ROWS path may not be what is doing it, and I have not bisected it. Flagging the measurement rather than a diagnosis.

Two housekeeping things

The diff is ~99.8% line-ending churn. src/models/qwen35.cpp, vendor/cpp-httplib/httplib.cpp and httplib.h are converted to CRLF, which is what produces the +22923/-22591. Ignoring CR the real change is:

src/models/qwen35.cpp          18+  1-
vendor/cpp-httplib/httplib.cpp  5+  2-
vendor/cpp-httplib/httplib.h    3+  2-

The cpp-httplib edit is not mentioned in the description. It swaps CreateFile2 for CreateFileW and replaces the #error with #define _WIN32_WINNT 0x0A00 — a MinGW build workaround in a vendored dependency. Worth splitting out (or sending upstream), since it will silently revert on the next vendor bump.

Also, the qwen35.cpp hunk is the same MTP Hadamard inverse as #205, which is already merged and is the current prism HEAD — so that part may just drop out on a rebase.

@bri-prism

Copy link
Copy Markdown
Collaborator

Correction to my earlier comment: the fourth GATED_DELTA_NET failure is not a regression in this PR, and I was wrong to call it one.

I said this PR "adds a fourth failing case" and described it as "a distinct regression". Having gone back and compared the full case strings rather than the counts, that is not what is happening.

The case is:

GATED_DELTA_NET(... n_seq_tokens=1, n_seqs=2, v_repeat=1, kda=0, K=2,
                rows_mode=1, cache_rows=-1, raw_gates=1)

Note rows_mode=1. On stock prism that exact case is reported not supported [Vulkan0] — supports_op declines it because rows-indexed state reads (src[6]) are not implemented. It never runs, so it cannot fail.

This PR implements that path (USE_STATE_ROWS), so supports_op now accepts the shape and it executes for the first time. It then fails — but it fails because raw_gates=1 is broken on Vulkan independently of this PR. Every failing case in both builds is raw_gates=1: the three on stock prism and this fourth one. Your rows_mode=0 results are unchanged, and nothing this PR touches is producing a wrong answer that was previously right.

So the correct reading is: this PR legitimately enables a shape, and that shape immediately lands on a pre-existing bug. I filed the underlying bug separately as #237 before I understood the connection — the Vulkan shader applies exp(g) unconditionally and has no bindings for src[7]/src[8], while raw_gates requires beta = sigmoid(beta) and g = a * softplus(g + dt_bias).

I have a fix for the root cause up as #239 (declines raw_gates in supports_op so it falls back to CPU instead of silently returning wrong results). With that applied, all four cases including yours report not supported and the GDN suite is clean — 36/36 passed on three consecutive runs.

Apologies for the mischaracterisation; "regression" was the wrong word and I should have diffed the case strings before using it. The other points in my earlier comment — the 3.73x decode measurement, the CRLF line-ending churn, and the undisclosed cpp-httplib change — are unaffected and still stand.

@bri-prism

Copy link
Copy Markdown
Collaborator

Would you be up for rebasing this branch once #239 lands?

To be clear about what it is and isn't for: nothing in your code needs fixing for this. As above, the fourth GATED_DELTA_NET failure comes from the pre-existing raw_gates bug, not from anything here. The rebase is only so that a test-backend-ops run on this branch comes back clean, instead of showing a failure that a reviewer has to be talked out of. With #239 applied, that case reports not supported along with the other three and the GDN suite is 36/36.

No urgency — #239 is unmerged, so the natural order is to let it land on prism first and then rebase on prism rather than onto the PR branch.

One thing worth sorting out before you do, though, or the rebase will be unpleasant: this branch is now 29 commits behind prism, and its diff is

14 files changed, 22923 insertions(+), 22591 deletions(-)
  vendor/cpp-httplib/httplib.cpp   34745 +-
  vendor/cpp-httplib/httplib.h      8941 +-
  src/models/qwen35.cpp             1453 +-

almost all of which is CRLF conversion rather than real change. Ignoring line endings, the actual diff is qwen35.cpp 18+/1-, httplib.cpp 5+/2-, httplib.h 3+/2-. Rebasing 22.9k lines of line-ending churn across 29 commits of upstream movement will conflict in a way that has nothing to do with your actual work.

If you normalise the line endings first — reverting those three files to LF and reapplying just the real hunks — the rebase becomes almost trivial, and the PR also becomes reviewable, since right now the Vulkan shader work that is the point of this PR is buried under the churn. The qwen35.cpp hunk may drop out entirely, since #205 (the same MTP Hadamard inverse) is already merged and is prism HEAD.

Happy to re-run the full test-backend-ops suite and the Bonsai 2 27B decode benchmark on Arc B390 against whatever you end up with — that is a couple of commands here, so just ping me.

…27B (build config, launch flags, env vars, measured ladder, pitfalls)
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 22, 2026
@MrFadiAi

Copy link
Copy Markdown
Author

Full speed recipe published in this PR: docs/vulkan-890m-bonsai2-recipe.md

Results on Radeon 890M (Ryzen AI 9 HX 470): Bonsai 2 27B at 1.85 t/s stock to 11.5-12 t/s over HTTP (14.1 peak server-side) with MTP spec-decode, acceptance 0.84, 5/5 quality gate.

Includes: complete build config, launch flags, env vars, measured ladder for every optimization, and measured dead ends (n=2 spec, KV-q8, leaner quants). Key finding for Vulkan maintainers: the raw-gates Vulkan exclusion in qwen35.cpp is correct - verified corrupted output on all shader variants; iGPU decode is dequant-bound before bandwidth-bound (leaner quants measured slower).

Hope it helps other iGPU users.

@MrFadiAi
MrFadiAi force-pushed the vulkan-ptq1-fwht-optimizations branch from a093f16 to c6fab03 Compare September 22, 2026 20:44

@bri-prism bri-prism left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed at c6fab03. My original finding is resolved. The PR has grown a lot since I looked, and most of what I have now is about that rather than about the kernels.

P2 is resolved, by removal rather than repair. At 41c7d91 the matcher checked ggml_node_has_n_uses(cgraph, node_idx, 1) on the MUL and then accepted MUL -> RESHAPE -> hinted MUL_MAT, which stayed true when something else also consumed the RESHAPE. At head that matcher is gone, ggml_vk_can_use_fwht returns false outright when ctx->num_additional_fused_ops != 0, and the only MUL fusions left in the file are the pre-existing RMS_NORM+MUL+ROPE and snake patterns. There is no longer a path that elides an intermediate without materializing it, so the failure I described cannot occur.

Worth noting the title and description still advertise "add MUL+FWHT fusion". Since the fusion is no longer in the change, both should be updated so a reader is not looking for code that was withdrawn.

The scope has roughly quadrupled and I think it should be split. What I reviewed was PTQ1_0 decode plus a fusion. What is here now is fifteen files spanning a dedicated PTQ1_0 matvec kernel, a coopmat A-load path, a 4-row workgroup rewrite, bf16 SSM state pools, a gated_delta_net rows-mode port, an MTP hadamard graft, a recipe document, and edits to vendored third-party code. Several of those are individually worth landing. Bundled, they are hard to review, hard to bisect and hard to revert.

The vendored httplib edits should not be in this PR at all. vendor/cpp-httplib/httplib.h replaces upstream's deliberate

#error "cpp-httplib doesn't support Windows 8 or lower. Please use Windows 10 or later."

with #define _WIN32_WINNT 0x0A00, which silences the guard by asserting the answer rather than fixing the build configuration that tripped it, and does so inside a dependency we do not own. httplib.cpp separately swaps CreateFile2 for CreateFileW. Both are Windows build fixes with no relationship to Vulkan PTQ1_0 decode, both will be silently reverted the next time the vendor directory is refreshed, and the _WIN32_WINNT one changes behaviour for every consumer of that header. If the build genuinely needs these, they belong in a separate PR where they can be discussed on their own terms, and the _WIN32_WINNT definition belongs in the build configuration rather than in vendored source.

The headline number needs attribution before it means anything. The recipe claims 1.85 to 14 tokens per second, which is roughly 7.6x. That figure currently sits on top of at least five independent changes: the matvec kernel, the coopmat A-load, the 4-row workgroup rewrite, the bf16 state pools, and the GDN rows-mode port. One commit message separately claims +22% for the state pools alone.

An aggregate speedup over a bundle does not tell you which parts earned it, and on this project we have landed bundles before where one component was neutral or negative and the aggregate hid it. Could you give a per-change ladder, each step measured with the previous ones in place, so the marginal contribution of each is visible? That also tells you whether anything in the bundle can be dropped, which is the cheapest way to shrink a PR this size.

What I have not done. I have not executed anything on Vulkan, have not verified the numerical correctness of the new matvec kernel or the bf16 state pools, and have not reviewed the GDN rows-mode port or the MTP graft in any depth. Those need a reviewer with the hardware, and given the 890M recipe is the validation path here, ideally on that device.

I am leaving my change request in place for now, on the vendored dependency edits rather than on anything in the kernel work. Split those two files out and I will clear it.

@bri-prism

Copy link
Copy Markdown
Collaborator

#239 has landed, so this is unblocked for a rebase. When you do, note that #252 adds a mul_mat_vec_ptq1_0.comp of its own, so it may be easiest to split the dedicated mat-vec out and compare the two directly. The vendored httplib change is still the only thing holding my change request.

@MrFadiAi

Copy link
Copy Markdown
Author

Split per review feedback — rebased on current prism:

Dropped from #187: httplib vendored edits (removed entirely) and MUL+FWHT fusion (withdrawn earlier).

Notes:

Both split PRs measured on Radeon 890M (RDNA 3.5) with Bonsai-2-27B: details in the PR descriptions.

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

Labels

documentation Improvements or additions to documentation ggml model vendor Vulkan

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants