cuda: shared-quantize cache, residual and elementwise-chain fusions (generic half of #338) - #343
Conversation
|
Thanks for the split and for folding the fixes in; points 1 through 5 from the #338 review all check out against the code (the CI: the
Two blockers beyond the build: Chain fusion can consume an elided intermediate. At 3718, Patch 7 looks redundant. The base branch already has a generic Smaller:
#342 is on its own track (one lifetime fix going in, see there). This one I'd hold until the chain operand check and the patch 7 question are settled. |
Several matvecs in each decode layer consume the same normed activation, and each was quantizing it to q8_1 independently: roughly 60% of the quantize launches per token were duplicates. Cache the most recent quantization, keyed on source pointer, size, type and graph epoch, and reuse it within a graph evaluation. The cache buffer is returned to the pool when the backend context is destroyed, so it never outlives the pool it was taken from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR (cherry picked from commit fd90c6b) (cherry picked from commit 31002c7)
Collapse a run of consecutive same-shape elementwise ops into one kernel that carries the value in a register across the whole run, instead of one dispatch and one HBM round trip per op. Hybrid MoE graphs are full of these: SIGMOID->MUL->ADD->ADD, ADD->SOFTPLUS->MUL, CLAMP->DIV, SILU->MUL, MUL->SCALE. Supports ADD/MUL/DIV/SCALE/CLAMP and the common unary ops, chains up to 8 long, with same-shape or single-value-broadcast operands. Aliasing: same-shape operands are read at index i and dst is written at index i after those reads, so an in-place output (which the allocator produces constantly for residual adds) is safe and must not be vetoed - using the generic memory-range guard here rejected almost every candidate. Only a broadcast operand, read at index 0 by every thread, needs the check. 1622 -> 1552 kernels/token on Qwen3.6-35B-A3B; +2.3% decode (paired A/B: +3.5/+2.0/+1.6). Output byte-identical. GGML_CUDA_FUSE_CHAIN=0 disables. (cherry picked from commit ec9bc5f) (cherry picked from commit 68c0d7c)
test-backend-ops builds single-op graphs, so the residual-epilogue fusion, the shared-quantize cache, the elementwise chain kernel and the MoE expert-reduce tail never ran under CI. Add whole-graph cases for each, compared against the CPU backend: MUL_MAT -> ADD(residual), including ADD(mm, mm), which must not fuse two MUL_MATs on one activation (second quantize is a cache hit) SILU -> MUL -> ADD -> ADD(scalar) [-> GELU -> SOFTPLUS] -> SCALE -> CLAMP MUL_MAT_ID -> MUL -> PERMUTE -> CONT -> SUM_ROWS Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR (cherry picked from commit 48b5e13)
17e02d3 to
2fb1a70
Compare
|
Rebased onto the merged base and reworked per your list; new head pushed. Patch 7 dropped. You're right: the base's bias fusion takes a same-shape ADD (it checks Chain fusion, all in the reworked patch 9:
The residual-fusion placement comment went with patch 7. Windows openblas was the fail-fast cancel, as you said. Head 2fb1a70, MI210 (gfx90a), |
The VMM pool asserts strict LIFO on free, and the q8 and TQ pre-rotation caches hold long-lived pool buffers plus chronological retire lists. Freeing each cache in turn, oldest retired entry first, trips that assert as soon as two held buffers coexist (test-backend-ops -o MUL_MAT_SHARED_SRC1 passed 12/12 and then aborted in ~ggml_backend_cuda_context on a GB10). Gather every held buffer from both caches and free them per device in descending address order, which is reverse allocation order on the VMM pool and harmless on the legacy pool.
|
Second pass on 2fb1a70. The rework holds up: the elided-operand check covers every binary operand against all of One real bug, found by running it on a GB10: Still checking one thing: the full Smaller items, none blocking:
|
|
GB10 results on f47832e: the teardown abort is gone (
|
…evice memory, not the pool The VMM pool frees strict LIFO. Both caches allocated their long-lived buffer from the pool while transient pool allocations were live (ggml_cuda_mul_mat_id holds sorted src1/dst/ids buffers and then calls the per-expert matmul, which allocates the q8 cache on top of them), so the transients were no longer the top of the pool when they went out of scope and the free asserted. Sorting at teardown cannot fix a runtime violation; a persistent buffer must not come from the LIFO pool at all. Use ggml_cuda_device_malloc / cudaFree for both caches and drop the teardown ordering logic. Retire-on-grow is unchanged. CUDA graph capture on nvcc and on CDNA runs in relaxed mode, where an allocation mid-capture is legal. The RDNA2 HIP path captures in ThreadLocal mode; the pool's own growth had the same exposure there, and a grow forces a re-capture anyway.
|
Pushed ad5c1f1: both caches (q8 and the TQ pre-rotation one) now take their buffer from One thing for your AMD side when you're back at a keyboard: the RDNA2 HIP path captures graphs in GB10 gates are running on this head; will merge when they and CI are green. |
|
GB10 (sm_121), head ad5c1f1, nvcc build: That is every CUDA gate I have. Merging once musa and the Windows re-run finish. |
1208c59
into
TheTom:feature/turboquant-kv-cache
|
Thanks for catching the pool-order bug and for running the GB10 gates; the device-allocation fix is the right shape and I'll carry ad5c1f1 over to our fleet branch, where the same caches (plus an fp16 variant for the MFMA path) still come from the pool. On the RDNA2 point: our V620 runs with HIP graphs on, so I'll check whether a cache grow inside a capture changes anything there and report back. The smaller items from your first pass (precedence comment and a timing check against |
Overview
Two patches that are not TurboQuant-specific and help any quantized weight type:
plus one commit of
test-backend-opscoverage. The MoE reduce-tail test is kept: it compares the tail against the CPU graph whether or not the backend fuses it.Review fixes, by point
ADDafterMUL_MAT(it checksggml_are_same_shapeon the ADD's inputs and goes through the n-uses check), so the residual case is covered without it; the earlier dispatch-count measurement predates the rebase that brought that fusion in. The residual test is kept and now exercises the base fusion, includingADD(mm, mm).t0 = SILU(x); out = MUL(t0, t0)would otherwise readt0unset; covered by the newself_multest). The detector is its own static function with a fixed op array, reads op params withmemcpy, and emitsSQR. The chain vetoes any operand that overlaps the output unless it aliases it exactly (same pointer and size);ggml_cuda_check_fusion_memory_rangesis deliberately not used because it would veto that exact in-place case, which is the normal one. Exact in-place aliasing is what galloc produces for residual adds and is safe, because every thread reads indexibefore writing indexi; a partial overlap at a nonzero offset is not, and neither is a broadcast operand inside the output. GELU callsggml_cuda_op_gelu_single()and softplus uses the(x > 20) ? x : logf(1 + expf(x))form, so fused and unfused paths agree bit for bit.cuda: fuse MoE weighted expert reduction(cuda: fuse MoE weighted expert reduction ggml-org/llama.cpp#25952) on 2026-09-01: any weight type, k = 2..15, allocator-integrated, with its owntest-backend-opscoverage. It only touches the reduction, so it is independent of which matmul produced the experts and would cover the TurboQuant MoE path too. One caveat for the rebase: cuda: fuse MoE weighted expert reduction ggml-org/llama.cpp#25952 matches the add-chain aggregation master builds (moe_out = ggml_add(moe_out, cur_experts[i])), while this branch'sbuild_moe_ffnemits thePERMUTE -> CONT -> SUM_ROWSform (llama-graph.cpp:2287) that our tail matched. Whichever shape survives the rebase, carrying a competing tail here makes no sense; if the SUM_ROWS form stays, I can re-send the tail as a follow-up against it. For the record, the measurement Tom asked for: on Qwen3-30B-A3B Q4_K_M the fused tail gave identical perplexity (6.7883 ± 0.1208 either way) and, forced on, was 3% slower than the existingx_scaleepilogue fusion for standard types (48.3 vs 49.9 tg128, measured while the card was shared; the ratio held on an idle card); on the TQ4max MoE it was worth +3.9% (121.1 ± 0.3 vs 116.6 ± 0.2 tg128, idle MI210). cuda: TurboQuant TQ4_1S decode optimisations #338's patch 2, the deep TQ4_1S fuse this tail had already beaten, is dropped for the same reason.test-backend-ops, compared against the CPU backend:MUL_MAT -> ADD(residual)includingADD(mm, mm), twoMUL_MATs on one activation (the second quantize is a cache hit),SILU -> MUL -> ADD -> ADD(scalar) [-> GELU -> SOFTPLUS] -> SCALE -> CLAMP, andMUL_MAT_ID -> MUL -> PERMUTE -> CONT -> SUM_ROWS, for Q8_0, Q4_0 and TQ4_1S.Nits:
mmvq.cuincludes<cstdlib>.Additional information
MI210 (gfx90a), ROCm 7.2.3,
GGML_TQ_MMQ=1 GGML_TQ_NATIVE=1:-o MUL_MAT_RESIDUAL_FUSION-o MUL_MAT_SHARED_SRC1-o ELEM_CHAIN_FUSIONMUL(SILU(x), SILU(x)))-o MOE_REDUCE_FUSION(unfused on this branch)-o MUL_MAT -p type_a=tq4_1s-o MUL_MAT_ID -p type_a=tq4_1s-o MUL_MAT(all types)q5_cr/q8_crConvRot cases that also flake on the pristine baseThe three quantized-graph tests use the same 5e-4 tolerance as
test_mul_matfor quantized types; the f32 chain test keeps the default 1e-7, which is what proves the GELU and softplus forms now round like the unary kernels (they did not before the fix).Requirements
🤖 Generated with Claude Code
https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
Note on the base branch
While checking upstream for this split I noticed
feature/turboquant-kv-cacheis at a 2026-08-06 merge-base, 477 commits behind master. Besides ggml-org#25952, three landed changes bear directly on this decode work:CUDA: extend MOE fusion to specdec(41ef91f, the MoE GLU and top-k router fusions for more than one token, i.e. the speculative verify batches),CUDA: switch points per HW and quant type for the mvq->MMQ decode crossover(ggml-org#26079), andspec: fuse the DFlash encoder into the KV injection(ggml-org#27310). Your call when to rebase; happy to help carry the TQ-specific pieces across.