cuda(quant): Q2_K CPU dequant + Q3_K / Q4_1 / Q5_1 CPU+CUDA dequant - #161
cuda(quant): Q2_K CPU dequant + Q3_K / Q4_1 / Q5_1 CPU+CUDA dequant#161jamesburton wants to merge 3 commits into
Conversation
…156) Extends K-quant family coverage: - CPU: Q2_K dequant + Q3_K / Q4_1 / Q5_1 dequant. - CUDA: Q3_K / Q4_1 / Q5_1 dequant kernels + PTX. - CudaModule.TryGetFunction — optional symbol lookup for kernels that may be absent from older PTX builds. Plugs gaps left by upstream Q4_K/Q5_K/Q6_K/Q8_0 support — these are common in GGUF files for smaller model variants and lightest- quantization variants of larger models (e.g. DeepSeek-V2-Lite-Q3_K_M). Verified against llama.cpp reference outputs. Foundation for the upcoming CUDA K-quant MMQ/MMVQ rollout. Closes #156 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for additional quantization formats across CPU and CUDA dequantization paths, including new CUDA kernels and unit tests for K-quant formats.
Changes:
- Add CPU dequantization support for Q2_K, Q3_K, Q4_1, and Q5_1 (plus RowByteSize updates).
- Add CUDA kernels and runtime dispatch for Q4_1, Q5_1, and optional Q3_K (with a “try get” symbol lookup).
- Add unit tests for Q2_K and Q3_K dequantization and stride sizing.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Cpu/Kernels/DequantizeKQuantTests.cs | Adds hand-calculated and sizing/validation tests for Q2_K & Q3_K. |
| src/DotLLM.Cuda/CudaModule.cs | Adds TryGetFunction to support optional kernels and cache lookups. |
| src/DotLLM.Cuda/CudaKernels.cs | Adds kernel handles and launch paths for Q4_1/Q5_1/Q3_K dequant. |
| src/DotLLM.Cpu/Kernels/DequantizeKQuants.cs | Implements Q2_K and Q3_K CPU dequantization. |
| src/DotLLM.Cpu/Kernels/Dequantize.cs | Adds RowByteSize mappings and scalar dequant for Q4_1/Q5_1; dispatch wiring. |
| src/DotLLM.Core/Configuration/QuantizationTypeExtensions.cs | Extends byte size mapping (adds Q3_K). |
| src/DotLLM.Core/Configuration/QuantizationType.cs | Adds enum values for Q2_K and Q3_K. |
| native/ptx/dequant.ptx | Updates compiled PTX (new targets/version) and adds new kernel entry points. |
| native/kernels/dequant.cu | Adds CUDA kernels for Q4_1, Q5_1, and Q3_K dequantization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public nint TryGetFunction(string name) | ||
| { | ||
| if (_functions.TryGetValue(name, out nint func)) | ||
| return func; | ||
|
|
||
| int result = CudaDriverApi.cuModuleGetFunction(out func, _module, name); | ||
|
|
||
| // CUDA_ERROR_NOT_FOUND = 500: symbol absent from PTX (older build). | ||
| if (result == 500) | ||
| { | ||
| _functions[name] = 0; | ||
| return 0; | ||
| } | ||
|
|
||
| result.ThrowOnError(); | ||
| _functions[name] = func; | ||
| return func; | ||
| } |
| // CUDA_ERROR_NOT_FOUND = 500: symbol absent from PTX (older build). | ||
| if (result == 500) |
| QuantizationType.Q5_0 => elementCount / 32 * 22, | ||
| QuantizationType.Q5_1 => elementCount / 32 * 24, | ||
| QuantizationType.Q8_0 => elementCount / 32 * 34, | ||
| QuantizationType.Q3_K => elementCount / 256 * 110, | ||
| QuantizationType.Q4_K => elementCount / 256 * 144, | ||
| QuantizationType.Q5_K => elementCount / 256 * 176, | ||
| QuantizationType.Q6_K => elementCount / 256 * 210, |
| int e = eBase + l; | ||
| int qBits = (qs[e / 4] >> ((e % 4) * 2)) & 0x03; | ||
| int hBit = (hmask[e / 8] >> (e % 8)) & 0x01; | ||
| int signed3 = ((hBit << 2) | qBits) - 4; // [-4, 3] | ||
| dest[(int)(destOffset + e)] = scaleD * signed3; | ||
| } | ||
| } |
| // Compiler Build ID: CL-37061995 | ||
| // Cuda compilation tools, release 13.1, V13.1.115 |
| .version 9.1 | ||
| .target sm_75 |
| case QuantizationType.Q5_0: | ||
| DequantizeQ5_0(src, elementCount, dest); | ||
| break; | ||
| case QuantizationType.Q4_1: | ||
| DequantizeQ4_1Scalar(src, elementCount, dest); | ||
| break; | ||
| case QuantizationType.Q5_1: | ||
| DequantizeQ5_1Scalar(src, elementCount, dest); | ||
| break; |
…ize (#156) Review follow-up. - native/ptx/dequant.ptx had been regenerated with CUDA 13.1, shipping `.target sm_75` / `.version 9.1`. That contradicts native/build.ps1, which pins -arch=compute_61, and would drop Pascal support and require a much newer driver to JIT. Regenerated from the unchanged dequant.cu with nvcc 12.8 -arch=compute_61 (same 9 kernels), and verified the result assembles with `ptxas -arch=sm_61` and `-arch=sm_86`. Added PtxTargetTests as a guard so a future default-arch regeneration fails the build instead of shipping. - CudaModule.TryGetFunction cached misses as 0 in the same dictionary GetFunction reads, so GetFunction would return 0 for a missing symbol instead of throwing. Misses now live in a separate set. - Replaced the bare 500 with CudaResult.NotFound (new named CUresult constants in the interop layer). - Added Q2_K to QuantizationTypeExtensions.ComputeByteCount (84 bytes per 256 elements), which had only gained Q3_K; a test asserts it agrees with Dequantize.RowByteSize. - DequantizeQ3_KScalar indexed dest through an unchecked (int) narrowing of a long offset. It now carries an int base index like DequantizeQ2_K, and both Q2_K/Q3_K validate dest.Length >= elementCount up front (dest.Length is an int, so that bounds the index) when called directly rather than via ToFloat32. - Added the missing Q4_1 / Q5_1 CPU coverage: hand-calculated single-block decode, two-block stride checks that catch Q4_0/Q5_0 block-size confusion, RowByteSize, and non-aligned-count throws. Tests: --filter "~Dequantize|~QuantizationType|~PtxTarget" -> 49 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all seven comments were valid; fixed in 280e7ba.
|
The Q3_K dequant added by this PR decodes a transposed layout in both
backends. Two independent transpositions:
1. Scale high bits. Read as `scales12[8 + sub/4] >> (sub%4)*2`; llama.cpp
packs them at `scales12[8 + sub%4] >> (sub/4)*2`. The two agree only for
sub in {0,5,10,15}, so 12 of 16 sub-block scales were wrong. The low
nibble source for sub 8..15 was likewise `sub-4` instead of `sub-8`,
reading the high-2-bits bytes as if they held nibble scales.
2. Element ordering. The 2-bit quants are not four consecutive elements per
byte. Element t is bit-pair (t/32)%4 of qs[(t%32) + 32*(t/128)], with high
bit t/32 of hmask[t%32]. Reading qs[t/4] @ (t%4)*2 and hmask[t/8] @ t%8
scatters every element into the wrong sub-block scale.
Together these reduced Q3_K weights to noise: correlating a Q3_K-decoded
tensor against the Q8_0 build of the same model gives corr 0.006 before,
0.988 after.
The existing Q3_K_SingleBlock_HandCalculated test could not catch this — it
exercises only elements 0/1 of sub-block 0 plus an all-zero sub-block 1, a
degenerate case where the correct and transposed layouts coincide. Adds
Q3_K_DenseRandomBlocks_MatchLlamaCppReference, which drives dense
pseudorandom super-blocks against a literal transcription of llama.cpp's
dequantize_row_q3_K (the aux/kmask shuffle and the shift/m loop over
128-element halves), deliberately kept in llama.cpp's control-flow shape so
it is structurally unlike the production closed-form indexing. Reverting
either half of the fix alone turns it red.
NOTE: native/ptx/dequant.ptx is NOT regenerated here and still contains the
buggy Q3_K kernel. The checked-in PTX is pinned to compute_61 (guarded by
PtxTargetTests) and CUDA 13.x dropped Pascal, so it needs a CUDA 12.x
toolkit to rebuild; none was available. Regenerate with native/build.ps1
before relying on the CUDA Q3_K path.
|
Correction to my own PR: as submitted, this would have shipped a broken Q3_K dequantizer in both backends. Pushed 46af747 to fix it before merge. The Q3_K decoder here reads a transposed layout. Two independent transpositions, both against 1. Scale high bits. I had: int hiBits = (scales12[8 + (sub >> 2)] >> ((sub & 3) * 2)) & 0x03; // 8 + sub/4 @ (sub%4)*2llama.cpp's int hiBits = (scales12[8 + (sub & 3)] >> ((sub >> 2) * 2)) & 0x03;The two forms agree only for 2. Element ordering. The 2-bit quants are not stored four consecutive elements per byte. Each 128-element half uses 32 Combined, Q3_K weights came out as effectively noise. Correlating a Q3_K-decoded tensor against the Q8_0 build of the same model: corr 0.006 before the fix, 0.988 after. Why the existing tests were green
Added Discrimination checked both ways, each half reverted alone:
One thing still outstanding:
|
Summary
Closes #156 — extends K-quant family coverage on both CPU and CUDA paths.
Changes
QuantizationType.cs,QuantizationTypeExtensions.csDequantize.cs,DequantizeKQuants.csdequant.cu+.ptx,CudaKernels.cs,CudaModule.csDequantizeKQuantTests.csImplementation notes
CudaModule.TryGetFunction(string)helper (returns 0 onCUDA_ERROR_NOT_FOUND, matches the existing guard pattern used byCudaKernels.cslookup paths) — required because the newCudaKernels.csdispatch uses it.Foundation for follow-ups
This PR is the foundation for the CUDA K-quant MMQ / MMVQ rollout (Q4_K MMQ, Q5_K/Q6_K/Q8_0 MMQ variants, pre-Q8_1 paths) and the i-quant family (IQ1_S, IQ2_, IQ3_, IQ4_*).
Verification
dotnet build src/DotLLM.Cpu -c Release— 0 warnings, 0 errors.dotnet build src/DotLLM.Cuda -c Release— 0 warnings, 0 errors.dotnet test --filter "FullyQualifiedName~Dequantize|FullyQualifiedName~Q2_K|FullyQualifiedName~Q3_K|FullyQualifiedName~Q4_1|FullyQualifiedName~Q5_1"— 38 / 38 passing.Closes #156
🤖 Generated with Claude Code