perf(cuda): Q6_K decode MMQ via SoA reader-port (free AoS) — +17% N=8 (#204) - #264
Conversation
…#204, #203) Routes the Q6_K trunk weights (lm-head + ffn_down-half) through the int8 tensor-core decode-MMQ tile, the last big batched-decode chunk (~8.3 ms of the ~25 ms N=8 step on the bit-exact WS-sw matvec). Measured on Qwen3-8B Q4_K_M @ 4070 Ti (aggregate decode t/s, vs the AoS/WS-sw baseline): N=6 269.0 -> 304.0 (+13.0%) N=7 305.1 -> 346.9 (+13.7%) N=8 327.2 -> 382.1 (+16.8%, reproduced 324.7 -> 379.6) peak VRAM 11394 / 12282 MiB — no spill. The decode MMQ needs coalesced staging (a SoA weight layout), which on a 12 GB card is fatal if kept as a *companion* beside the interleaved AoS weight (it ~2.3x the Q6_K weight VRAM and spilled at N=8: 325 -> 169 t/s). Earlier zero-VRAM AoS-direct staging avoided the spill but regressed -7..-11% (uncoalesced 210-B block stride). The fix is the reader-port: repack each 2-D Q6_K matmul weight to the int8 SoA layout ONCE at upload and FREE the AoS copy (like Q4_K's RepackQ4KSoa), so SoA is the only copy (~+0.4 GB net, fits N=8). To free the AoS, every Q6_K reader now consumes SoA: new _soa variants of llm_matvec_q6k / _n2 / _gemm_n / _ws / _ws_sw and llm_dequant_q6k_to_f16, each BIT-IDENTICAL to its AoS counterpart (SoA stores (q6-32) int8 + verbatim scales + d, so d*scale*(q6-32) and the reduction order are unchanged). Dispatch picks the _soa kernel when the weight handle is SoA-repacked. The token embedding table (read by embed_lookup, untied from output.weight) is excluded from the repack and stays AoS. SHARPI_Q6K_SOA=0 reverts to AoS everywhere (kill-switch); SHARPI_Q6K_DECODE_MMQ=0 keeps SoA readers but disables just the decode MMQ tile. Bit-exactness verified: CudaGemmQ6KTests.Q6KSoaReaders_AreBitIdenticalToAos + MatMulBatchedGemm_Q6K (prefill), CudaDecodeMmqTests Q6_K vs SimdKernels.DotQ6K, all 8 CudaBatchForwardMultiTests, and all 5 CudaSpecBatchVerifyTests incl. the 48-token single-user SpecDecode_GreedyParity_E2E (the ported single-user matvec stays byte-stable). Build clean (TreatWarningsAsErrors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements support for repacking 2-D Q6_K weights into a scale-pre-unpacked Structure of Arrays (SoA) layout (Issue #204). This change frees the interleaved Array of Structures (AoS) copy to save VRAM and routes all Q6_K readers (single-token, N=2, GEMM-N, weight-stationary, prefill dequant, and decode-MMQ) to their respective SoA-aware kernels. Feedback on the changes suggests optimizing the 16-bit float scale loading in several CUDA kernels by replacing two 8-bit loads and bitwise operations with a single 16-bit load to reduce instruction count in hot paths.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| long g = (long)row * num_blocks + block; | ||
| const signed char* q = qReg + g * 256L; // 256 signed int8 (q6-32) | ||
| const signed char* s = sReg + g * 16L; // 16 int8 scales | ||
| unsigned int dbits = (unsigned int)dReg[g * 4L] | ((unsigned int)dReg[g * 4L + 1] << 8); |
There was a problem hiding this comment.
Since dReg is aligned to 16 bytes and g * 4L is a multiple of 4, the address dReg + g * 4L is 4-byte aligned. We can optimize this by performing a single 16-bit load instead of two 8-bit loads followed by a shift and bitwise OR. This reduces instruction count in the hot path.
unsigned int dbits = *(const unsigned short*)(dReg + g * 4L);| long g = (long)row * num_blocks + block; | ||
| const signed char* q = qReg + g * 256L; | ||
| const signed char* s = sReg + g * 16L; | ||
| unsigned int dbits = (unsigned int)dReg[g * 4L] | ((unsigned int)dReg[g * 4L + 1] << 8); |
There was a problem hiding this comment.
Since dReg is aligned to 16 bytes and g * 4L is a multiple of 4, the address dReg + g * 4L is 4-byte aligned. We can optimize this by performing a single 16-bit load instead of two 8-bit loads followed by a shift and bitwise OR. This reduces instruction count in the hot path.
unsigned int dbits = *(const unsigned short*)(dReg + g * 4L);| long g = (long)row * num_blocks + block; | ||
| const signed char* q = qReg + g * 256L; | ||
| const signed char* s = sReg + g * 16L; | ||
| unsigned int dbits = (unsigned int)dReg[g * 4L] | ((unsigned int)dReg[g * 4L + 1] << 8); |
There was a problem hiding this comment.
Since dReg is aligned to 16 bytes and g * 4L is a multiple of 4, the address dReg + g * 4L is 4-byte aligned. We can optimize this by performing a single 16-bit load instead of two 8-bit loads followed by a shift and bitwise OR. This reduces instruction count in the hot path.
unsigned int dbits = *(const unsigned short*)(dReg + g * 4L);| long g = (long)row * num_blocks + block; | ||
| const signed char* q = qReg + g * 256L; | ||
| const signed char* s = sReg + g * 16L; | ||
| unsigned int dbits = (unsigned int)dReg[g * 4L] | ((unsigned int)dReg[g * 4L + 1] << 8); |
There was a problem hiding this comment.
Since dReg is aligned to 16 bytes and g * 4L is a multiple of 4, the address dReg + g * 4L is 4-byte aligned. We can optimize this by performing a single 16-bit load instead of two 8-bit loads followed by a shift and bitwise OR. This reduces instruction count in the hot path.
unsigned int dbits = *(const unsigned short*)(dReg + g * 4L);| long g = (long)row * num_blocks + block; | ||
| const signed char* q = qReg + g * 256L; | ||
| const signed char* s = sReg + g * 16L; | ||
| unsigned int dbits = (unsigned int)dReg[g * 4L] | ((unsigned int)dReg[g * 4L + 1] << 8); |
There was a problem hiding this comment.
Since dReg is aligned to 16 bytes and g * 4L is a multiple of 4, the address dReg + g * 4L is 4-byte aligned. We can optimize this by performing a single 16-bit load instead of two 8-bit loads followed by a shift and bitwise OR. This reduces instruction count in the hot path.
unsigned int dbits = *(const unsigned short*)(dReg + g * 4L);There was a problem hiding this comment.
Code Review
This pull request introduces support for repacking 2-D Q6_K weights into a scale-pre-unpacked Structure of Arrays (SoA) layout, freeing the original interleaved Array of Structures (AoS) weights to optimize VRAM usage. It implements several SoA-aware CUDA kernels—including single-token, batched, weight-stationary, and dequantization variants—along with a new int8 decode-MMQ tile. Comprehensive tests are added to ensure the new SoA readers are bit-identical to their AoS counterparts and track the CPU reference. The review feedback highlights a platform-dependent issue in the CUDA kernels where long is used for 64-bit offset calculations; since long is 32-bit on Windows (LLP64), it should be replaced with long long or int64_t to prevent potential overflow.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| long sb = (long)blockIdx.x * blockDim.x + threadIdx.x; | ||
| int nb = cols >> 8; | ||
| long total = (long)rows * nb; | ||
| if (sb >= total) return; | ||
|
|
||
| long srcOff = sb * 210L; | ||
| signed char* qDst = (signed char*)(dst + sb * 256L); | ||
| unsigned char* sDst = dst + total * 256L + sb * 16L; | ||
| unsigned char* dDst = dst + total * (256L + 16L) + sb * 4L; |
There was a problem hiding this comment.
In CUDA C/C++ (and standard C++ on Windows), long is a 32-bit integer (LLP64 model), whereas on Linux it is 64-bit (LP64 model). To ensure consistent 64-bit precision for offset and size calculations across all platforms (preventing potential overflow/truncation issues on Windows if tensors ever grow extremely large), it is highly recommended to use long long (or int64_t) instead of long, and the LL suffix instead of L for literals.
This pattern also applies to the other newly added _soa kernels and templates in this PR.
long long sb = (long long)blockIdx.x * blockDim.x + threadIdx.x;
int nb = cols >> 8;
long long total = (long long)rows * nb;
if (sb >= total) return;
long long srcOff = sb * 210LL;
signed char* qDst = (signed char*)(dst + sb * 256LL);
unsigned char* sDst = dst + total * 256LL + sb * 16LL;
unsigned char* dDst = dst + total * (256LL + 16LL) + sb * 4LL;dReg is 16-B aligned and g*4 is 4-aligned, so the per-super-block d (fp16) load is a single aligned u16 read instead of two byte loads + shift/or. Bit-identical (same fp16 value); CudaGemmQ6KTests bit-identity + DecodeMmq_Q6K + 48-token SpecDecode greedy parity still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Applied the 16-bit aligned |
Summary
Resolves #204 (umbrella #203). Routes the Q6_K trunk weights (lm-head + ffn_down-half) through the int8 tensor-core decode-MMQ tile — the last big batched-decode chunk (~8.3 ms of the ~25 ms N=8 step was on the bit-exact WS-sw matvec). Measured on Qwen3-8B Q4_K_M @ RTX 4070 Ti (aggregate decode t/s vs the AoS/WS-sw baseline):
Peak VRAM 11394 / 12282 MiB — no spill.
Why a reader-port (the two contained approaches failed on 12 GB)
The decode MMQ needs coalesced staging → a SoA weight layout. Keeping that SoA as a companion beside the interleaved AoS weight ~2.3×'d the Q6_K weight VRAM and spilled the 12 GB card at N=8 (325 → 169 t/s). A zero-VRAM AoS-direct staging avoided the spill but regressed −7…−11% (uncoalesced 210-B block stride).
The fix: repack each 2-D Q6_K matmul weight to the int8 SoA layout once at upload and free the AoS copy (exactly like Q4_K's
RepackQ4KSoa) — so SoA is the only copy (~+0.4 GB net, fits N=8). To free the AoS, every Q6_K reader was ported to SoA:_soavariants ofllm_matvec_q6k/_n2/_gemm_n/_ws/_ws_swandllm_dequant_q6k_to_f16. Each is BIT-IDENTICAL to its AoS counterpart: the SoA stores(q6−32)int8 + verbatim scales + d, sod·scale·(q6−32)and the per-element reduction order are unchanged._soakernel iff the weight handle is SoA-repacked, else AoS.embed_lookup, untied fromoutput.weight) is excluded from the repack and stays AoS.SHARPI_Q6K_SOA=0reverts to AoS everywhere (kill-switch);SHARPI_Q6K_DECODE_MMQ=0keeps SoA readers but disables just the decode-MMQ tile.The decode-MMQ tile itself (per-16 scales via two
m16n8k32mmas split at the a0/a1|a2/a3 k-boundary, symmetric no-min fixup) is argmax-stable — the same contract as the Q4_K tile.Validation
CudaGemmQ6KTests.Q6KSoaReaders_AreBitIdenticalToAos(MatMul / N2 / Batched / WS / MatMulBatchedGemm SoA == AoS, byte-for-byte) +MatMulBatchedGemm_Q6K_TracksCpuReference.SimdKernels.DotQ6K:CudaDecodeMmqTests.DecodeMmq_Q6K_Soa_TracksCpuReference.CudaBatchForwardMultiTests+ all 5CudaSpecBatchVerifyTests, incl. the 48-token single-userSpecDecode_GreedyParity_E2E(the ported single-user matvec/WS stays byte-stable) and the prefill-parity oracle.Closes #204. Refs #203 (umbrella). Caps the batched-decode arc: #262 (+11-28%) → #263 (+6.6%) → this (+17% @ N=8).
🤖 Generated with Claude Code