[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6) - #20571

Merged
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt
Jul 9, 2026
Merged

[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)#20571
Gasoonjia merged 12 commits into
mainfrom
cuda-int4-int6-metadata-opt

Conversation

@Gasoonjia

@GasoonjiaGasoonjia commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Quant schema update

Weights are loaded from GGUF and kept at 4-bit (Q4_K) / 6-bit (Q6_K); we further-quantize the per-group metadata (the affine scale/zero) instead of storing it in bf16. Dequant stays affine: w = (q - zero) * scale. The grouping mirrors GGUF's Q4_K/Q6_K layout: group_size = 32 (int4) / 16 (int6), and a 256-weight super-block that shares one fp16 metadata step.

  • int4 (Q4_K):group_size=32, super-block=256 (8 groups). scale = per-group uint8 code x per-256 fp16 step; zero = per-group uint8 code x per-256 fp16 step. Both fp16 steps are packed into one 32-bit word and broadcast to the 8 lanes of a super-block with a single warp shuffle in the decode kernel. 4.625 bpw.
  • int6 (Q6_K): symmetric (no zero, exactly like Q6_K), group_size=16, super-block=256 (16 groups). scale = per-group int8 code x per-256 fp16 step; the per-group int8 codes are hoisted (loaded once per uint4) in the decode kernel. 6.5625 bpw.

Why this design

(a) Follow Q4_K / Q6_K. Weights come from GGUF, so we mirror llama.cpp's 256-weight super-block granularity (one fp16 step per 256 weights) and its symmetric-Q6_K / asymmetric-Q4_K split, keeping our representation close to the source format.

(b) int6 keeps its int8 scale code. int6 is symmetric, so scale is its only metadata; dropping it to a 6-bit code collapses accuracy (-9.5 dB) with no zero to compensate, so int6 keeps int8 scale + per-256 fp16 step. A dedicated int6 6-bit-planar-metadata path could revisit this in the future.

Ablation study

int4 / Q4_K whole-weight SNR vs the exact GGUF Q4_K dequant (same 8-tensor sample throughout; anchors reproduce exactly).

scale codescale stepzero codezero stepSNR (dB)bpw
bf16 (no code quant)-bf16-48.04-
uint8per-256 fp16uint8per-256 fp16 (latest / z_pack)45.894.625
uint8per-256 fp16uint8per-256 bf1645.674.625
uint8per-256 fp16uint8per-row fp1645.174.5625
uint8per-256 fp16uint8per-row bf1645.154.5625
uint8per-256 bf16uint8per-row bf1645.104.5625
uint8per-row bf16uint8per-row bf1644.374.500
uint8per-row fp16uint8per-row bf1644.364.500
6-bitper-256 fp168-bitper-256 fp1646.19*4.5625
6-bitper-256 fp166-bitper-256 fp1638.54.500
6-bitper-row8-bitper-row37.854.441

*6-bit-scale variant: higher raw SNR but rejected on perf (6-bit planar scale reconstruct = +5.2% decode; see below).

Analysis. The accuracy lever is step granularity, not step dtype or code width: moving the scale step from per-row to per-256 buys +0.78 dB (44.37 -> 45.15), and moving the zero step to per-256 as well buys another +0.74 dB (-> 45.89); step dtype (fp16 vs bf16) is worth <=0.05 dB for a uint8 code. We therefore keep both codes at uint8 (a 6-bit code loses 6-10 dB, and its planar reconstruct also costs decode perf) and spend the accuracy budget on per-256 fp16 steps for both scale and zero — which is exactly GGUF Q4_K's per-super-block-fp16-d granularity. This keeps the weight codes byte-aligned and identical to the source 4-bit/6-bit values, holds bpw in a tight band (4.625, +0.125 over the 4.5 baseline), and lands at 45.89 dB (+1.52 dB over the prior 44.37 baseline). The one higher-raw-SNR alternative (6-bit scale + 8-bit zero, 46.19 dB) was rejected because the 6-bit planar scale reconstruct regresses decode +5.2% for +0.3 dB — not worth it.

e2e results

gemma4-31b Q4_K_M @131072, greedy, cuda_graph, 3-rep median.

A100, turboquant OFF / bf16 KV

Comparing ET latest (this PR) vs ET prior (pre-metadata-opt baseline) vs llama.cpp.

Prefill (tok/s, higher better)

prompt lenET latestET priorllama.cpp
5121729.71701.01266.2
20482438.12494.51513.7
81922273.72275.61514.9
327681674.61710.01269.1
130048851.6855.9768.8

Decode (tok/s, higher better)

prompt lenET latestET priorllama.cpp
51249.9350.543.63
204847.9348.442.73
819247.4248.142.12
3276846.7947.039.74
13004836.9837.233.04

Peak VRAM (GB, lower better)

prompt lenET latestET priorllama.cpp
51231.5631.3819.38
204831.8531.6619.70
819231.8831.6920.17
3276831.8831.6922.04
13004831.8831.6929.46

A100 takeaways. vs ET prior: decode neutral (-0.45% to -1.41%, mean ~-0.9%), prefill neutral (+-2.3%), VRAM +~0.19 GB (the expected +0.06 bpw of the per-256 zero step) — i.e. +1.52 dB SNR at negligible runtime cost. vs llama.cpp: ET decode is +12-18% faster and ET prefill is faster at every length; llama.cpp uses ~12 GB less VRAM at short/mid lengths (packed Q4_K_M weights vs ET's bf16 runtime weights), a gap that narrows to ~2.4 GB at 130048 as the KV cache dominates.

RTX 5090, turboquant ON / tq4 KV cache

Comparing ET latest (New, this PR) vs ET prior (Orig, pre-metadata-opt baseline), decode d=512.

Decode (tok/s, higher better) & Peak VRAM (GiB, lower better)

prompt lenOrig decodeNew decodeDecode improvementOrig VRAMNew VRAMVRAM improvement
51257.1660.78+6.33%25.0623.29-7.06%
204856.3959.79+6.03%25.0623.29-7.06%
819255.6058.88+5.90%25.0623.29-7.06%
3276855.2958.60+5.99%25.0623.29-7.06%

5090 takeaways. With turboquant tq4 KV, the metadata-opt is a clear win on both axes: decode +5.9-6.3% faster and peak VRAM -7.06% (-1.77 GiB) at every measured length. (The VRAM drop here is a turboquant-path effect, not the +0.06 bpw weight-metadata cost seen on the A100 bf16-KV config.)

…ow super-scale)
Decode for gemma4-31B on CUDA is weight-bandwidth-bound: the int4/int6
weight-only matvecs are ~72% of per-token decode time and already run at
~89% of the RTX 5090 HBM roofline using the same dp4a algorithm as
llama.cpp. The only lever left is fewer bytes/token.
ET previously stored per-group (group_size=32) scale AND zero as bf16:
int4 = 5.0 bits/weight (20% metadata overhead), int6 = 7.0 bits/weight.
llama.cpp's Q4_K/Q6_K store far less metadata (4.5 / 6.5625 bpw).
This change re-encodes the quant metadata to match llama.cpp's byte
density WITHOUT touching the dp4a inner loop or the 4/6-bit weight codes:
- per-group scale/zero: bf16 -> uint8 codes
- per-row bf16 "super-scale" (step = row_max/255) restores dynamic range
(plain fp8/int8 alone fails: Q4_K scales span ~1e-4..8e-2 -> subnormal
blowup, 15-18 dB SNR; the two-level encoding keeps 46.7-48.1 dB == bf16)
- int6 is symmetric (no zero), int8 scale codes + per-row step (mirrors Q6_K)
Result -> 4.77 bpw (llama.cpp parity). Touches the packer
(coalesced_int4_tensor / dp4a_planar_int6_tensor), the decode shims
(int4/int6_plain_mm .cu/.cuh/.h, +steps arg), the dispatch ops, the AOTI
ABI sig in cuda_backend, and the gemma4_31b concat source transform.
e2e (gemma4-31B, 128k+TurboQuant export, CUDA-graph decode, 3-rep median):
ctx baseline -> this
512 57.16 -> 60.78 (+6.3%)
2048 56.39 -> 59.79 (+6.0%)
8192 55.60 -> 58.88 (+5.9%)
32768 55.29 -> 58.60 (+6.0%)
VRAM peak 25.06 -> 23.29 GiB; .ptd 26.18 -> 24.28 GB. Accuracy: Paris
coherent, dequant SNR ~baseline. Win holds at long context under CUDA
graph (verified, not a microbenchmark estimate).
@pytorch-bot

pytorch-botBot commented Jun 28, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20571

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 28, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@Gasoonjia
Gasoonjia marked this pull request as ready for review June 30, 2026 22:26
The compact-metadata change (bf16 -> uint8/int8 codes + per-row bf16
super-scale) migrated the int4/int6 plain_mm kernels and Python dispatch
but left the tests on the old signatures, breaking the CUDA shim CMake
build and the dispatch unit tests.
- test_aoti_torch_cuda_int4_plain_mm.cpp: regenerate vectors as uint8
scale/zero codes + [N,2] bf16 steps; update to the 7-arg signature.
- test_aoti_torch_cuda_int6_plain_mm.cpp: regenerate vectors as int8
scale codes + [N,1] bf16 steps; update to the 6-arg signature.
- test_int4_dispatch.py / test_int6_dispatch.py: add the steps arg to
the custom-op recorders, build tensors via the new constructors, and
compare against the decoded (code * step) scale instead of raw codes.
Vectors are generated from the production pack path (CudaCoalescedInt4Tensor
/ pack_int6 + _encode_int8_per_row) with expected[] from the export-path
_dequant_matmul references.
Test Plan:
- Built the CUDA shim gtests and ran on GPU: int4 7/7, int6 3/3 pass.
- python -m pytest backends/cuda/tests/test_int4_dispatch.py
backends/cuda/tests/test_int6_dispatch.py -> 33 passed.
- lintrunner init && lintrunner -a: no lint issues.
Migrate the CUDA int4 (Q4_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + uint8 scale code, mirroring GGUF Q4_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
45.15 dB (vs 44.37 dB), with NEW >= OLD generation quality.
The naive per-group load of the separate scale_step tensor cost ~8% decode
(latency-bound W4A8 matvec). Fixed with a llama.cpp-style super-block-cooperative
kernel: 8-lane warp subgroups each cover one super-block per iteration; only the
subgroup leader loads+converts the fp16 scale_step and __shfl-broadcasts it to
its 7 followers (8x fewer loads, fp16 convert off the dp4a path), register-only
(no smem, no occupancy cliff). dp4a inner product + zero decode unchanged.
Same-box decode vs the prior encoding: +1.7%..+2.9% (512/8K, bf16+tq4), VRAM
neutral. Op-level: packer SNR 45.15 dB, int4 gtest 5/5, dispatch 20/20, int6 3/3.
Also adds aoti_torch_dtype_float16 (ScalarType::Half) to the AOTI shims, required
by the fp16 scale_step tensor.
Files: kernel (int4_plain_mm.{cu,cuh,h}), packer (coalesced_int4_tensor.py),
dispatch (int4_dispatch.py), ABI (cuda_backend.py), fp16 shim
(common_shims{,_slim}.{cpp,h}), tests (int4 gtest + test_int4_dispatch.py),
gate/up fusion consumer (cuda_source_transformations.py).
…code
Migrate the CUDA int6 (Q6_K) path from a per-row bf16 scale-step to a
per-256-super-block fp16 scale-step + int8 scale code, mirroring GGUF Q6_K's
per-super-block scale granularity. Improves whole-weight dequant SNR to
~49.2 dB (vs ~45.7-46.6 dB for the per-row step), measured against the exact
GGUF Q6_K decode reference (extension/llm/export/gguf.py) across 3 shapes.
NEW >= OLD everywhere (+3.3 dB). Weight layout (planar ql/qh), int8 code width,
and Q6_K symmetry (no zero) are UNCHANGED.
Kernel: keep decode perf-neutral by amortizing metadata per uint4 rather than
the T3 warp-shuffle used for int4. The int4 shuffle only pays off because
int4's OLD baseline had a distant per-group step load to amortize; int6's OLD
step was already a per-row loop-invariant, so a shuffle recovers nothing (an
int6 T3 variant measured ~2-7% SLOWER, tied by a plain per-super-block __ldg).
The actual lever is the SAME code-load hoist int4's commit landed: load the
per-group int8 codes once per group per uint4 (gs=16 => 2 codes/uint4, held in
registers across the 4 dp4a words) instead of 4x/uint4 per word, plus a single
per-uint4 __ldg of the fp16 step (8 lanes share each tiny L1-resident address).
SASS confirms LDG.E.S8 drops 4 -> 2 per uint4. dp4a inner product + planar
weight decode unchanged; register-only (no smem, no occupancy cliff).
Op-level decode vs the prior per-row encoding (A100, M=1, same-box, 3 reps):
avg -3.0% (faster in 4/5 of {4096,8192,11008,14336}x{4096,8192,11008}, worst
+1.0%) -- neutral-to-better. The hoist's extra ILP raises the matvec from
REG:40 to REG:48 (theoretical occupancy 75% -> 62% on sm_80) with STACK:0 (zero
spills); measured wall-clock is faster regardless (ILP > occupancy on this
latency-bound matvec), and __launch_bounds__(256,6) to reclaim REG:40 caused
spills, so REG:48 is kept intentionally.
Gates: packer round-trip SNR +3.3 dB vs exact GGUF Q6_K; int6 gtest 4/4
(single/multi super-block step indexing + wide-N warp reduction + null args);
python int6 dispatch 20/20-style green (13 tests). ABI: steps arg is now the
per-256 fp16 tensor [N, K/256] (was per-row bf16 [N, 1]); reuses the float16
AOTI shim added for int4.
Files: kernel (int6_plain_mm.{cuh,h}), packer (dp4a_planar_int6_tensor.py),
dispatch (int6_dispatch.py), tests (int6 gtest + test_int6_dispatch.py).
…+0.74dB SNR (45.89 vs 45.15), op-level decode neutral (-0.44%), +0.06 bpw (4.625)
Promote the int4 zero step from per-row bf16 to per-256-super-block fp16 (the
SNR-max zero256 encoding, B2), decoded via z_pack: the 8-lane subgroup leader
packs BOTH fp16 steps (scale low16, zero high16) into the ONE existing 32-bit
warp-shuffle word and broadcasts it once — no extra shuffle vs the committed
scale-only baseline (the naive second __shfl / z_2shuf regressed +7.4%).
- packer (coalesced_int4_tensor.py): zero_step now [N,K/256] fp16 via
_encode_uint8_per_super (same per-256 grid as scale); dequantize + from_int4
updated. bpw 4.5625 -> 4.625.
- decode (int4_plain_mm.cuh): per-256 fp16 zero_step packed with scale_step into
one shuffle word (z_pack); dp4a inner loop unchanged. cuobjdump: REG:32
STACK:0 SHARED:0 == committed (no occupancy change).
- ABI (int4_plain_mm.cu/.h): zero_step dtype Half, shape [N,K/256]. C-shim
arg count unchanged (AtenTensorHandle), so cuda_backend.py needs no change.
- dispatch (int4_dispatch.py): prefill _dequant_matmul zero uses per-256 step.
- tests: int4 gtest regenerated for fp16 [N,K/256] zero_step + new
PackedShuffleMultiSuper case (K=1024/n_super=4, packed-shuffle-word
correctness); test_int4_dispatch.py per-256 zero decode.
Gates (same A100 box): whole-weight SNR 45.89 dB (calibration: committed 45.15);
int4 gtest 6/6 + python dispatch 20/20 + int6 gtest 4/4 (untouched) green;
op-level decode z_pack -0.44% time-weighted (3 solo reps, within ~1-2% bar).
int6 path untouched. e2e export intentionally deferred (running separately).
@GasoonjiaGasoonjia changed the title [cuda] Compact int4/int6 weight quant metadata (bf16 -> uint8 + per-row super-scale)[cuda] Nestest Superscale Quantization for CUDA Weights (int4 / int6)Jul 6, 2026
@digantdesai

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claudeBot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @digantdesai's task in 7m 15s —— View job


PR Review: Superscale Quantization for CUDA Weights (int4 / int6)

Reviewed the full diff against origin/main. This is a clean, well-documented change: the quant metadata (scale/zero) moves from per-row bf16 to per-group uint8/int8 codes + a per-256-super-block fp16 step, mirroring GGUF Q4_K/Q6_K. The op signatures, meta kernels, C shims, .cu/.cuh, dispatch, and tests are all updated consistently, and the ablation study justifies the design well. Overall it looks correct and mergeable. A few notes below.

Correctness

  1. _encode_uint8_per_super assumes zero_point >= 0 (safe today, but undocumented invariant).backends/cuda/coalesced_int4_tensor.py:147-150 uses block_max = xb.amax(...) (max, not absmax) and clamp_(0, 255), so any negative value silently clamps to 0. I confirmed torchao's Int4Tensor always produces non-negative zero_point (unsigned [0,15], and the symmetric path pins it to 8.0), so this is correct for all real inputs. But the old code stored t.zero_point.t() as bf16 and would have preserved a negative sign if one ever appeared. Consider a one-line assert or docstring note in _encode_uint8_per_super that the input must be non-negative, so a future non-torchao packer fails loudly instead of silently degrading. The scale encoder has the same max-based assumption, which is fine since scales are positive.

  2. int6 kernel: unconditional scale_row[g_base + 1] read is OOB for gs=32.backends/cuda/runtime/shims/int6_plain_mm.cuh:251 always loads scale_row[g_base + 1]. For the shipped gs=16 this is safe (g_base is always even and g_base+1 < n_groups). But the comment at lines 246-248 explicitly claims gs in {16, 32} is supported — and for gs=32 a uint4 spans exactly one group, so g_base runs up to n_groups-1 and scale_row[g_base+1] reads one past the row (the ws1 value is then unused, since wpg_shift = gs_shift-3 = 2 makes every w>>2 == 0). This is a latent OOB read, not currently exercised because only gs=16 is used/tested. Either guard the ws1 load (g_base+1 < n_groups) or tighten the comment to state only gs=16 is supported. Fix this →

  3. Stale docstring in _concat_coalesced_int4_along_n.examples/models/gemma4_31b/cuda_source_transformations.py:700 describes zero_step as [N, 1] bf16, but the actual encoding is [N, K/256] fp16 (as the rest of the PR documents). Minor, but worth fixing since the surrounding docstring is otherwise precise about shapes/dtypes. Fix this →

Things done well

  • int4 warp-shuffle deadlock avoidance (int4_plain_mm.cuh:189-197): the warp-aligned trip count so all 32 lanes reach __shfl_sync(0xffffffff, ...) even when K_half_16 < 32 is the correct fix for full-mask shuffle on tiny shapes, and the "safe dummy load (index 0), contribute 0" for inactive lanes is right. Good that the test suite includes small shapes to exercise this.
  • z_pack (packing both fp16 steps into one 32-bit word, one leader shuffle per 8-lane subgroup) keeps the dp4a dot products bit-identical to the scale-only baseline while adding zero-step decode at no extra shuffle — nicely reasoned and matches the "+0.74 dB at op-level-neutral" claim.
  • Kernel-side validation (n_super = K/256, zero_step.size(1) == scale_step.size(1), K % 256 == 0) is thorough, and the error messages are detailed.
  • Encode/decode agreement: rounding uses the fp16-rounded step (step_f) so pack-time and kernel-time reconstruction match — this is the subtle correctness detail that's easy to get wrong, and it's handled.

Test coverage

Solid — test_int4_dispatch.py / test_int6_dispatch.py and the two .cpp kernel tests were substantially expanded (single/multi super-block, wide-N, small shapes). One gap: no test exercises gs=32 for int6, which is why note #2 slipped through. If gs=32 is genuinely intended to be supported, add a case; otherwise the comment should say gs=16 only.

CI

The listed failures (test_int4_weights_preserved, the export-model-cuda-artifact jobs) are worth confirming are unrelated infra/merge-base noise vs. this metadata change — the TestInt4Inference::test_int4_weights_preserved failure in particular is on a path this PR touches. Recommend confirming it's green after the merge with main.

None of these are blocking; #1 and #2 are the ones I'd address (or explicitly wontfix) before merge.
· branch cuda-int4-int6-metadata-opt

Comment threadexamples/models/gemma4_31b/cuda_source_transformations.py

_lib.define(
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor zero, int group_size) -> Tensor"
"int4_plain_mm(Tensor self, Tensor qdata, Tensor scale, Tensor scale_step, Tensor zero, Tensor zero_step, int group_size) -> Tensor"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about non k-quant users?

Comment threadbackends/cuda/quantize_op_dispatch/int4_dispatch.py
TEST_F(AOTITorchInt4PlainMMTest, SingleSuperBlock) {
int64_t M = 1, K = 256, N = 8, gs = 32;
int64_t ng = K / gs; // 8
int64_t n_super = K / 256; // 1
// clang-format off
uint8_t qdata_host[] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check in the script to recreate these

"scale",
"scale_step",
"zero_point",
"zero_step",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/zero_step/zero_point_step - Nit

Comment on lines +137 to +139
scale_step: torch.Tensor,
zero_point: torch.Tensor,
zero_step: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Tensor only for k-quants?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except the fact that I am not sure if we want to use the plain kernel for other 4/6b weights or just k-quant with quantized/nested scales.

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

@digantdesai

Copy link
Copy Markdown
Contributor

Thanks @digantdesai for review.

Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

@digantdesaidigantdesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamping it to unblock you

@Gasoonjia

Copy link
Copy Markdown
ContributorAuthor

Thanks @digantdesai for review.
Tensor now is k-quant only but the pipeline doesn't. We convert the original hdd int4 and int6 tensor into q4_k and q6_k and reuse k-quant pipeline. The precision are the same and only have ~0.7db weight during the conversion.

What about performance? Running Q4_0 as nested k-quant kernel must cost some perf, no?

for kernel-base test perf is even better cuz now bpw is lower.

@Gasoonjia
Gasoonjia merged commit 7b1122c into mainJul 9, 2026
280 of 286 checks passed
@Gasoonjia
Gasoonjia deleted the cuda-int4-int6-metadata-opt branch July 9, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/cudaCLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Gasoonjia@digantdesai