cuda: Bonsai 2 27B at the full 262k window with the MTP head on 12 GB Ada: #218 + #215 hybrid PTQ1_0 dispatch, in-place q4_0/q8_0 K/V flash attention (integration checkpoint) - #221
Conversation
|
Up to 103.3 mean! |
|
Two more commits on top of d8a7180:
RTX 4070 12 GB, 262144 ctx, q4_0 KV, draft-mtp n_max=2, backend sampling: 101.6 tok/s mean over 136 requests on the three probe prompts (byte-identical output to the unfused path). |
|
Short cross-reference for anyone building this integration branch: the one-hunk compile fix posted on #218 ( Reproduced numbers on an RTX 5070 Ti Laptop (sm_120, 12 GB), paired against the stock fork binary with the same PTQ1_0 file, One thing you may already have covered: #189 (short-query MMA FA with quantized V) is the same shape as the MTP verify batch here ( |
|
Follow-up after building the newer head ( Plain decode/prefill: flat.
All within ±1%, i.e. run-to-run noise. That is what the bandwidth-bound argument predicts: at batch 1 on this card decode reads ~5.5 GB of weights per token and is saturated on DRAM, so removing ~390 launches per step (the Hadamard-fusion commit) does not show up. The launch-count win presumably needs a card that is launch/latency-bound at batch 1 rather than bandwidth-bound. MTP: +8%, same policy. Net: I would take this head for correctness (the aliasing hazard, the out-of-vocab ids, the stale deferred rows) rather than for speed, and I would measure the fusion commit separately on a latency-bound GPU before claiming a decode win there. |
|
@AlexGabbia a few of those follow-ups are scoring the wrong delta, or attributing commits that do not run in the test you ran.
Speed vs stock on your card is the first comment: pp512 519 → 1192, tg128 48 → 63. That is already in The MTP 68.39 → 74.05 (+8%) is not stale-deferred-rows or depth-max. One prompt, one run, no slot reuse, #189 is not in this branch. It is a separate open PR (MMA FA Recipe, so the 74 tok/s / 128k / q4_0 number does not become the checkpoint: current 12 GB default is 96k / q8_0, draft to 24k ( |
|
Same restrict / C2912 note as on #218, for anyone building this integration tree with The hunk @AlexGabbia posted ( We hit that exact C2912 on Windows (CUDA 13, MSVC 14.44) while making the multi-arch zip and did not wait on it: shipped It should still go in so a clean |
|
Serving-recipe note, not a kernel change and not this diff. The GGUF template defaults Recorded on the integration binaries, original PTQ1_0, 4 SVG prompts + one short code task, request max_tokens=4096:
Bundle recipe (https://github.com/professorpalmer/bonsai-ada-surgery/blob/main/docs/QUALITY.md): chat |
|
Pushed
4070 numbers are unchanged (still SoA at 1-col). No new zip; this is source on |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Native K/V eligibility and deferred-decode error handling contain unresolved correctness and platform-scope issues.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 3
Open (6)
Handle failed deferred flush before continuing draft initialization · New Gate native quantized K/V dispatch to NVIDIA devices · New Check tensor base alignment for quantized loader eligibility · New Remove UTF-8 BOM from source file · New Correct misleading FWHT fusion function documentation · New Remove UTF-8 BOM from new source file · New
What changed in this PR
Integrates CUDA optimizations for full-context Bonsai 2 inference, including PTQ1_0 dispatch, native quantized FlashAttention, recurrent-state fusion, and deferred MTP catch-up.
Changes:
- Adds optimized PTQ1_0 quantization, MMVQ/MMQ, FWHT, and GDN paths.
- Reads q4_0/q8_0 K/V directly in FlashAttention MMA kernels.
- Improves Qwen35 MTP state handling, draft scheduling, and depth limits.
| File | Description |
|---|---|
tools/server/server-context.cpp |
Adds speculative depth cutoff. |
tests/test-backend-ops.cpp |
Adds FlashAttention and projection cases. |
src/models/qwen35.cpp |
Fixes MTP embeddings and output rows. |
ggml/src/ggml-cuda/vecdotq.cuh |
Updates PTQ1_0 vector dot logic. |
ggml/src/ggml-cuda/template-instances/generate_cu_files.py |
Generates quantized K/V kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_1-ncols2_8.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_2-ncols2_4.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_2-ncols2_8.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_4-ncols2_2.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_4-ncols2_4.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_4-ncols2_8.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_8-ncols2_1.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_8-ncols2_2.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_8-ncols2_4.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_8-ncols2_8.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_16-ncols2_1.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_16-ncols2_2.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_16-ncols2_4.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_32-ncols2_1.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_32-ncols2_2.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q4_0-ncols1_64-ncols2_1.cu |
Instantiates q4_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_1-ncols2_8.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_2-ncols2_4.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_2-ncols2_8.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_4-ncols2_2.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_4-ncols2_4.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_4-ncols2_8.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_8-ncols2_1.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_8-ncols2_2.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_8-ncols2_4.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_8-ncols2_8.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_16-ncols2_1.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_16-ncols2_2.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_16-ncols2_4.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_32-ncols2_1.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_32-ncols2_2.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-q8_0-ncols1_64-ncols2_1.cu |
Instantiates q8_0 MMA kernels. |
ggml/src/ggml-cuda/quantize.cuh |
Extends quantization interfaces. |
ggml/src/ggml-cuda/quantize.cu |
Adds PT layouts and fused FWHT quantization. |
ggml/src/ggml-cuda/mmvq.cu |
Adds hybrid PTQ1_0 dispatch. |
ggml/src/ggml-cuda/mmvq-ptq1_0.cuh |
Implements planar PTQ1_0 mat-vec. |
ggml/src/ggml-cuda/mmvf.cu |
Extends small-N mat-vec dispatch. |
ggml/src/ggml-cuda/mmq-load-tiles.cuh |
Makes PTQ1_0 tile loading uniform. |
ggml/src/ggml-cuda/mmq-config-ampere.cuh |
Adds wider Ampere tile configurations. |
ggml/src/ggml-cuda/ggml-cuda.cu |
Adds FWHT and GDN graph fusions. |
ggml/src/ggml-cuda/gated_delta_net.cuh |
Documents fused gather integration. |
ggml/src/ggml-cuda/gated_delta_net.cu |
Adds direct state gathering and wider warps. |
ggml/src/ggml-cuda/fwht.cuh |
Extracts the reusable FWHT butterfly. |
ggml/src/ggml-cuda/fwht.cu |
Reuses the shared FWHT implementation. |
ggml/src/ggml-cuda/fattn.cu |
Dispatches native quantized K/V kernels. |
ggml/src/ggml-cuda/fattn-mma-f16.cuh |
Implements in-place K/V dequantization. |
ggml/src/ggml-cuda/fattn-common.cuh |
Stabilizes invariant KV splitting. |
ggml/src/ggml-cuda/common.cuh |
Adds layouts, registries, and feature flags. |
common/speculative.cpp |
Defers and merges MTP catch-up work. |
common/sampling.cpp |
Diagnoses invalid sampled tokens. |
common/common.h |
Adds speculative depth configuration. |
common/arg.cpp |
Exposes the depth-limit option. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| reuse = d.pos[k] < N && prompt[d.pos[k]] == d.tokens[k]; | ||
| } | ||
| if (reuse) { | ||
| flush_deferred(seq_id); |
There was a problem hiding this comment.
Fixed in 3aa89d8. Failed flush_deferred now sets catchup_failed and clears drafting for that sequence in begin() and draft(). The other two call sites already returned false.
| static inline bool ggml_cuda_fattn_mma_kv_native_supported(const ggml_tensor * dst) { | ||
| const ggml_tensor * Q = dst->src[0]; | ||
| const ggml_tensor * K = dst->src[1]; | ||
| const ggml_tensor * V = dst->src[2]; | ||
| if (K->type != V->type || !ggml_cuda_fattn_mma_kv_native(K->type)) { |
There was a problem hiding this comment.
Fixed in 3aa89d8. ggml_cuda_fattn_mma_kv_native_supported is now false on HIP/MUSA and on non-NVIDIA cc, so MFMA/WMMA keep the F16 scratch path.
| // Rows must be 32-bit addressable for the block-pair loads. | ||
| for (const ggml_tensor * t : {K, V}) { | ||
| if (t->nb[1] % 4 != 0 || t->nb[2] % 4 != 0 || t->nb[3] % 4 != 0) { | ||
| return false; |
There was a problem hiding this comment.
Fixed in 3aa89d8. Eligibility is ggml_cuda_is_aligned(K, 4) && ggml_cuda_is_aligned(V, 4), which checks the base pointer and the higher strides.
| @@ -1,4 +1,4 @@ | |||
| #include "ggml-cuda.h" | |||
| #include "ggml-cuda.h" | |||
| // GET_ROWS(cache[D, n_rs], ids[n_seqs]) -> [RESHAPE] -> GATED_DELTA_NET src[5]. | ||
| // build_rs gathers each layer's live recurrent state into a temp (3 MB for the 27B) that only the | ||
| // GDN kernel reads. In-graph the gather kernel is ~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 gathered temp has no other consumer, skip the | ||
| // A Hadamard rotation of an activation ([MUL signs, RESHAPE,] MUL_MAT with GGML_HINT_SRC0_IS_HADAMARD) | ||
| // whose every use is a PTQ1_0 mat-vec on the mmvq path does not need to exist in F32: each of those | ||
| // mat-vecs quantizes it to q8_1 straight away, so the transform kernel quantizes as it goes and writes | ||
| // the q8_1 rows (in the layout the mat-vecs expect) into its own output buffer; the mat-vecs then skip | ||
| // their quantize launch (ggml_cuda_fwht_q8). On Bonsai 2 that removes ~390 launches per decode step. | ||
| // The check walks every later node, so any other use of the output (or of a view of it) keeps the | ||
| // plain transform, and use counts guard against consumers outside this graph. Returns the number of | ||
| // nodes consumed (3 or 1) or 0 to fall through. GGML_CUDA_FWHT_FUSION=0 disables it. |
There was a problem hiding this comment.
Removed the leftover GDN preamble in 3aa89d8. The comment now describes only the FWHT-q8 fusion.
| @@ -0,0 +1,475 @@ | |||
| // PTQ1_0 mat-vec inner loop on a planar-transposed Q8_1 activation layout. | |||
|
Serving-recipe note, not a kernel change ? leaving it here because this is the combo / draft-mtp PR. We grafted ProCreations' on-policy MTP head (the blk.64 tensors only) onto official PTQ1_0. Their combined PQ2_0-MTP GGUF is the wrong pack for Ada and is not what we load. Same 4070, 96k / q8_0, draft-mtp n-max 2, think off, paired probe:
+3.9% decode, +4.3 pp acceptance, +92 MiB. Strip of the graft hashes equal the original PTQ1_0 trunk. Greedy draft-on vs draft-off matches on code and shows the same close-token mmap flip the teacher graft already had, so this is not a new identity regression. Useful recipe tweak, not a new class of speedup. Default in professorpalmer/bonsai-ada-surgery ( |
|
Review of 3aa89d8: [P2] Bring over the corrected PTQ1 shared-memory guard from #218, accounting for this branch's extra padded element. In ggml/src/ggml-cuda/mmvq-ptq1_0.cuh:459-460 the guard still accepts 2*(K/128)ncols <= 4096. The launcher at line 433 actually requests ncolsrows_per_cta*(K/128+1)sizeof(float)(has_gate?2:1). For the one-column gated path with 64 output rows, K=196608 passes the guard but requests 49184 bytes, exceeding the default 49152-byte block limit. K=262144 also passes and requests 65568 bytes. This affects the enabled planar path, including Ampere one-column dispatch. Validation: compiled the exact host rows_per_cta helper from this head and checked these launch-byte calculations. This is source/host validation, not a fresh GPU reproduction. #218 already fixed the corresponding unpadded kernel after a reported invalid-argument launch failure. Please share the actual byte calculation between guard and launcher, including the +1 padding here, and add boundary coverage for fused gates. |
|
Thank you for the host-side check. The P2 is correct, and it is on this branch because of the extra pad. The launcher here requests
I have not rebuilt the four-GPU campaign on this head. Happy to if you want it queued. Authorship of the #218 helper is preserved in the comment; this commit is only the pad-aware wiring. |
|
Follow-up on 7e56d13: the padded shared-memory guard fix addresses the earlier finding in source; guard and launcher now share the same byte calculation. No fresh CUDA run claimed. [P2] Reserve capacity for deferred catch-up before appending the first draft row (common/speculative.cpp:2482-2490). The MTP batch is allocated with llama_n_batch(ctx_dft) entries. process() may defer that many rows when n_tokens <= params.n_max + 1, but draft() appends every deferred row and then one extra anchor without a capacity check. For a single-head, separate-memory MTP context with batch/ubatch 32, n_max=31 and a 32-token final prompt batch, the stash can contain 32 valid rows; the first draft call attempts a 33rd entry and aborts with "llama_batch size exceeded". No rejected-token trimming protects the initial prefill case. Multiple drafting sequences also need their combined catch-up/anchor count budgeted. Validation: replayed this exact append boundary using the real llama_batch_init/common_batch_add helpers on ARM: 31 catch-up rows plus the anchor succeeds in a capacity-32 batch; 32 plus the anchor aborts (SIGABRT). This is a focused host reproduction plus source-path tracing, not a full model/server reproduction. The previous eager path decoded catch-up separately and did not combine these rows. Please flush deferred rows before constructing the first draft batch whenever total catch-up plus active draft rows would exceed llama_n_batch(ctx_dft), or split them into bounded decodes. Growing only the host allocation is insufficient because llama_decode also enforces the context batch limit. Add a full-prefill boundary test and a multi-sequence combined-capacity test. |
|
Thank you. This one is right — process() can stash n_batch rows when n_tokens == n_max+1, and draft() then added the first-draft anchor with no remaining slot. Pushed a59ba13. If catch-up plus one anchor per drafting sequence would exceed llama_n_batch(ctx_dft), we flush the stash first (same decode limit llama_decode enforces), then build the first draft batch. Growing only the host allocation would still abort in decode. tests/test-mtp-catchup-batch.cpp covers the 32+1 full-prefill boundary and the 16+16+2 multi-seq case. Host-only; no fresh CUDA or server run on this one. |
|
Same MUSA hole as the new finding on #215: once the SoA path was compiled out, the generic PTQ1_0 vec-dot returned zero. |
bri-prism
left a comment
There was a problem hiding this comment.
Reviewed at 8b3d194. I came to this specifically to settle the activation-layout question I deferred on #215 and #218, and it settles it well. The flash-attention work is also in better shape than I expected for a change of that size. Two unreported defects in the speculative path, plus some housekeeping, are what I would want fixed before this lands.
The layout decision is the right one and is built correctly
This is what I asked for on both #215 and #218, which was that the layout be decided here rather than by merge order. ggml_cuda_q8_1_layout_host() is one host-side decision that both the quantizer and the kernel switch consume, the enum documents all three layouts, and the Ampere versus Ada split is measured rather than asserted. Forcing the one-column case onto the planar layout under GGML_CUDA_BATCH_INVARIANT is the right call, since that is the whole point of the flag.
Two notes on it.
The layout is recomputed at three call sites rather than computed once and threaded through, and two of them derive the column count by different expressions (ncols_dst at mmvq.cu:1211 against (int) (ids ? ne2 : ne11) at :1656). The comments at both sites acknowledge that they must match, so the coupling is known, but a future edit to one argument expression breaks agreement silently and nothing would catch it. Computing the layout once in ggml_cuda_mul_mat_vec_q and passing it down would make that structural instead of documented.
ggml_cuda_q8_1_layout_for is marked __host__ __device__ and its comment calls it "the single decision both sides must agree on", but it is not the function that makes the decision: it does not consult cc, so on Ampere it returns SOA_ISUM where the host chooses PT. Nothing calls it from device code today, so this is a trap rather than a bug, but the comment points a future reader at the wrong function. Worth renaming or re-wording.
The FWHT-quantize fusion does the consumer validation properly
Worth saying explicitly because I blocked #187 for the opposite: this matcher walks every use of the transform output, including through reshape views, compares uses_found against ggml_node_get_use_count, and checks input/output overlap. It also requires GGML_OP_MUL_MAT rather than MUL_MAT_ID, which is what makes the hardcoded has_ids = false at ggml-cuda.cu:2895 correct by construction rather than by luck. I went looking for a mismatch there and did not find one.
Flash attention on quantized K/V holds up
I had this audited closely because it is the highest-risk change here, and the things I expected to be wrong were not.
The allocator and the kernel use literally the same predicate function rather than two copies, so get_alloc_size cannot reserve nothing while the kernel takes the conversion path. nstages is forced to 0 for quantized types through matching host and device overloads, so the quantized variants land on the pre-existing non-cp.async path rather than a new one, and every multi-stage block is if constexpr-gated out. The byte-stride change is confined to two lines, and every remaining sizeof(half2) divide sits inside an if constexpr (type_KV == GGML_TYPE_F16) or an nstages > 1 branch. The q4_0 nibble ordering matches the canonical dequant with no swap, q8_0 sign-extends correctly, and the "bit-identical to a to_fp16 pass" claim holds because the product rounds exactly once. The 64 instantiations match the reachable ncols1/ncols2 set exactly.
Three smaller things from that pass:
e5cd0f3 also adds a batch-invariant early return in ggml_cuda_get_best_fattn_kernel (fattn.cu:481-483). That has nothing to do with native quantized K/V, is not in the commit message, and changes kernel selection for batch-invariant builds. It should be its own commit.
HIP and MUSA glob template-instances/fattn-mma*.cu (ggml-hip/CMakeLists.txt:66, ggml-musa/CMakeLists.txt:35), so both build all 32 new instance files, 64 instantiations of a heavy template, for a path whose gate hard-returns false on those backends. Pure compile time and binary size.
The new tests cover 48 cases, but logit_softcap != 0 with a quantized cache is instantiated and never exercised, and max_bias != 0 likewise. At D=128 only two shapes are tested against four plus both permutations at D=256.
Two defects in the speculative deferral path, neither mentioned
Both need a llama_decode failure to reach, so they are low-frequency, but both are real. I verified each in the source rather than taking them on report.
catchup_failed is latched for the life of the object. It is declared at common/speculative.cpp:2084, set at :2112, read at :2447 and :2474, and never assigned false anywhere in the file. begin() resets d.pending and d.n_valid immediately beside it but not this flag, and deferred.assign(n_seq, {}) runs once in the constructor. So a single transient decode failure disables drafting on that slot permanently, for every subsequent task, with no further log. It should be cleared in begin() alongside the other per-task state.
draft() drops the stash before a decode that can fail. d.pending = false is set at :2518 while the batch is being built, and the failure path at :2552 logs and breaks without re-stashing and without setting catchup_failed. Those catch-up rows then exist in neither the stash nor ctx_dft, and the only downstream signal is the soft pos_max < N-1 warning in begin(). Clearing pending after a successful decode, or restoring it on failure, would close it.
Related: the commit described as flushing MTP catch-up "when it plus anchors would overflow n_batch" is fixing a heap buffer overflow that the catch-up batching commit introduced, since batch is sized at exactly llama_n_batch(ctx_dft) while the stash could push one row past it. That is worth naming as an overflow fix rather than a flush condition, because it changes how a reader weighs it.
The 1e1409a MTP fix is genuine and I confirmed the mechanism: the consumer at llama-context.cpp:2197 copies with a literal 0 source offset for n_outputs rows, so before the fix any masked MTP batch with n_tokens > n_outputs read the first catch-up row's hidden state instead of the draft row's. Silent draft corruption, no crash. It is a hard prerequisite for the catch-up batching commit, which is what would have started producing that batch shape, so the ordering matters if anyone cherry-picks from this branch.
Housekeeping before this can land
The branch is CONFLICTING. prism has moved 19 commits since the merge base while this has 25, and the conflicts are in mmq-load-tiles.cuh, src/models/qwen35.cpp and tests/test-backend-ops.cpp, which are exactly the files whose cherry-picks (#214, #217) have since landed independently. It needs a rebase that drops the already-merged commits, and the same will apply to #215 and #218 as they land.
The only CI on this is labeler. For a 60-file CUDA change that includes a flash-attention rewrite, new template instantiations across two backends, and speculative-decoding state machine changes, that is not coverage. This is not this PR's fault and I have raised it separately, but it does mean every correctness claim here rests on the author's own runs plus source review.
Method note: source reading at 8b3d194. I built nothing and ran nothing; the 103 tok/s figure and the per-commit percentages are the author's. Where I say a finding is confirmed, I mean I read the code path myself.
8b3d194 to
1b053d3
Compare
|
Thank you. Both of those are real, and they are in
Rebased onto current Also reworded Built this head on Windows, sm_89, VS 2022. The first clean build failed: The flash-attention notes (the batch-invariant early return belonging in its own commit, HIP/MUSA still compiling the new instances, and the untested softcap/bias shapes) are still as they were. Happy to take those next if you want them before this lands. |
|
Independent sm_120 (Blackwell) build and test check at Windows 11, MSVC 14.44.35207 (VS 2022 17.14), CUDA 13.0.88,
Both speculative defects are fixed at this head, read in the source rather than taken from the commit message:
Stated explicitly because it is the only new test here: Still open at this head, for whoever takes them: the batch-invariant early return is still part of the native-K/V commit ( |
… 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.
…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.
…ts 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.
…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.
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.
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.
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.
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.
process() can stash n_batch rows; draft() then added one more anchor. Flush first when the combined count would exceed llama_n_batch.
Same hole as PrismML-Eng#215: the generic entry returned zero once MUSA was kept off the SoA path. MUSA now takes the HIP scalar implementation.
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.
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.
7feb951 to
32842f6
Compare
…n, skip HIP/MUSA q4/q8 FA instances, exercise ALiBi/softcap.
|
Rebased onto current The two speculative defects you asked for are still in ( Remaining notes from the 8b3d194 / 18f113d reviews, now in
The batch-invariant vector-kernel early return is still in the native-K/V commit. I did not rewrite that history on the rebase. The comment on it already says it is a batch-invariance policy, not part of the quantized loader. I did not rebuild CUDA on this box for this push (live 4070 serve stayed up). Happy to queue |
|
Picked up @LamplighterPaul's finding from #218.
Same one-line wait as #215 does not carry this kernel. #218 is the source PR; the patch itself is sudoingX#1. |
…tches 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.
|
Follow-up from the 5080 notes (thanks @LamplighterPaul). The 104.8 tg128 / 131K MTP numbers are a win on the full combo, not a miss. Two small adjustments landed on this branch:
Ada never takes PDL, so I cannot replay the 5080 identity check here. A |
|
Follow-up measured on the same 4070 (same clocks as the table above), now up as a draft stacked on this branch: #285. Three things in it bear on this PR:
#215 and #220 are not touched by it (the op-level profile has the PTQ1_0 mat-vecs at ~82% of DRAM bandwidth for single-token decode and the 4-column MMQ crossover here holds up). |
|
Independent RTX 4000 Ada (sm_89, CUDA 12.8) check at the current head versus this PR's base. Both were clean CUDA builds on the same card.
CUDA backend tests: Both builds initialized |
|
Thank you for the RTX 4000 Ada run and for landing it.
Follow-up housekeeping now that this is in
|
|
@bri-prism following up on the
|


Summary
Integration checkpoint: Bonsai 2 27B served at its full 262,144-token window with the MTP draft head, on a 12 GB RTX 4070, at 103 tok/s greedy (three-prompt mean, up from 60 without the draft head at this context and from 51 on the stock build; 121 on code). Same PTQ1_0 weights, q4_0 K/V, byte-identical output.
It is the merge of two independent PTQ1_0 lines of work plus two new pieces that neither had:
31ad62098ea410..5883186GGML_CUDA_BATCH_INVARIANT, bf16 small-N mat-vec82ebc3d94c913fe6b40a96f856a729ec099e5cd0f3847a3231e1409ad8a7180His commits are cherry-picked with authorship intact. The standalone PRs stay open for review on their own; this branch is what all of them look like together and is what produced the numbers below. Reviewers can take it whole or use it to pick pieces.
The new commits
Hybrid PTQ1_0 dispatch (
29ec099)Measured head to head on this card, the #215 layout wins at one column and the #218 kernel wins at 2-4 columns, so both are kept and one function chooses:
Quantizer and kernel both call
ggml_cuda_q8_1_layout_host(), so they cannot disagree.y_soais a template parameter ofmul_mat_vec_q(a runtime flag cost registers and 4% of decode).ggml_cuda_should_use_mmvqroutes PTQ1_0 to the mat-vec path throughne11 <= 4; the MMQ tile path (#214) takes over from 5 columns.calc_nwarps/calc_rows_per_blockapply the #218 4-warp/1-row geometry only forncols_dst > 1.Flash attention on quantized K/V without the F16 copy (
e5cd0f3)With
-ctk q4_0 -ctv q4_0the MMA kernel used to convert the whole K/V cache to F16 into a scratch buffer per micro-batch during prefill. At 262k that is ~1 GB per model, ~2 GB with the MTP draft, which is exactly what did not fit in 12 GB. The MMA kernel now takestype_KVas a template parameter:flash_attn_ext_f16_load_tile_qdequantizes q4_0/q8_0 blocks straight from the cache into the shared-memory tile (aligned 32-bit word reads, scale + nibble/byte unpack to half2), the cp.async multi-stage pipeline is disabled for quantized types (it cannot dequantize), strides become byte strides, andggml_cuda_flash_attn_ext_get_alloc_sizeno longer reserves the F16 buffers when the in-place kernel applies. Instances are generated for D=128 and D=256, ncols 8..64, q4_0 and q8_0.ggml_cuda_fattn_mma_kv_native_supported()gates it on head size, matching K/V types and word-aligned strides; everything else takes the old path unchanged.Multi-column PTQ1_0 mat-vec, second pass (
847a323)Nsight Compute on the 3-column verify kernel from #218 showed it latency-bound at 25% occupancy, 5.6 warp cycles per issued instruction and ~2100 dynamic instructions per warp where ~1300 do the dot products. Two changes, arithmetic unchanged:
ptq1_0_trit_stepreturns the raw 0/1/2 digits instead of biasing them to -1/0/1 per word. The dot isdp4a(unsigned digits, signed q8)and the bias is applied once per 32-block assumi - isum, whereisumis the exact int16 sum of the quantized activations thatquantize_q8_1now stores in theds.yhalf slot for the PT layout (bit-cast, same as the SOA_ISUM layout does). One SIMD subtract per 4 weights gone.bpr + 1) so there are no bank conflicts, and a fastdiv forrows_per_cta.Instructions per warp ~2100 -> ~1500, DRAM utilisation 61% -> 83%, stall samples on the epilogue shuffles gone.
llama-benchon this branch at the serving memory clock: pp3 145.4 -> 169.5, pp4 167.1 -> 212.2, pp1 unchanged (different kernel). Tried and reverted with numbers in the linked repo: a cp.async shared-memory stage for the weights (-7%), a 128-register cap for 4 CTAs/SM (spills, -5%), and 2 rows per thread at double occupancy (-19%); the 4-row, 3-CTA geometry stays.MTP h_nextn rows (
1e1409a) and the merged catch-up (d8a7180)Per step the draft side was four launches: the target verify graph, a catch-up decode of the draft over every verified row (to fill the draft's memory; partly on rows the target rejects), then two single-row draft passes. A CUPTI trace of a step showed each launch boundary costing 0.1-0.45 ms of host time on WDDM on top of the draft's ~0.85 ms GPU time for the catch-up.
d8a7180keeps the catch-up rows inprocess()and decodes only the accepted prefix at the start ofdraft()in the samellama_decodeas the first draft row. Positions are contiguous and the draft row attends to them in-batch, so the drafts are the same tokens; acceptance per prompt and step counts are identical to the eager path, and output stays byte-identical to the draft head off. Prompts fed in tiny ubatches, a sequence that stops drafting, and rollback replay fall back to a separate decode. Shared-memory (gemma4) and chained-head drafters keep the old path;LLAMA_MTP_EAGER_CATCHUP=1restores it for A/B.Doing that exposed
1e1409a: the QWEN35 MTP builder sett_h_nextnbefore theinp_out_idsgather, whilellama_context::decodecopies the firstn_outputsrows of it in masked mode. A 4-row MTP batch with one output row therefore returned row 0's hidden state for the draft row, and the second draft pass ran on the wrong input (acceptance 0.86 -> 0.67 on the code prompt). Every MTP decode before this hadn_tokens == n_outputsor no outputs, so it never showed. The trunk graph already gathers before publishing; the MTP head now does the same.Numbers (RTX 4070 12 GB, sm_89, Bonsai-2-27B PTQ1_0)
llama-bench, stock clocks, paired alternating runs:9a9394aLast column: the component measurement of whichever path the hybrid dispatch selects at that column count (SOA_ISUM at 1, PT at 2-4, MMQ from 5). Measured on this exact branch with the +1500 MHz memory clock used for serving: pp1 61.5, pp3 169.5, pp4 212.2 (pp3 145.4 / pp4 167.1 before
847a323).llama-server, MTP draft (--spec-type draft-mtp --spec-draft-n-max 2),-ctk/-ctv q4_0, one slot,--temp 0, three prompts (code / prose / bash), GDDR6X +1500 MHz:847a323)847a323+--backend-sampling; code 113.0, bash 96.4, prose 79.9d8a7180; code 120.9, bash 103.3, prose 86.2; 22.4 ms/step; two fresh processes agree to 0.1; 59.8 with the draft head offWithout
e5cd0f3the 262k + MTP configuration does not fit: nvidia-smi caps at 11.9 GB and WDDM silently pages, 41 tok/s.Correctness
test-backend-ops -b CUDA0 -o MUL_MAT: 1283/1283 (45 PTQ1_0 shapes vs CPU);-o MUL_MAT_ID: all PTQ1_0 shapes;-o GATED_DELTA_NET: 39/39.test-backend-ops -b CUDA0 -o FLASH_ATTN_EXT: 2984/2984, including new cases added ine5cd0f3for q4_0/q8_0 K/V at D=128/256, GQA, batch 1..64, KV lengths that are not tile multiples, and permuted layouts.GGML_CUDA_BATCH_INVARIANT=1, 300-token greedy output is byte-identical with the draft head on and off for all three prompts (verify_identity.pyfrom CUDA: fast, batch-invariant small-batch PTQ1_0 mat-vec (1.5x decode on Ampere) #218).Scope
CUDA only. HIP keeps the block_q8_1 layout and the old paths (
ptq1_0_pt_enabled()is false,ggml_cuda_q8_1_layout_forreturns AOS). The in-place K/V flash attention is gated to the MMA kernel on NVIDIA with the head sizes above; other sizes and the vector kernels are untouched.Repro, recipes and the profiler used to find each step: https://github.com/professorpalmer/bonsai-ada-surgery