Skip to content

CUDA: fast, batch-invariant small-batch PTQ1_0 mat-vec (1.5x decode on Ampere) - #218

Open
sudoingX wants to merge 10 commits into
PrismML-Eng:prismfrom
sudoingX:pr-ptq1-mmv
Open

sudoingX wants to merge 10 commits into
PrismML-Eng:prismfrom
sudoingX:pr-ptq1-mmv

Conversation

@sudoingX

@sudoingX sudoingX commented Sep 19, 2026 •

Copy link
Copy Markdown

Branch: pr-ptq1-mmv, five commits on top of prism (9a9394a89), 8 files, 596 insertions, 21 deletions.
Measured on one RTX 3060 12GB (sm_86, CUDA 12.4, driver 550.144.03) with Ternary Bonsai 2 27B.

What

  1. Add: planar-transposed activation layout for the PTQ1_0 mat-vec path (quantize.cu, mmvq.cu,
    new mmvq-ptq1_0.cuh). PTQ1_0 activations are quantized into a layout where the 128 quants a
    thread needs are 8 aligned 16-byte pieces plus one 16-byte piece of scales, adjacent lanes on
    adjacent pieces; same bytes per row and the same column stride as block_q8_1, so nothing in the
    launchers changes. Trit decode unchanged; the byte-wise -1 is (q + 0x7F7F7F7F) ^ 0x80808080.
    nwarps pinned to 4 for all column counts; mmvq handles PTQ1_0 for 1 to 4 columns, 5 and above take the
    MMQ tile path now that cuda: branch-free PTQ1_0 MMQ tile loader and full Ampere tile table (2x prefill) #214 is in the tree (see the last commit). HIP unchanged.
  2. Add: dedicated PTQ1_0 mat-vec kernel with full lane utilization (mmvq-ptq1_0.cuh,
    mul_mat_vec_ptq1_0_pt). For plain 2D MUL_MAT: (row group, K block) work items flattened so
    every thread has work, 4 rows per thread per K block, one fp32 partial per (row, column, K block)
    in dynamic shared memory, one warp per (row, column) reducing in a fixed order. The per-block
    partial is d * sum_k d8_k * sumi_k with exact integer sumi_k (__fmaf_rn, __fmul_rn), the
    order depends only on the weight shape: a column's result is bit-identical for every column count
    1 to 4. Fusion for one column as in the generic kernel; batched and MoE calls keep the generic
    kernel (which also reads the new layout).
  3. Test: add Bonsai 2 projection shapes to the mul_mat perf cases (tests/test-backend-ops.cpp):
    PTQ1_0 and Q4_0 at the six Bonsai 2 projection shapes for 1, 2, 3, 4, 8 columns (8 now exercises the
    MMQ tile path), plus the bf16 [5120 x 48] gate projection.
  4. Add: GGML_CUDA_BATCH_INVARIANT for batch-invariant small-batch kernels (common.cuh, mmvf.cu,
    fattn.cu, fattn-common.cuh). Off by default. When set: small F16/BF16 matrices take
    mul_mat_vec_f for 1 to 8 columns, flash attention with up to 8 queries takes the vector kernel,
    and its KV split is sized as for one query tile.
  5. Fix: use the mat-vec kernel for bf16 matrices under 64 rows at 2 to 8 columns (mmvf.cu).
  6. Fix: keep GGML_CUDA_RESTRICT off the PTQ1_0 mat-vec kernel signature (mmvq-ptq1_0.cuh): the
    host stub for sm_90 and sm_120 rejects restrict-qualified formal parameters; aliases inside the body.
  7. Fix: budget the PTQ1_0 mat-vec shared memory the launch will request (mmvq-ptq1_0.cuh,
    tests/test-backend-ops.cpp): the entry guard and the launcher share ptq1_0_pt_smem_bytes(), four
    eval cases at the 48 KiB boundary with and without gate fusion.
  8. Fix: cap the PTQ1_0 mat-vec at 4 columns and send 5 and above to the MMQ tile path (mmvq.cu,
    mmvq-ptq1_0.cuh, common.cuh): with cuda: branch-free PTQ1_0 MMQ tile loader and full Ampere tile table (2x prefill) #214's branch-free tile loader in prism, MMQ does a batch of 8
    in 64.4 ms where the 8-column mat-vec took 112.7 ms (RTX 3060, K = 5120 shapes, llama-bench pp8, r=3;
    github.com/sudoingX/bonsai2-small-gpu/blob/main/kernel/ab/ab_215_214_rtx3060.md), so
    ggml_cuda_should_use_mmvq routes PTQ1_0 to the mat-vec for 1 to 4 columns only.
    Two Docs: commits scope the batch-invariance statement to 1 to 4 columns and to the paths the flag covers.

Why

The fork's PTQ1_0 mat-vec charged 1.5 single-token passes for a 2-token batch and 2.3 for a
3-token batch, so speculative decoding (--spec-type draft-mtp) could not pay for its drafts on a
ternary model, and its logits changed with the batch size, so greedy output with the draft head
differed from greedy output without it. Two causes, both in the same kernel: the activations were
read as 32 scattered 4-byte loads per column out of 36-byte structs, and on the K = 5120 projections
(three quarters of the 27B's weights) 88 of every 128 threads sat idle because one thread owned a
whole 128-element block. test-backend-ops perf on a 4096 x 14336 PTQ1_0 matrix: 47 / 104 / 154 /
201 us for 1 / 2 / 3 / 4 columns; Q4_0 on the same shape 101 / 102 / 115 / 151.

Before / after

llama-bench, Ternary-Bonsai-2-27B-PTQ1_0 with the MTP head appended, 4096 context,
-fa 1 -ctk q4_0 -ctv q4_0, 8 repetitions:

test before, tok/s after, tok/s before, ms per batch after, ms per batch before vs pp1 after vs pp1
pp1 25.26 37.29 39.6 26.8 1.00 1.00
pp2 34.10 61.08 58.7 32.7 1.48 1.22
pp3 33.29 72.04 90.1 41.6 2.28 1.55
pp4 35.26 81.03 113.5 49.4 2.87 1.84
pp8 53.81 73.06 148.7 109.5 3.76 4.09
pp16 102.61 102.16 MMQ path, unchanged
tg32 26.14 39.76 38.3 25.2

GGML_CUDA_BATCH_INVARIANT=1 measures the same within noise (tg32 39.70).

Kernel level, test-backend-ops perf, us per call (before = the fork's kernel on the same layout
change, so this isolates the dedicated kernel; the layout change alone took the 4096 x 14336 case
from 47 / 104 / 154 / 201 to 46 / 55 / 65 / 75 us):

shape (K x M) 1 col 2 cols 3 cols 4 cols 8 cols
attn_qkv 5120 x 10240, before 72.4 75.0 100.4 150.7 219.0
attn_qkv, after 41.8 52.5 71.8 85.4 193.9
ffn_up 5120 x 17408, before 120.0 124.6 165.9 254.3 368.6
ffn_up, after 66.7 84.7 116.4 140.9 324.5
ffn_down 17408 x 5120, before 75.1 85.3 107.4 150.6 423.4
ffn_down, after 68.5 81.8 112.1 145.7 431.1
attn_gate 5120 x 6144, before 44.8 46.7 61.0 92.3 134.6
attn_gate, after 27.7 34.1 45.3 54.2 120.1

Speculative decoding end to end (llama-server, 131072 context, one slot, q4_0 K/V, thinking off,
client-side tok/s over streamed tokens, medians of 3 runs on 3 prompts):

arm code prose bash overall greedy text equals flag off
prebuilt, flag off 25.2 25.0 25.0 25.0
this branch, flag off 39.8 40.0 39.7 39.8
this branch, draft-mtp n-max 1, GGML_CUDA_BATCH_INVARIANT=1 53.2 41.8 50.1 50.1 yes, all three prompts
this branch, n-max 2, GGML_CUDA_BATCH_INVARIANT=1 55.1 36.8 45.4 45.4 yes
this branch, n-max 2, default kernels 56.9 36.9 48.7 48.7 no

At 41.8K tokens of context: 22.2 tok/s flag off, 26.9 with n-max 1 (invariant mode), 28.3 with
n-max 2 on the default kernels.

Correctness

test-backend-ops test -o MUL_MAT -p type_a=ptq1_0: 47 of 47 against the CPU reference, including
1 to 9 columns and batched shapes (generic kernel). -o MUL_MAT_ID: 77 of 77. The flag-off greedy
text changes versus the prebuilt build at near ties (the K summation order changed), as expected for
any kernel change; the batched-prefill check that exposed the prebuilt fork's own decode and prefill
paths disagreeing by up to 0.1 nats now shows bit-identical logprobs for 1 to 4 tokens.

How to reproduce

cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DLLAMA_BUILD_TESTS=ON
cmake --build build --target llama-bench test-backend-ops llama-server -j
./build/bin/test-backend-ops test -o MUL_MAT -b CUDA0 -p "type_a=ptq1_0"
./build/bin/test-backend-ops perf -o MUL_MAT -b CUDA0 -p "type_a=ptq1_0,type_b=f32,m=10240,n=(1|2|3|4|8),k=5120"
./build/bin/llama-bench -m Ternary-Bonsai-2-27B-PTQ1_0.gguf -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0 -p 1,2,3,4,8,16 -n 32 -r 8

The invariance check (same prefix, last N tokens in one batch, compare the next-token logprobs) is
tools/batch_numerics.py in github.com/sudoingX/bonsai2-small-gpu against a running llama-server.

Known limits

  • The per-column cost is still about 22% of a single-token pass per extra column (pp2 1.22x, pp3
    1.55x of pp1). Removing the activation loads from the kernel (perf only) makes 1 to 8 columns cost
    the same, so the loads are the cost; broadcasting them across 8 lanes (warp tiles), more rows per
    thread, tighter launch bounds and two mma.sync.m16n8k16.s8 variants (correct, bit-identical by
    construction) did not beat the dp4a kernel at 2 to 4 columns on this card. Details and diffs of
    the attempts are in the repo's KERNEL_REPORT.md and results/kernel/experiments/.
  • GGML_CUDA_BATCH_INVARIANT=1 makes batches of 1 to 4 bit-identical. Batches of 5 and above take the
    MMQ tile path (since the column cap at 4) and are not covered by the flag; before the cap, 5 to 8
    agreed with each other but differed from 1 to 4 by up to 0.03 nats on the tested steps (top-1
    unchanged), and the kernel that switched between 4 and 5 columns was not identified. Prompt processing
    is not covered either.
  • In invariant mode the vector flash-attention kernel with 3 queries over 42K keys is slow, so
    n-max 2 loses at depth (21.1 vs 22.2 tok/s flag off) where the default kernels gain (28.3).
  • MoE PTQ1_0 goes through the generic kernel with the new layout and passes MUL_MAT_ID correctness;
    it was not benchmarked (no ternary MoE file at hand).
  • HIP keeps the previous layout and vec_dot (ptq1_0_pt_enabled() is false there); the new code is
    compiled out with !defined(GGML_USE_HIP).

@sudoingX

Copy link
Copy Markdown
Author

Cross-reference: #215, opened the same day, attacks the same batch-1 wall from the same diagnosis: the AoS q8_1 activation loads cost about 36 L1 wavefronts per warp instruction in the small-K geometry, and 88 of 128 threads idle at K=5120. The two activation layouts differ (#215 groups 32 K blocks with word w contiguous, this PR stores each column as 8 planes of 16-byte pieces plus a plane of scales), both change quantize_q8_1 for PTQ1_0 and its consumer, so one layout has to be chosen.

What this PR has that #215 does not:

  • the 2 to 8 column path. RTX 3060 12GB, llama-bench on the merged MTP file: pp2 costs 1.22x pp1 instead of 1.48x, pp3 1.55x instead of 2.28x, pp4 1.84x instead of 2.87x. This is what makes speculative verification pay.
  • GGML_CUDA_BATCH_INVARIANT=1: a token's logits are bit-identical whether it is decoded alone or inside a batch of up to 4, so greedy output with --spec-type draft-mtp equals greedy output without it (checked on three prompts at n-max 1, 2 and 3).
  • the bf16 under-64-rows rule and Bonsai 2 shapes in test-backend-ops.

What #215 has that this PR does not: exact int16 activation sums with the bias subtracted once per block (one fewer op per word in the trit decode, worth adopting here too) and the GDN gather fusion.

Proposed split, if it helps review: #215's exact sums and GDN fusion, this PR's multi-column path, invariance switch and tests, rebased onto whichever layout the maintainers prefer. Extending #215's word-grouped layout to N columns is a per-column stride, so that rebase is mechanical; happy to do it on request.

RTX 3060 numbers for the #215 branch will follow once the card is free (#215 was measured on a 4070).

@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.

No additional correctness finding in this source pass. Please reconcile the activation layout and MMVQ changes with #215 before integration; these are competing implementations rather than independent patches. Keeping the gather fusion separate would make that comparison easier.

The description correctly records whole-model batch invariance only for 1-4 tokens. Please keep the switch's documentation consistent with that limitation rather than promising 1-8. CUDA compilation, numerical tests and throughput were not rerun in this review.

Reviewed commit: 588318660c5076795ad43487c3410f1a44970bf8.

@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 follow-up, posted at the maintainer’s request: reproduced a build blocker at head 5883186.

[P1] Keep architecture-dependent restrict qualifiers off the new kernel signature — ggml/src/ggml-cuda/mmvq-ptq1_0.cuh:275–277.

On RTX 5090, CUDA 12.8.93 with GNU host compiler 13.3.0, building llama-bench and test-backend-ops for CMAKE_CUDA_ARCHITECTURES=120 (normalized by CMake to 120a) succeeds at base 9a9394a and fails at this PR head. Both quantize.cu and mmvq.cu fail while compiling the generated host stubs for mul_mat_vec_ptq1_0_pt:

error: template-id '__wrapper__device_stub_mul_mat_vec_ptq1_0_pt<1, 4, true, true>' ... does not match any template declaration

The generated specialization takes const void*& / float*&, while its candidate declaration takes const void* __restrict__& / float* __restrict__&. The new formal parameters use GGML_CUDA_RESTRICT; that macro expands differently in the host pass and the Hopper-or-newer device pass when PDL is enabled. This makes the new kernel's signature inconsistent across those passes.

Please follow the existing kernels in mmvq.cu: accept unqualified vx_ptr, vy_ptr, and dst_ptr parameters, then introduce GGML_CUDA_RESTRICT local aliases inside the kernel body. Removing the formal parameter qualifiers is another minimal candidate. These fixes are suggested from the diagnostic and existing pattern; a patched rebuild has not been run. This is a compile-time failure, so setting the runtime GGML_CUDA_PDL environment variable does not resolve it.

No head performance or correctness results can be reported on this configuration until the build succeeds.

@bri-prism

Copy link
Copy Markdown
Collaborator

Agent benchmark follow-up, posted at the maintainer's request.

Pinned head 5883186 against merge-base 9a9394a. Same public PTQ1 model on both arms, full GPU offload, flash attention, q4_0 K/V, batch/microbatch 512, 8 CPU threads. Three alternating baseline/candidate pairs, three repetitions per invocation, short/empty starting context.

GPU pp512 before → after, tok/s Paired change tg128 before → after, tok/s Paired change
RTX 3090 752.75 → 752.11 -0.1% 59.74 → 75.82 +26.9%
RTX 4090 1579.68 → 1586.65 +0.4% 90.67 → 90.56 -0.1%
H100 SXM build failed — build failed —
RTX 5090 build failed — build failed —

Selected CPU-reference backend checks passed on the compared arms.

The CUDA 12.8 build blocker on Hopper/Blackwell is documented in the earlier review. No patched source was substituted. These timings do not establish batch invariance or speculative acceptance.

The percentages describe these paired runs; small changes should not be interpreted as established improvements. No long-context, multi-slot serving, or end-to-end logit-parity claim is made.

Exploratory small-batch throughput changes (one pair, three repetitions per invocation):

GPU pp2 pp4 pp8
RTX 3090 +59.3% +98.5% +59.8%
RTX 4090 +33.0% +60.7% +82.3%
H100 SXM unavailable unavailable unavailable
RTX 5090 unavailable unavailable unavailable

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.

professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 19, 2026
…per-row small-K GEMV (+14% TG)

Two changes to the batch-1 PTQ1_0 mat-vec path, found by tracing a Bonsai 2 27B decode graph
on an RTX 4070 (sm_89) with CUPTI. Same weights, same bits, output byte-identical.

1. Exact integer sums + warp-transposed (SoA) q8_1 activation layout.
   quantize_q8_1<exact_isum> stores the int16 sum of each 32-value block in ds.y (instead of
   the float input sum) and writes the activations grouped by 32 K-blocks with word w of the
   group contiguous. The ternary vec-dot then accumulates raw trits {0,1,2} with dp4a and
   corrects once per block, removing a __vsub4 per 4 weights (no native SIMD byte subtract on
   sm_8x), and a warp-wide load of "word w" in the small-K geometry hits 1 L1 wavefront instead
   of ~36 (lanes were reading block_q8_1 structs 144 B apart). That LSU pressure, not GDDR,
   capped every PTQ1_0 GEMV at ~370 GB/s. Bytes per column are unchanged when K is padded to
   32*128. Only PTQ1_0 opts in (ggml_cuda_q8_1_exact_isum); other types keep the AoS layout.

2. Warp-per-row geometry for small-K PTQ1_0 GEMVs.
   The generic small_k loop strides K-blocks across the whole 128-thread block, so for K=5120
   (40 blocks) only threads 0..39 ever load anything and two of four warps just hold SM slots
   at __syncthreads. Each warp now owns one row and its 32 lanes stride that row's K-blocks;
   shuffle reduction, no shared memory, no barrier. qkv 391 -> 445 GB/s, lm_head 414 -> 460.

The recurrent-state gather fusion that was part of the first revision of this PR is now its
own PR so that this activation layout can be compared with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3:
  prism @ 9a9394a                 51.0 t/s tg128
  + 1 + 2 (this PR)               56.4     (+10.5%)
  + GDN gather fusion (follow-up) 58.5     (+14.7%)

test-backend-ops CUDA0 vs CPU: MUL_MAT ptq1_0 45/45 supported cases, MUL_MAT_ID ptq1_0 75/75.
400 greedy tokens identical with the change on and off.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 19, 2026
… (+3.8% TG on Ada)

build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp
(3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on
an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN
weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the
identical GEMVs in attention layers.

When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN
node's state input, the graph evaluator skips the GET_ROWS node and records the gather
(cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row
s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several
sequences a gathered row may alias a row another sequence writes in the same op.
GGML_CUDA_GDN_GATHER_FUSION=0 disables it.

The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context,
beside the concurrent-stream state), keyed by node pointer and reset at the start of every
graph evaluation, so two contexts or host threads decoding at once never see each other's
skips. This replaces the process-wide static map in the first revision of PrismML-Eng#215.

Independent of the activation layout change; split out of PrismML-Eng#215 so that layout can be compared
with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of PrismML-Eng#215 (+3.8%). pp512 unchanged.

test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged.
400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 19, 2026
…per-row small-K GEMV (+14% TG)

Two changes to the batch-1 PTQ1_0 mat-vec path, found by tracing a Bonsai 2 27B decode graph
on an RTX 4070 (sm_89) with CUPTI. Same weights, same bits, output byte-identical.

1. Exact integer sums + warp-transposed (SoA) q8_1 activation layout.
   quantize_q8_1<exact_isum> stores the int16 sum of each 32-value block in ds.y (instead of
   the float input sum) and writes the activations grouped by 32 K-blocks with word w of the
   group contiguous. The ternary vec-dot then accumulates raw trits {0,1,2} with dp4a and
   corrects once per block, removing a __vsub4 per 4 weights (no native SIMD byte subtract on
   sm_8x), and a warp-wide load of "word w" in the small-K geometry hits 1 L1 wavefront instead
   of ~36 (lanes were reading block_q8_1 structs 144 B apart). That LSU pressure, not GDDR,
   capped every PTQ1_0 GEMV at ~370 GB/s. Bytes per column are unchanged when K is padded to
   32*128. Only PTQ1_0 opts in (ggml_cuda_q8_1_exact_isum); other types keep the AoS layout.

2. Warp-per-row geometry for small-K PTQ1_0 GEMVs.
   The generic small_k loop strides K-blocks across the whole 128-thread block, so for K=5120
   (40 blocks) only threads 0..39 ever load anything and two of four warps just hold SM slots
   at __syncthreads. Each warp now owns one row and its 32 lanes stride that row's K-blocks;
   shuffle reduction, no shared memory, no barrier. qkv 391 -> 445 GB/s, lm_head 414 -> 460.

The recurrent-state gather fusion that was part of the first revision of this PR is now its
own PR so that this activation layout can be compared with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3:
  prism @ 9a9394a                 51.0 t/s tg128
  + 1 + 2 (this PR)               56.4     (+10.5%)
  + GDN gather fusion (follow-up) 58.5     (+14.7%)

test-backend-ops CUDA0 vs CPU: MUL_MAT ptq1_0 45/45 supported cases, MUL_MAT_ID ptq1_0 75/75.
400 greedy tokens identical with the change on and off.
@professorpalmer

Copy link
Copy Markdown

Ada numbers for the comparison, since the maintainer runs only had #218 on the 4090. RTX 4070 12 GB, pr-ptq1-mmv @ 5883186 built from source (CUDA 13.3, sm_89), paired against the Prism release binary and #215 revision 2 (layout + warp-per-row only; the gather fusion is now #220), stock clocks, three alternating rounds of llama-bench -fa 1 -ctk q4_0 -ctv q4_0 -r 3, same PTQ1_0 file:

tg128 pp1 pp2 pp4 pp8 pp512
prism 9a9394a 51.0 47.5 65.0 73.0 93.9 600
#218 54.9 50.5 92.2 148.9 148.3 599
#215 56.4 52.4 72.4 92.0 225.0 1256 (#214 in build)
#215 + #220 58.5 54.0 73.8 93.2 229.2 1259

Same picture the 3090/4090 pair suggested: your kernel's win is at 2-8 columns and on Ampere, the #215 layout's win is at one column on Ada. Neither covers the other's case yet. The pp8 gap the other way is worth a look on your side: with mul_mat_vec_ptq1_0_pt handling up to 8 columns, pp8 on Ada lands at 148 while the 4-column point is 149, so the 8-column tile is not scaling there.

I am fine with the split you proposed. Concretely, the pieces that need to end up on one layout are: the exact int16 per-block sums (bias subtracted once per block, which is also what makes your d * sum_k d8_k * sumi_k epilogue cheaper), the warp-per-row small-K geometry for one column, your multi-column kernel and the invariance switch. Your layout stores the four (d, s) half2 per block in plane 8; swapping s for the int16 sum there is the same one-line quantizer change as in #215's quantize_q8_1<exact_isum>. If the maintainers pick your layout I can port the #215 pieces onto it; if they pick #215's, the per-column stride you describe is the rebase. Either way #220 applies on top unchanged.

3060 numbers for #215 whenever the card is free would close the loop. Raw JSON for the table: artifacts/h2h_215_vs_218_4070_stockclocks.json in github.com/professorpalmer/bonsai-ada-surgery.

@professorpalmer

Copy link
Copy Markdown

Follow-up: #221 is the integration branch with #217, #218, #214, #215, #216 and #220 together plus the two pieces that make the combination fit on 12 GB: a hybrid dispatch that uses the #215 layout at one column and the #218 kernel at 2-4 columns (one ggml_cuda_q8_1_layout decision shared by quantizer and kernel), and a flash-attention MMA path that reads q4_0/q8_0 K/V in place so no F16 scratch copy is made at prefill.

Result on an RTX 4070 12 GB: Bonsai 2 27B with the MTP head at the full 262,144 window resident, 86 tok/s greedy three-prompt mean (64 without the draft head, 51 stock), byte-identical output under GGML_CUDA_BATCH_INVARIANT=1, test-backend-ops green for MUL_MAT / MUL_MAT_ID / GATED_DELTA_NET / FLASH_ATTN_EXT. Authorship of every cherry-picked commit is preserved. This PR stays as-is for standalone review; #221 is there for anyone who wants the whole stack or to compare against.

@AlexGabbia

Copy link
Copy Markdown

Confirming @bri-prism's P1 on mmvq-ptq1_0.cuh and adding a patched build that completes, since that was the open question in the review ("a patched rebuild has not been run").

The failure is not architecture-specific. On Windows with MSVC 14.44 building d8a7180, it is 24 × C2912 across quantize.cu and mmvq.cu:

mmvq.cu
...mmvq.cudafe1.stub.c(450): error C2912: explicit specialization
'void __wrapper__device_stub_mul_mat_vec_ptq1_0_pt<1,4,true,true>(const void *&,const void *&,
const ggml_cuda_mm_fusion_args_device &,float *&,const int &,...)'
is not a specialization of a function template

Root cause: ggml/src/ggml-cuda/mmvq-ptq1_0.cuh:283-285 puts GGML_CUDA_RESTRICT on the parameters of mul_mat_vec_ptq1_0_pt. cudafe emits the host wrapper with const void *__restrict__ & (cudafe1.cpp:116485) and the explicit specialization without __restrict (cudafe1.stub.c:450). __restrict is part of the parameter type for MSVC (and for GCC here), so the specialization no longer matches the primary template. Minimal repro with no CUDA at all:

template<int A> void f(const void * __restrict &);
template<> void f<1>(const void *&);   // cl.exe: error C2912

Fix, mirroring the pattern already used in this file for mul_mat_vec_q (mmvq.cu:576-587): plain pointers in the signature, GGML_CUDA_RESTRICT on local aliases.

--- a/ggml/src/ggml-cuda/mmvq-ptq1_0.cuh
+++ b/ggml/src/ggml-cuda/mmvq-ptq1_0.cuh
@@ -281,10 +281,17 @@
 static __global__ void mul_mat_vec_ptq1_0_pt(
-        const void * GGML_CUDA_RESTRICT vx, const void * GGML_CUDA_RESTRICT vy, const ggml_cuda_mm_fusion_args_device fusion,
-        float * GGML_CUDA_RESTRICT dst,
+        const void * vx_, const void * vy_, const ggml_cuda_mm_fusion_args_device fusion,
+        float * dst_,
         const int ncols_x, const int nrows_x, const int stride_row_x, const int stride_col_y, const int stride_col_dst,
         const int rows_per_cta, const uint3 bpr_fd, const uint3 rpc_fd) {
+    const void * GGML_CUDA_RESTRICT vx = vx_;
+    const void * GGML_CUDA_RESTRICT vy = vy_;
+    float      * GGML_CUDA_RESTRICT dst = dst_;
     extern __shared__ float partials[];

With that hunk the tree builds clean for -DCMAKE_CUDA_ARCHITECTURES=120 (→ 120a) on CUDA 13.0 with MSVC: 411/411 targets, no other change required. The defect entered with c755124 (this PR) in a file created by 98ea410; base 9a9394a is unaffected. Suggested commit message: ggml-cuda: keep GGML_CUDA_RESTRICT off the new PTQ1_0 kernel signature.

Two notes that may matter for the Hopper/Blackwell builds that currently fail:

  • MMQ on sm_120 resolves through the Ampere tile table (mmq-config-blackwell.cuh ends with return ggml_cuda_mmq_get_config_ampere(type, J, fallback);), so the new PTQ1_0 tile rows do apply on Blackwell once the compile issue is out of the way.
  • The in-place q4_0/q8_0 K/V flash-attention instances are globbed outside the GGML_CUDA_FA_ALL_QUANTS branch, so they are present without that flag; the flag is only needed for K≠V or for q4_1/q5_0/q5_1.

Measured after the fix on an RTX 5070 Ti Laptop (sm_120), paired runs against the stock fork binary, same PTQ1_0 file, -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0: pp512 519.4 → 1192.3 t/s and tg128 48.0 → 62.9 t/s at 0 depth; pp512 408.9 → 834.6 t/s and tg128 34.0 → 42.1 t/s at 32768 depth.

@professorpalmer

Copy link
Copy Markdown

The C2912 / GGML_CUDA_RESTRICT diagnosis is right, and that hunk should land on this PR. Thank you for the minimal repro and the sm_120 rebuild.

The 5070 Ti timings attached to that comment are not this PR. #218 does not touch the MMQ tile loader. Standalone, pp512 is flat (bri-prism: 3090 753→752, 4090 1580→1587; same on a 4070: 600→599). A 519→1192 pp512 move is #214, which lives on the #221 integration branch. Same for mixing the in-place q4_0/q8_0 flash-attention note in here — that commit is also #221, not #218.

#218's own win, as already measured: 2–8 column PTQ1_0 and Ampere decode (3090 tg128 +27%; 4070 pp2 65→92, pp4 73→149). Ada batch-1 is the #215 layout. Please keep those columns on this thread so review does not think this patch doubles prefill.

@professorpalmer

Copy link
Copy Markdown

On the one real code finding (C2912 / GGML_CUDA_RESTRICT on mul_mat_vec_ptq1_0_pt parameters): agreed, that hunk should land here. It is compile-only — MSVC and GCC host stubs disagree with __restrict on the kernel signature when you build sm_120. It does not change kernels, logits, or any of the timings on this thread.

It is not in 5883186 yet. We hit the same C2912 on Windows (CUDA 13, MSVC) while producing a multi-arch zip, and shipped around it: sm_75/86/89 SASS plus compute_89 PTX, no native 120 target. RTX 50 JITs the 89 PTX. Desktop 4070 / Ampere numbers are unaffected.

So: apply the local-alias pattern already used by mul_mat_vec_q (plain pointers in the signature, GGML_CUDA_RESTRICT on the locals). After that, CMAKE_CUDA_ARCHITECTURES=120 should build without a side patch. Not a reason to reopen the speed or layout discussion.

@sudoingX

Copy link
Copy Markdown
Author

Pushed 497ea28 on top of 5883186, two commits:

  • 2578fdf keeps GGML_CUDA_RESTRICT off the formal parameters of mul_mat_vec_ptq1_0_pt and aliases them inside the body, the mul_mat_vec_q pattern. Reproduced the failure first: the unfixed head compiled for sm_90 (CUDA 12.4, PDL on) fails on mmvq.cu with the same template-id '__wrapper__device_stub_mul_mat_vec_ptq1_0_pt<1, 4, true, true>' ... does not match any template declaration diagnostic; the fixed head compiles mmvq.cu.o and quantize.cu.o clean for sm_90. Same hunk as @AlexGabbia posted, thanks for the MSVC confirmation.
  • 497ea28 states the invariance guarantee as 1 to 4 columns in the common.cuh and fattn.cu comments, as requested.

Regression check on an RTX 3060 (sm_86), same PTQ1_0 file, llama-bench -fa 1 -ctk q4_0 -ctv q4_0 -r 3: tg128 40.54 ± 0.09 t/s, pp512 268.67 ± 2.50 t/s (40.47 / 267.8 at 5883186). test-backend-ops -o MUL_MAT -p PTQ1_0 passes. Greedy identity with the MTP head under GGML_CUDA_BATCH_INVARIANT=1 --spec-type draft-mtp --spec-draft-n-max 1, three prompts, byte-identical to the flag-off transcripts.

The reconciliation with #215 stays as proposed above. #221 already carries this branch's commits and the hybrid dispatch, so 2578fdf applies there unchanged.

professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 21, 2026
C2912 / stub mismatch on sm_90 and sm_120 when GGML_CUDA_RESTRICT is on the formal parameters of mul_mat_vec_ptq1_0_pt (same hunk as 2578fdf on PrismML-Eng#218). Ampere (cc 800-889) takes the planar 1-col kernel; Ada and newer keep SoA. 3060: PrismML-Eng#218 was +5.9% tg128 vs PrismML-Eng#215 at one column.
@khosravipasha
khosravipasha requested a balanced review from Copilot September 21, 2026 00:32

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 review overview

🟡 Changes recommended

Environment parsing, shared-memory bounds, and the documented invariance guarantee need correction.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)

Comment on lines +432 to +435
if (!ptq1_0_pt_enabled() || nchannels_dst != 1 || nsamples_dst != 1 || ncols_x % QK_PTQ1_0 != 0 ||
ncols_dst < 1 || ncols_dst > PTQ1_0_PT_MAX_COLS || 2 * (ncols_x / QK_PTQ1_0) * ncols_dst > PTQ1_0_PT_SMEM_FLOATS) {
return false;
}
Comment thread ggml/src/ggml-cuda/common.cuh Outdated
Comment on lines +176 to +180
// GGML_CUDA_BATCH_INVARIANT=1: prefer kernels whose per-column arithmetic does not depend on the
// number of columns in the batch, so that a token decoded alone and a token verified inside a
// speculative batch see the same logits bit for bit. Whole-model invariance is established for
// batches of 1 to 4 columns; 5 to 8 columns agree with each other but can differ from 1 to 4.
// Costs some throughput at 2 to 8 columns.
@bri-prism

Copy link
Copy Markdown
Collaborator

Agent review follow-up: the exact-tree delta to current head 497ea28 changes the new kernel to unrestricted pointer parameters with GGML_CUDA_RESTRICT local aliases. This addresses the signature mismatch identified in the earlier build failure. The failures above belong to tested head 5883186; the new head has not been rebuilt or benchmarked in this campaign. The delta also correctly narrows the whole-model invariance claim to batches 1–4. Please retain a fresh Hopper/Blackwell build check before merging.

…ag changes

The comment in common.cuh read as a whole-model promise. The flag only
changes the F16 and BF16 mat-vec paths it selects, the PTQ1_0 mat-vec and
flash attention up to 8 queries, for batches of 1 to 4 columns; other
weight types and attention shapes outside those kernels can still pick
batch-dependent kernels. Say that, and keep the 1 to 4 wording.
…MMQ tile path

With the branch-free PTQ1_0 MMQ tile loader (PrismML-Eng#214) in the tree, the MMQ path
is the faster one from 5 columns on. On an RTX 3060 with the K = 5120
projections of Bonsai 2 27B, llama-bench -p 8 runs a batch of 8 in 64.4 ms
through the new MMQ tiles against 112.7 ms through the 8-column mat-vec
(five-arm a/b, r=3, github.com/sudoingX/bonsai2-small-gpu
kernel/ab/ab_215_214_rtx3060.md). The mat-vec keeps its lead at 2 to 4
columns (1.20x / 1.49x / 1.77x of a single pass against 1.46x / 2.21x /
2.75x for the tile path), which is the range speculative verification uses.

ggml_cuda_should_use_mmvq now routes PTQ1_0 to the mat-vec for ne11 <= 4
(PTQ1_0_PT_MAX_COLS, was 8) and the 5 to 8 column instantiations of the
dedicated kernel are gone. GGML_CUDA_BATCH_INVARIANT keeps its 1 to 4 column
guarantee; the comment no longer mentions 5 to 8, which the flag never
covered once those batches take the tile path.
@sudoingX

Copy link
Copy Markdown
Author

Rebased onto the current prism head, bdc23b5 (the #214 merge), with no conflicts; the branch is now 10 commits at 285542d.

The mat-vec column limit stops at 4. PTQ1_0_PT_MAX_COLS is 4 and ggml_cuda_should_use_mmvq routes PTQ1_0 to the dedicated mat-vec for 1 to 4 columns; 5 and above go to #214's MMQ tile path, which is now underneath this branch. Reason: on the RTX 3060 with the K = 5120 projections, the branch-free tile loader does a batch of 8 in 64.4 ms where the 8-column mat-vec took 112.7 ms (llama-bench pp8, r=3, five-arm a/b: https://github.com/sudoingX/bonsai2-small-gpu/blob/main/kernel/ab/ab_215_214_rtx3060.md). The mat-vec keeps 2 to 4 columns, where it costs 1.20x / 1.49x / 1.77x of a single pass against 1.46x / 2.21x / 2.75x for the tile path; that is the range speculative verification uses. The 5 to 8 column instantiations are gone and the two comments plus the description say 1 to 4 (commit 285542d).

sm_86 after the rebase, RTX 3060 12GB, CUDA 12.4, original Ternary-Bonsai-2-27B-PTQ1_0.gguf, head 285542d:

  • test-backend-ops test -b CUDA0 -o MUL_MAT,MUL_MAT_ID,MUL_MAT_VEC_FUSION -p ptq1_0: 216/216, the four shared-memory boundary cases included.
  • llama-bench -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0 -p 512 -n 128 -r 3: tg128 40.56 +/- 0.06 tok/s, pp512 523.47 +/- 10.29 tok/s (269.08 on the previous base; the gain is cuda: branch-free PTQ1_0 MMQ tile loader and full Ampere tile table (2x prefill) #214's prefill).
  • -p 1,2,3,4,8 -n 0 -r 3: 33.98 / 57.27 / 69.49 / 78.39 / 124.39 tok/s. pp8 was 71.00 tok/s (112.7 ms per batch) through the 8-column mat-vec; through the tile path it is 64.3 ms.
  • Identity, GGML_CUDA_BATCH_INVARIANT=1 with --spec-type draft-mtp --spec-draft-n-max 1 on the merged MTP file at 131072, temperature 0, 300 tokens: byte-identical to the head-off transcript on all three prompts.

Both earlier findings are closed. @bri-prism reviewed 3443dde on Sep 21: the guard and the launcher share ptq1_0_pt_smem_bytes() with the real rows per CTA and the gate multiplier, oversized launches fall back to the generic path, the restrict fix retained, no new finding. @AlexGabbia built sm_120 on Sep 21, which the CUDA 12.4 box here cannot: RTX 5070 Ti Laptop, CUDA 13.0.88, MSVC, -DCMAKE_CUDA_ARCHITECTURES=120, 405/405 targets with 0 errors, test-backend-ops -o MUL_MAT,MUL_MAT_ID,MUL_MAT_VEC_FUSION -p ptq1_0 160/160 including the four new boundary cases, and end to end pp512 1103.55 +/- 38.08 tok/s, tg64 48.59 +/- 2.62 tok/s. Thank you both.

Could you re-review when you have a moment? The changes-requested is from Sep 19 and refers to the restrict failure fixed in 2578fdf.

@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 285542d. Every point I raised is addressed, the standing build condition is met, and the new column cap is measured rather than asserted. Clearing my change request.

P1, the restrict qualifiers. Resolved as suggested: the formal parameters of mul_mat_vec_ptq1_0_pt are unqualified with local GGML_CUDA_RESTRICT aliases inside, and the comment at mmvq-ptq1_0.cuh:295 records why, which is the part that stops someone reinstating it later. Independently build-verified by @AlexGabbia on sm_120 with CUDA 13.0 and MSVC, 405/405 targets and zero errors. That also satisfies the fresh Hopper/Blackwell build check I asked to retain before merge, so consider that condition discharged.

The batch-invariance documentation. Narrowed from 1 to 8 down to 1 to 4 in both common.cuh and fattn.cu, with the 5 to 8 behaviour stated explicitly rather than left implied, and the code predicate unchanged. That was the ask.

The shared-memory budgeting fix. I reviewed 844dd06 separately and had no finding. The entry guard and the launcher now compute the same number through ptq1_0_pt_smem_bytes(), including rows per CTA and the gate multiplier, with oversized launches falling back to the generic path.

The new column cap is the right shape and I checked it holds in code. PTQ1_0_PT_MAX_COLS is 4 at mmvq-ptq1_0.cuh:35, the kernel entry guard rejects outside 1 to 4 at :448, and ggml_cuda_should_use_mmvq routes on ne11 <= PTQ1_0_PT_MAX_COLS at mmvq.cu:304. Host routing and device guard read the same constant rather than two copies of the same number, which is what keeps a future edit from splitting them. This is the failure mode where a dispatch gate and the kernel it dispatches to disagree about their own limits, and it is avoided here by construction.

The justification is measured, on an RTX 3060 with the K = 5120 projections, five arms at r = 3, 64.4 ms through the MMQ tiles against 112.7 ms through the 8-column mat-vec at a batch of 8, with the mat-vec keeping its lead at 2 to 4 columns where speculative verification actually lives. Handing the range you lose to #214 rather than defending it is the right call, and publishing the arms is what makes the cap reviewable instead of a magic number.

One thing still outstanding, and it is sequencing rather than code. The activation layout still has to converge with #215. I approved #215 earlier today on its own contents, so both are now clear on their merits while carrying different layouts, and #221 is where the hybrid dispatch reconciles them. I have not reviewed #221. Whoever merges these should decide the layout there rather than letting merge order decide it, because merge order will otherwise pick a winner silently.

Method note: source reading at 285542d plus the build and benchmark reports on this thread. I did not rebuild or rerun anything myself, and the 3060 numbers are the author's.

professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
… (+3.8% TG on Ada)

build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp
(3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on
an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN
weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the
identical GEMVs in attention layers.

When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN
node's state input, the graph evaluator skips the GET_ROWS node and records the gather
(cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row
s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several
sequences a gathered row may alias a row another sequence writes in the same op.
GGML_CUDA_GDN_GATHER_FUSION=0 disables it.

The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context,
beside the concurrent-stream state), keyed by node pointer and reset at the start of every
graph evaluation, so two contexts or host threads decoding at once never see each other's
skips. This replaces the process-wide static map in the first revision of PrismML-Eng#215.

Independent of the activation layout change; split out of PrismML-Eng#215 so that layout can be compared
with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of PrismML-Eng#215 (+3.8%). pp512 unchanged.

test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged.
400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
…per-row small-K GEMV (+14% TG)

Merged onto the planar-transposed activation layout and dedicated 2-8 column kernel
(PrismML-Eng#218, sudoingX). One decision, ggml_cuda_q8_1_layout_for(type, ncols_dst, ids),
is evaluated by both the quantizer call and the kernel switch:

  PTQ1_0, 1 column, no ids  -> SOA_ISUM (this change): exact int16 sums, warp-per-row small-K geometry
  PTQ1_0, 2-8 columns / MoE -> PT (PrismML-Eng#218): shared weight decode, full lane utilization
  anything else             -> plain block_q8_1

GGML_CUDA_BATCH_INVARIANT=1 forces the one-column case onto PT as well so a token decoded alone
and a token verified inside a speculative batch still see identical arithmetic.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
C2912 / stub mismatch on sm_90 and sm_120 when GGML_CUDA_RESTRICT is on the formal parameters of mul_mat_vec_ptq1_0_pt (same hunk as 2578fdf on PrismML-Eng#218). Ampere (cc 800-889) takes the planar 1-col kernel; Ada and newer keep SoA. 3060: PrismML-Eng#218 was +5.9% tg128 vs PrismML-Eng#215 at one column.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
… (+3.8% TG on Ada)

build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp
(3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on
an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN
weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the
identical GEMVs in attention layers.

When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN
node's state input, the graph evaluator skips the GET_ROWS node and records the gather
(cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row
s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several
sequences a gathered row may alias a row another sequence writes in the same op.
GGML_CUDA_GDN_GATHER_FUSION=0 disables it.

The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context,
beside the concurrent-stream state), keyed by node pointer and reset at the start of every
graph evaluation, so two contexts or host threads decoding at once never see each other's
skips. This replaces the process-wide static map in the first revision of PrismML-Eng#215.

Independent of the activation layout change; split out of PrismML-Eng#215 so that layout can be compared
with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of PrismML-Eng#215 (+3.8%). pp512 unchanged.

test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged.
400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
…per-row small-K GEMV (+14% TG)

Merged onto the planar-transposed activation layout and dedicated 2-8 column kernel
(PrismML-Eng#218, sudoingX). One decision, ggml_cuda_q8_1_layout_for(type, ncols_dst, ids),
is evaluated by both the quantizer call and the kernel switch:

  PTQ1_0, 1 column, no ids  -> SOA_ISUM (this change): exact int16 sums, warp-per-row small-K geometry
  PTQ1_0, 2-8 columns / MoE -> PT (PrismML-Eng#218): shared weight decode, full lane utilization
  anything else             -> plain block_q8_1

GGML_CUDA_BATCH_INVARIANT=1 forces the one-column case onto PT as well so a token decoded alone
and a token verified inside a speculative batch still see identical arithmetic.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 23, 2026
C2912 / stub mismatch on sm_90 and sm_120 when GGML_CUDA_RESTRICT is on the formal parameters of mul_mat_vec_ptq1_0_pt (same hunk as 2578fdf on PrismML-Eng#218). Ampere (cc 800-889) takes the planar 1-col kernel; Ada and newer keep SoA. 3060: PrismML-Eng#218 was +5.9% tg128 vs PrismML-Eng#215 at one column.
@bri-prism

Copy link
Copy Markdown
Collaborator

Kernel-level numbers from an RTX 4090 (sm_89, CUDA 12.8), comparing this PR's head (285542d) with #215's head (ad61b84) and prism at 0324c66. They come from test-backend-ops perf with n = 1, min of 2 runs. Each cell is PTQ1_0 time divided by PQ2_0 time on the same build.

shape prism #218 #215
MUL_MAT, K = 1536, weights larger than L2 1.48 0.84 0.85
MUL_MAT, K = 2688, L2 resident (M = 6144) 1.60 1.26 1.03
MUL_MAT, K = 4096, L2 resident 1.20 0.91 0.75
MUL_MAT_ID, 128 experts, top 6, M = 1856, K = 2688 1.78 2.03 1.11
MUL_MAT_ID, 128 experts, top 6, M = 2688, K = 3712 1.80 1.86 0.99

The dedicated mat-vec does what it says for plain MUL_MAT. As the description notes, MoE calls stay on the generic kernel. On the first expert shape that path is about 14% slower than on prism (I have not isolated why), and PTQ1_0 still takes about twice as long as PQ2_0. For MoE models that is where most of the decode time goes, so it would be good to either route MUL_MAT_ID through the new layout properly or keep the old layout for it. Other types match prism to within 1.2%, and test-backend-ops -o MUL_MAT / -o MUL_MAT_ID -p ptq1_0 pass on this head.

professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 25, 2026
…per-row small-K GEMV (+14% TG)

Two changes to the batch-1 PTQ1_0 mat-vec path, found by tracing a Bonsai 2 27B decode graph
on an RTX 4070 (sm_89) with CUPTI. Same weights, same bits, output byte-identical.

1. Exact integer sums + warp-transposed (SoA) q8_1 activation layout.
   quantize_q8_1<exact_isum> stores the int16 sum of each 32-value block in ds.y (instead of
   the float input sum) and writes the activations grouped by 32 K-blocks with word w of the
   group contiguous. The ternary vec-dot then accumulates raw trits {0,1,2} with dp4a and
   corrects once per block, removing a __vsub4 per 4 weights (no native SIMD byte subtract on
   sm_8x), and a warp-wide load of "word w" in the small-K geometry hits 1 L1 wavefront instead
   of ~36 (lanes were reading block_q8_1 structs 144 B apart). That LSU pressure, not GDDR,
   capped every PTQ1_0 GEMV at ~370 GB/s. Bytes per column are unchanged when K is padded to
   32*128. Only PTQ1_0 opts in (ggml_cuda_q8_1_exact_isum); other types keep the AoS layout.

2. Warp-per-row geometry for small-K PTQ1_0 GEMVs.
   The generic small_k loop strides K-blocks across the whole 128-thread block, so for K=5120
   (40 blocks) only threads 0..39 ever load anything and two of four warps just hold SM slots
   at __syncthreads. Each warp now owns one row and its 32 lanes stride that row's K-blocks;
   shuffle reduction, no shared memory, no barrier. qkv 391 -> 445 GB/s, lm_head 414 -> 460.

The recurrent-state gather fusion that was part of the first revision of this PR is now its
own PR so that this activation layout can be compared with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3:
  prism @ 9a9394a                 51.0 t/s tg128
  + 1 + 2 (this PR)               56.4     (+10.5%)
  + GDN gather fusion (follow-up) 58.5     (+14.7%)

test-backend-ops CUDA0 vs CPU: MUL_MAT ptq1_0 45/45 supported cases, MUL_MAT_ID ptq1_0 75/75.
400 greedy tokens identical with the change on and off.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 25, 2026
… (+3.8% TG on Ada)

build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp
(3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on
an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN
weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the
identical GEMVs in attention layers.

When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN
node's state input, the graph evaluator skips the GET_ROWS node and records the gather
(cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row
s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several
sequences a gathered row may alias a row another sequence writes in the same op.
GGML_CUDA_GDN_GATHER_FUSION=0 disables it.

The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context,
beside the concurrent-stream state), keyed by node pointer and reset at the start of every
graph evaluation, so two contexts or host threads decoding at once never see each other's
skips. This replaces the process-wide static map in the first revision of PrismML-Eng#215.

Independent of the activation layout change; split out of PrismML-Eng#215 so that layout can be compared
with PrismML-Eng#218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of PrismML-Eng#215 (+3.8%). pp512 unchanged.

test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged.
400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 25, 2026
…per-row small-K GEMV (+14% TG)

Merged onto the planar-transposed activation layout and dedicated 2-8 column kernel
(PrismML-Eng#218, sudoingX). One decision, ggml_cuda_q8_1_layout_for(type, ncols_dst, ids),
is evaluated by both the quantizer call and the kernel switch:

  PTQ1_0, 1 column, no ids  -> SOA_ISUM (this change): exact int16 sums, warp-per-row small-K geometry
  PTQ1_0, 2-8 columns / MoE -> PT (PrismML-Eng#218): shared weight decode, full lane utilization
  anything else             -> plain block_q8_1

GGML_CUDA_BATCH_INVARIANT=1 forces the one-column case onto PT as well so a token decoded alone
and a token verified inside a speculative batch still see identical arithmetic.
professorpalmer added a commit to professorpalmer/llama.cpp-ada-ternary that referenced this pull request Sep 25, 2026
C2912 / stub mismatch on sm_90 and sm_120 when GGML_CUDA_RESTRICT is on the formal parameters of mul_mat_vec_ptq1_0_pt (same hunk as 2578fdf on PrismML-Eng#218). Ampere (cc 800-889) takes the planar 1-col kernel; Ada and newer keep SoA. 3060: PrismML-Eng#218 was +5.9% tg128 vs PrismML-Eng#215 at one column.
@LamplighterPaul

Copy link
Copy Markdown

Heads-up from testing this branch on an RTX 5080 (sm_120): at 285542d, mul_mat_vec_ptq1_0_pt is launched with PDL through ggml_cuda_kernel_launch, but it never calls ggml_cuda_pdl_sync(). It can read vy before the q8_1 quantize kernel has written it, and on Hopper/Blackwell llama-server output breaks into !!!! a few tokens in. GGML_CUDA_PDL=0 or GGML_CUDA_DISABLE_GRAPHS=1 hides it. llama-bench and test-backend-ops don't catch it. Ampere and Ada are unaffected.

The fix is one line, at no speed cost: sudoingX#1. Isolation table and the full 5080 numbers: sudoingX/bonsai2-small-gpu#3.

@professorpalmer

Copy link
Copy Markdown

Same hole is in #221 (this kernel is the hybrid 2?4 column path). Picked up the one-line ggml_cuda_pdl_sync() there. This 4070 is Ada and never takes PDL, so I cannot add a second isolation table. The 5080 numbers stand.

@professorpalmer

professorpalmer commented Sep 25, 2026 •

Copy link
Copy Markdown

Thanks @LamplighterPaul, seriously appreciate you catching this and doing the isolation work. My 4070 would never have exposed it.

The fix is in our #221 combo branch and patch 0023 in the setup bundle. It uses the existing PDL dependency wait before the kernel reads the quantized activations. Your PR on Sudo's fork is still the route into #218.

Validation is now green:

For anyone on the Linux bundle, from the repo directory:

git pull --ff-only
bash build/build_linux.sh

Then restart with the rebuilt server. Existing downloaded binaries don't pick this up just from pulling the patches.

Your 5080 isolation remains the runtime evidence; I haven't independently rerun it on Hopper/Blackwell and I'm not claiming a new performance result. If anything still breaks on the fixed combo branch, send me the commit, model, launch command and failing output and I'll dig into our side. Thanks again for helping get this right.

jasontitus added a commit to jasontitus/mlx-serve that referenced this pull request Sep 25, 2026
…ernary GEMV

The multi-row decode-once kernel ports the approach of PrismML-Eng/llama.cpp#218
(CUDA: fast, batch-invariant small-batch PTQ1_0 mat-vec) by @sudoingX, by way of
its Metal port on jasontitus/llama.cpp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHJDMbEnsswDXJU3LAQQZ7
@LamplighterPaul

Copy link
Copy Markdown

@professorpalmer on it, i can easily re-run it shouldn't be a problem.

jasontitus added a commit to jasontitus/mlx-serve that referenced this pull request Sep 25, 2026
…ernary GEMV

The multi-row decode-once kernel ports the approach of PrismML-Eng/llama.cpp#218
(CUDA: fast, batch-invariant small-batch PTQ1_0 mat-vec) by @sudoingX, by way of
its Metal port on jasontitus/llama.cpp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHJDMbEnsswDXJU3LAQQZ7
ddalcu pushed a commit to ddalcu/mlx-serve that referenced this pull request Sep 25, 2026
…on (Bonsai 1 + 2) (#530)

* perf: 2-bit ternary GEMV for 1..8 rows, per GPU generation, on Bonsai 1 too

Ports the multi-column mat-vec idea from the Bonsai Metal work on the
llama.cpp fork: each packed 2-bit word decodes once and serves every
activation row (plain decode, batched decode, MTP verify), so 2..8-row
steps stop paying one full decode per row.

- qmv2: one `msv_qmv2_rows` kernel over M = 1..8, bf16 or f16 activations
  (x read as half2 for f16, float2 for bf16), R rows per simdgroup and G
  simdgroups per threadgroup as template args. Needs biases == -scales.
- `planFor` picks the geometry per GPU generation from kernel sweeps on the
  Bonsai 27B shapes: g13 (M1 Ultra) R4G2; g16 (M4 Pro) R4G2 odd M, R2G8 even;
  g17 (M5 Max) R4G2 at M=1 f16, R4G8/R2G8 above, stock at M=5 and bf16 M=1.
  Unmeasured generations and phones keep the previous dispatch exactly.
- Non-Hadamard ternary packs (Prism Bonsai 1) get the kernel: a load-time
  scan arms `ternary_2bit` only when every matmul weight's biases are
  exactly -scales (an embedding only gathered is exempt; the first
  offending tensor is logged otherwise).
- On measured generations a generic-bias weight of a Hadamard pack keeps
  the bias-aware `qmv` at M=1.

Tests: parity vs f32 truth at every M, geometry, dtype and bias layout;
per-generation routing incl. legacy byte-for-byte; the ternary scan; the
qmatmul wiring (red when the ternary branch is removed).

Rejected after measurement: a fused gate/up/SwiGLU GEMV (bit-exact, ~1% at
M=1 on M1 Ultra, slower at M>=2). The GDN state gather/copy-back removed on
the fork does not exist here.

* perf: qmv2 plans send narrow outputs and M5's non-MLP single rows to stock

Kernel sweeps on the remaining Bonsai shapes: below 2048 output rows (GDN
a/b at 48, attention k/v at 1024) too few threadgroups stream K and stock
is up to 2x faster on M1 Ultra and M5 Max at every width; on M5 Max the
single-row kernel only wins at the MLP width (lm_head 1.06x, 17408 rows)
and loses 3-12% below it. Unmeasured generations keep the legacy dispatch.


* docs: Bonsai ternary kernel CHANGELOG line and the per-chip tuning gotcha

* docs: credit @sudoingX's CUDA small-batch kernels behind the Bonsai ternary GEMV

The multi-row decode-once kernel ports the approach of PrismML-Eng/llama.cpp#218
(CUDA: fast, batch-invariant small-batch PTQ1_0 mat-vec) by @sudoingX, by way of
its Metal port on jasontitus/llama.cpp.

* docs: leave CHANGELOG to the v26.9.6 release PR (#518)

The entry moves to the PR description so it lands in #518's Changes list
instead of a second v26.9.6 header that conflicts with #518 and #531.

* test: tell the ternary route from stock by bias, not by rounding luck

The routing test assumed the kernel and stock round differently on a
random fixture. On M4 Max they came out bit-identical, so the vacuity
guard failed. Biases of +scale make them disagree by construction: the
ternary kernel never reads biases.
@LamplighterPaul

Copy link
Copy Markdown

Ran #221 (09b6cce) on the RTX 5080 (sm_120, CUDA 13.3). The PDL fix holds and output is coherent across text, image, code, prose and bash. Decode is +8% over our fixed #218 build (tg128 104.8 vs 96.9), MTP n-max 2 gets 169 / 137 / 173 tok/s on code / prose / bash at 131K, and VRAM is ~470 MiB lower. Nice work.

Two small notes, in case they're useful:

  • Under GGML_CUDA_BATCH_INVARIANT=1, MTP-on output is no longer byte-identical to MTP-off (late and still coherent, e.g. "coastal waters" vs "coastal areas"). It bisects to 5300cd1 (the per-pair epilogue); restoring the old epilogue brings identity back at ~3% decode. It only matters if you want BATCH_INVARIANT to prove MTP is lossless.
  • gated_delta_net_cuda (4381a8a) reads s_ids[sequence] before ggml_cuda_pdl_sync(). It's safe today because inp_s_copy is host-uploaded, but ggml_cuda_try_gdn_gather_skip accepts any I32 tensor.

@professorpalmer

Copy link
Copy Markdown

Thanks for running 09b6cce on the 5080. That's #221, not this PR's kernel by itself, and the PDL wait holding under real llama-server (text / image / code / prose / bash) is the check we could not do on Ada.

The +8% tg128 and the 131K MTP numbers are the hybrid stack (#215 one-column + this PR at 2–4 + in-place FA + GDN). Good to have them on a Blackwell part.

Both notes are real and belong on #221 / #220, not here.

No patch from this box; we'll pick those two up on the combo branch.

bri-prism pushed a commit that referenced this pull request Sep 29, 2026
… Ada: #218 + #215 hybrid PTQ1_0 dispatch, in-place q4_0/q8_0 K/V flash attention (integration checkpoint) (#221)

* Add: planar-transposed activation layout for the PTQ1_0 mat-vec path

The mmvq path gave every thread one 128-weight PTQ1_0 block and read the
activations as 32 scattered 4-byte loads per column out of 36-byte
block_q8_1 structs, so every extra column cost a full pass: 47 / 104 / 154 /
201 us for 1 / 2 / 3 / 4 columns on a 4096 x 14336 matrix (RTX 3060,
test-backend-ops perf). Quantize PTQ1_0 activations into a planar-transposed
layout instead: per column, 8 planes of 16-byte quant pieces and one plane of
the 4 (d, s) half2 scales, 16 bytes per 128-element block each, with the same
bytes per row and the same column stride as block_q8_1. A thread then reads
its block's activations as 9 aligned 16-byte loads with adjacent lanes on
adjacent pieces: 46 / 55 / 65 / 75 us. The trit decode is unchanged; the
byte-wise minus one is (q + 0x7F7F7F7F) ^ 0x80808080 instead of __vsub4.
nwarps is pinned to 4 for every column count so the K partition, and with it
the fp32 summation order, does not depend on the batch. mmvq now handles
PTQ1_0 up to 8 columns (MMQ took 395 us at 8). HIP keeps the old layout.

* Add: dedicated PTQ1_0 mat-vec kernel with full lane utilization

With 128-element blocks the generic kernel's 128 threads cover 128 K blocks
per iteration, so a K = 5120 projection (40 blocks) keeps 31% of the lanes
busy and K = 17408 (136 blocks) 53%. This kernel handles plain 2D MUL_MAT
(no batch dims, no expert ids, K a multiple of 128, up to 8 columns): it
flattens (row group, K block) pairs into one index space with rows_per_cta
chosen on the host to fill whole 128-thread iterations, gives each thread 4
rows per K block (2 at 5 to 8 columns) for latency hiding and activation
reuse, stores one fp32 partial per (row, column, K block) in dynamic shared
memory and has one warp per (row, column) sum them in a fixed order
(lane-strided sequential, then a butterfly). The per-block partial is
d * sum_k d8_k * sumi_k with exact integer sumi_k, written as __fmaf_rn and
__fmul_rn so every instantiation rounds alike, and the order depends only on
the weight shape: a column's result is bit-identical for every column count
1 to 8. Fusion (bias, gated GLU epilogue) is supported for one column as in
the generic kernel, which remains the path for batched and MoE calls.

Bonsai 2 shapes, RTX 3060 12GB, us per call, 1 column: attn_qkv 5120 x 10240
72.4 to 41.8, ffn_up 5120 x 17408 120.0 to 66.7, attn_gate 5120 x 6144 44.8 to
27.7. Whole model llama-bench tg32 26.1 to 39.8 tok/s, pp2 34.1 to 61.1,
pp3 33.3 to 72.0.

* Test: add Bonsai 2 projection shapes to the mul_mat perf cases

* Add: GGML_CUDA_BATCH_INVARIANT for batch-invariant small-batch kernels

Off by default, nothing changes. When set, kernels are picked so that the
per-column arithmetic of a token does not depend on how many columns (1 to 8)
share the launch: F16 and BF16 matrices with at most 512 rows take the
mul_mat_vec_f kernel for 1 to 8 columns on Ampere and newer (its K partition
depends on K only), flash attention with up to 8 queries takes the vector
kernel on Turing and newer instead of switching to the tensor-core kernel at
2 queries, and the KV split (parallel_blocks) is sized as for one query tile
so the partial-softmax combine order does not depend on the batch. Together
with the batch-invariant PTQ1_0 mat-vec this makes the logits of a token
bit-identical whether it is decoded alone or verified inside a speculative
batch of 2, 3 or 4 tokens (measured on Ternary Bonsai 2 27B: greedy output
with draft-mtp equals greedy output without it on three prompts at n-max 1, 2
and 3). Batches of 5 to 8 agree with each other but not with 1 to 4. Costs
throughput at depth (the vector attention kernel at 3 queries over 42K keys).

* Fix: use the mat-vec kernel for bf16 matrices under 64 rows at 2 to 8 columns

The qwen35 gated-delta-net gate projections are bf16 [5120 x 48]. At 2 to 8
columns on Ampere they took a 10.5 us path where mul_mat_vec_f takes 4.4 to
7.3 us (test-backend-ops perf, RTX 3060); at one column both already used
mul_mat_vec_f (3.3 us). 96 calls per token on the 27B model.

* cuda: fold the recurrent-state gather into the GATED_DELTA_NET kernel (+3.8% TG on Ada)

build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp
(3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on
an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN
weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the
identical GEMVs in attention layers.

When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN
node's state input, the graph evaluator skips the GET_ROWS node and records the gather
(cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row
s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several
sequences a gathered row may alias a row another sequence writes in the same op.
GGML_CUDA_GDN_GATHER_FUSION=0 disables it.

The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context,
beside the concurrent-stream state), keyed by node pointer and reset at the start of every
graph evaluation, so two contexts or host threads decoding at once never see each other's
skips. This replaces the process-wide static map in the first revision of #215.

Independent of the activation layout change; split out of #215 so that layout can be compared
with #218 on its own.

RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks,
three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of #215 (+3.8%). pp512 unchanged.

test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged.
400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.

* cuda: PTQ1_0 decode on consumer Ampere/Ada: SoA q8 activations, warp-per-row small-K GEMV (+14% TG)

Merged onto the planar-transposed activation layout and dedicated 2-8 column kernel
(#218, sudoingX). One decision, ggml_cuda_q8_1_layout_for(type, ncols_dst, ids),
is evaluated by both the quantizer call and the kernel switch:

  PTQ1_0, 1 column, no ids  -> SOA_ISUM (this change): exact int16 sums, warp-per-row small-K geometry
  PTQ1_0, 2-8 columns / MoE -> PT (#218): shared weight decode, full lane utilization
  anything else             -> plain block_q8_1

GGML_CUDA_BATCH_INVARIANT=1 forces the one-column case onto PT as well so a token decoded alone
and a token verified inside a speculative batch still see identical arithmetic.

* cuda: PTQ1_0 hybrid dispatch: template y_soa, MMQ from 5 columns

The SoA/planar choice is a kernel template parameter (only instantiated true for PTQ1_0 at one
column) instead of a runtime branch inside the K loop; the runtime branch cost 5% TG on Ada.
ggml_cuda_should_use_mmvq keeps PTQ1_0 on the mat-vec path up to 4 columns: with the branch-free
MMQ tile loader the tile path overtakes the planar mat-vec kernel at n=5 (RTX 4070, Bonsai 2 27B:
163 vs 137 t/s at n=5, 258 vs 148 at n=8).

* cuda: flash attention MMA reads q4_0/q8_0 K/V in place (no F16 scratch copy)

The tensor-core flash attention path converted the whole quantized K/V cache
to F16 in a scratch buffer on every micro-batch: 1 GB per context at 262k
with q4_0 caches, 2 GB with a draft context, and a full re-conversion per
ubatch during prefill. With Bonsai 2 27B + MTP that pushed the 4070 12 GB
over the edge and WDDM spilled to system RAM (41 tok/s).

flash_attn_ext_f16 now takes type_KV as a template parameter. For q4_0 and
q8_0 the tile loader dequantizes blocks straight from the cache into the
shared-memory half2 tiles (aligned 32-bit word reads, scale from the block
header), the cp.async multi-stage pipeline is disabled for those types
(cp.async cannot dequantize), and K/V strides are byte strides. The
allocator no longer reserves the F16 copy when the native kernel handles the
call. Dispatch is limited to D=128/256 heads with matching K/V types and
word-aligned strides; anything else keeps the old conversion path.

test-backend-ops: 48 new FLASH_ATTN_EXT cases for q4_0/q8_0 at D=128/256,
GQA, batch 1-8, KV lengths incl. non-multiple of the tile, permuted layouts.
All pass against the CPU reference; the full suite is 2984/2984.

Bonsai 2 27B PTQ1_0 + MTP, RTX 4070 12 GB, 262144 context, ub 128:
resident, 222 MiB shared (no spill), 86 tok/s mean over 3 prompts.

* cuda: PTQ1_0 multi-column mat-vec: raw digits with exact activation sums, per-pair epilogue (+13% 3-col, +24% 4-col)

Two changes to mul_mat_vec_ptq1_0_pt, found with Nsight Compute on an RTX 4070 (sm_89).

1. The trit decode returns the raw base-3 digits {0,1,2} and the dot product uses a mixed-sign
   dp4a (u8 x s8) against the activations, subtracting the exact int16 sum of the 32 quantized
   activations once per sub-block: sum((q-1)*a) = sum(q*a) - sum(a). The PT quantizer now stores
   that sum bit-cast in the ds.y half slot (the SoA layout already did). Two ALU ops fewer per
   four weights; the result is the same integer, so output is bit-identical.

2. The epilogue summed each (row, column) pair with one warp: a runtime integer division per
   pair, a lane-strided loop and a 5-step shuffle butterfly. Under the profiler that was 23% of
   issued instructions and ~50% of warp stall samples (FADD on SHFL scoreboard, MUFU.RCP/I2F/F2I
   from the division) while the CTA held its slot and moved no bytes. Now one thread per pair sums
   the K blocks sequentially from shared memory with four interleaved accumulators, over a padded
   (odd) row stride so the reads are bank-conflict-free. The order still depends only on the
   weight shape, so the batch-invariant guarantee holds.

Profile of the 3-column gate|up GEMV (K=5120, N=17408) before -> after, ncu locked clocks:
2135 -> 1493 instructions per warp, 56.3 -> 43.5 us, DRAM 62% -> 83% of peak.
llama-bench, +1500 MHz memory: pp3 150 -> 169 tok/s, pp4 172 -> 212. pp1 unchanged (SoA path).

test-backend-ops -b CUDA0 -o MUL_MAT: 1283/1283.

* qwen35: MTP graph publishes only the output rows of h_nextn when embeddings_nextn_masked

llama_context::decode copies the first n_outputs rows of t_h_nextn when embeddings_nextn_masked is
set, but the QWEN35 MTP builder assigned t_h_nextn before the inp_out_ids gather, so the tensor had
n_tokens rows. Any MTP batch with more tokens than outputs (e.g. catch-up rows decoded together with
a draft row) returned row 0's hidden state for the draft row and the next draft step ran on the
wrong input. Never triggered before because every MTP decode had n_tokens == n_outputs or no outputs.

Gather before publishing in the masked case, exactly as the trunk graph does at its last layer;
the unmasked case is unchanged.

* speculative: draft-mtp decodes the catch-up rows together with the first draft row

process() used to run the draft over every row of the target's verify batch immediately, purely to
fill the draft's memory, and partly on rows the target then rejected. That is one graph launch and
one host round trip per step spent on nothing the next draft needs yet.

Single-head MTP with its own memory now keeps those rows (tokens, positions, shifted h) and decodes
only the accepted prefix at the start of draft(), in the same llama_decode as the first draft row.
Positions are contiguous and the draft row attends to the catch-up rows in-batch, so the draft is
unchanged; accept() trims the stash to the accepted prefix, and rows are decoded on their own when a
batch does not lead straight into a draft (prompt fed in tiny ubatches, drafting stopped, rollback
replay overwrites them). Shared-memory and chained-head drafters keep the eager path, as does
LLAMA_MTP_EAGER_CATCHUP=1 for A/B.

RTX 4070, Bonsai-2-27B PTQ1_0 + MTP head, 262k context, q4_0 K/V, greedy, --backend-sampling:
23.0 -> 22.4 ms/step, three-prompt mean 101.0 -> 103.3 tok/s with identical draft acceptance
(0.86 / 0.46 / 0.65) and byte-identical output vs the draft head off. Draft-side launches per step
4 -> 3.

* cuda: Hadamard transform quantizes its own output when every consumer is a PTQ1_0 mat-vec

A Hadamard-rotated activation ([MUL signs, RESHAPE,] MUL_MAT with GGML_HINT_SRC0_IS_HADAMARD)
whose every use is src1 of a PTQ1_0 mat-vec on the mmvq path never needs to exist in F32: each
consumer immediately quantizes it to q8_1. The transform kernel now quantizes as it goes and
writes the q8_1 rows, in the layout the consumers expect (AoS / PT / SoA with exact integer sums
and the same padding rule as ggml_cuda_mul_mat_vec_q), into its own output buffer; the mat-vecs
find the rows through a per-context registry keyed by tensor identity and skip their quantize
launch. On Bonsai 2 this removes ~390 launches per decode step.

Safety rules the pass enforces:
- every later node that reads the transform output (directly or via a whole-tensor reshape view)
  must be a PTQ1_0 MUL_MAT that ggml_cuda_mul_mat routes to mmvq, and the graph use count must
  match, so nothing ever reads the buffer as F32
- the registry is keyed by ggml_tensor pointer, never by data pointer: ggml-alloc recycles a dead
  output's block for later tensors in the same graph
- when the output buffer aliases the input (ggml-alloc does this for the output-projection input
  on every Bonsai layer: x is a reshape view of a cont, there is no in-place reuse through a view
  parent, so the cont is released at the MUL and mm takes its block two nodes later) the rows go
  to a pool block held until the next graph evaluation. Writing into the aliased buffer raced the
  kernel's own input reads and produced sporadic garbage that, on a hybrid model, entered the
  recurrent state and poisoned the rest of the sequence (surfaced as INT_MAX from the backend
  argmax over an all-NaN logits row, about once per ~170 requests)
- GGML_CUDA_FWHT_FUSION=0 disables the pass

fwht.cuh: the block butterfly is a shared inline so fwht.cu and the fused quantizer stay one
implementation.

Validated: 340 consecutive requests (probe.py x 20 at 262144 ctx, q4_0 KV, draft-mtp n_max=2,
backend sampling) with zero errors; output byte-identical to the unfused path.

* sampling: reject out-of-vocab token ids from the backend sampler and the draft

A backend-sampled id outside [0, n_vocab) (the CUDA argmax/top-k pad sentinel INT_MAX when a
logits row is all NaN) used to travel as-is into the server, where the tokenizer's vector::at()
threw "invalid vector subscript" and killed the request. Log the origin (idx, n_vocab, first
candidate) and fall back to the CPU chain instead; the draft-mtp loop drops a draft whose top
candidate id is out of vocab rather than feeding it to the verify decode.

* speculative: draft-mtp drops stale deferred rows when a new task starts on the slot

begin() used to decode whatever catch-up rows the previous task left pending. That is right only
when the new prompt reuses them (same tokens at the same positions, continuing what ctx_dft
holds). After a cancelled task or a different conversation landing on the slot, the server has
already trimmed ctx_dft past those rows and decoding them writes stale positions into the
recurrent draft state. Check first, flush when they belong, discard otherwise.

* cuda: release the FWHT-q8 held pool blocks LIFO and before the pools are destroyed

The registry keeps pool blocks alive across a graph when the fused Hadamard quantize would
otherwise write over its own input. Its implicit destructor released them oldest first, and it is
declared before the pools, so at context teardown the frees hit a destroyed VMM pool:
GGML_ASSERT(ptr == pool_addr + pool_used) at the end of llama-bench. Release newest first in a
destructor, and reset() from the context destructor ahead of the pools.

* server: --spec-draft-depth-max stops drafting once the sequence is deep

Deep in the context a decode step is bound by reading the KV cache. The draft passes and the
multi-column verify add to that read without shortening it, so past some depth speculation costs
more than the accepted tokens save: with Bonsai 2 27B on an RTX 4070 the MTP draft is +85% at
zero depth, even near 24-32k tokens and -27% at 64k. Past the cutoff the slot decodes one token
per step and the process() hook is skipped for batches that sit entirely beyond it (by position,
so the early ubatches of a long prompt still reach the draft context and remain reusable as a
prefix). Default 0 keeps the old behaviour. 0 / 32k / 64k tokens: 101 / 48 / 37 tok/s with a 24k
cutoff against 101 / 45 / 27 without.

* cuda: restrict off PTQ1_0 kernel params; Ampere uses the PT 1-col path.

C2912 / stub mismatch on sm_90 and sm_120 when GGML_CUDA_RESTRICT is on the formal parameters of mul_mat_vec_ptq1_0_pt (same hunk as 2578fdf on #218). Ampere (cc 800-889) takes the planar 1-col kernel; Ada and newer keep SoA. 3060: #218 was +5.9% tg128 vs #215 at one column.

* cuda: address Copilot review on native q4/q8 FA, MTP catch-up, and BOM.

Disable drafting when deferred catch-up decode fails. Gate in-place quantized K/V FA to NVIDIA and require ggml_cuda_is_aligned on K and V. Strip UTF-8 BOMs. The FWHT fusion comment no longer opens with the GDN gather notes.

* cuda: share the PTQ1_0 mat-vec smem budget between guard and launch

The combo kernel pads each partials row by one float. Use the same
byte count in the entry guard as the launch, including that pad, so
oversized gated shapes fall back instead of requesting more than smpb.

* spec: flush MTP catch-up when it plus anchors would overflow n_batch

process() can stash n_batch rows; draft() then added one more anchor.
Flush first when the combined count would exceed llama_n_batch.

* cuda: restore the scalar PTQ1_0 vec-dot on MUSA

Same hole as #215: the generic entry returned zero once MUSA was kept off the SoA path.
MUSA now takes the HIP scalar implementation.

* spec: keep MTP catch-up across a failed draft decode

catchup_failed was set on a flush failure and never cleared, so one bad decode disabled that slot for every later task. begin() now resets it with the rest of the per-task state.

draft() also cleared pending while building the batch, so a failed llama_decode dropped those rows from both the stash and ctx_dft. pending stays set until that first decode succeeds, and a failure marks the slot so it is not drafted again until the next begin(). flush_deferred waits for success the same way.

The layout_for comment now says it is the column-count helper. ggml_cuda_q8_1_layout_host is the decision both sides call.

* cuda: define the PTQ1_0 layout helper after ggml_cuda_info

The host layout decision reads the current device's compute capability. It was declared above ggml_cuda_info and ggml_cuda_get_device, so a clean sm_89 build failed in every CUDA translation unit. The helper now sits below those declarations.

* cuda: land remaining #221 review notes: pass q8 layout down, skip HIP/MUSA q4/q8 FA instances, exercise ALiBi/softcap.

* cuda: wait on the PDL dependency in the PTQ1_0 planar mat-vec (Hopper/Blackwell).

* cuda: restore warp-reduce epilogue under BATCH_INVARIANT so MTP-on matches MTP-off.

The four-accumulator path (5300cd1) is faster but a 5080 run flipped a late near-tie.
GDN now waits on PDL before reading s_ids.

---------

Co-authored-by: sudoingX <200180104+sudoingX@users.noreply.github.com>
Co-authored-by: Cary Palmer <24235924+professorpalmer@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants