Skip to content

Nunchaku Lite quantization in place - #14608

Draft
rootonchair wants to merge 13 commits into
huggingface:mainfrom
rootonchair:feature/data-free-quantization
Draft

Nunchaku Lite quantization in place#14608
rootonchair wants to merge 13 commits into
huggingface:mainfrom
rootonchair:feature/data-free-quantization

Conversation

@rootonchair

@rootonchair rootonchair commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds data-free quantize-on-load to the Nunchaku Lite backend: pass pre_quantized=False to quantize an unquantized checkpoint into the Nunchaku W4A4 packed format while its weights stream in — no calibration data, no offline conversion step.

transformer = Flux2Transformer2DModel.from_pretrained(
    "black-forest-labs/FLUX.2-klein-9B",
    subfolder="transformer",
    quantization_config=NunchakuLiteQuantizationConfig(
        svdq_w4a4={"precision": "nvfp4", "group_size": 16, "rank": 32},
        pre_quantized=False,
    ),
    torch_dtype=torch.bfloat16,
    device_map="cuda",
)

For each targeted linear: weight-span smoothing, a rank-r SVD low-rank branch, and int4/nvfp4 group quantization of the residual, packed directly into the kernel layout (quantizers/nunchaku/svdquant.py, pure PyTorch). When targets is omitted they are inferred structurally (repeated block stacks, adaLN/_keep_in_fp32_modules exclusions, packing-constraint filter) — zero configuration on typical DiTs. Includes a fix in load_model_dict_into_meta letting a quantize-on-load quantizer claim checkpoint keys that no longer exist on the model (replaced modules have qweight/… instead of weight), and an optional smooth_exponent knob.

Validation

  • Packing verified bit-identical to DeepCompressor-style exporters (weight codes, group/micro scales, vectors, low-rank layout); end-to-end reconstruction error matches an offline exporter to four decimals. Loading published pre-quantized checkpoints with this backend cross-validates both directions.
  • Auto-inference selected the correct target sets with no configuration on two architectures: 1,344 layers on LTX-2.3 (19B audio-video DiT), 144 on FLUX.2-klein-9B — matching hand-curated published checkpoints exactly.
  • Quality: seed-matched against bf16 on both models, data-free output is statistically equivalent to calibrated (smoothing-search + GPTQ) checkpoints of the same config — e.g. LTX-2.3 int4: 13.3 dB (data-free) vs 12.6 dB (calibrated) vs the same bf16 reference; nvfp4: 17.3 vs 15.3 dB. The trade against pre-quantized checkpoints is purely load time (~15 min of on-load SVDs for 19B vs ~40 s), not fidelity.
  • Tuning: rank is the effective quality lever (int4 rank=128: +2.7 dB over rank=32 for +16% transformer memory) and interacts with smooth_exponent (larger ranks prefer weaker smoothing) — documented in the quantization docs.

Hardware notes

nvfp4 requires Blackwell (sm_100+); int4 runs on Turing/Ampere/Ada and is the pre-Blackwell path. Known kernel-level issue, not introduced here: the int4 path is ~3-4x slower than nvfp4 on sm_120 (fine on A100), reproducible with existing pre-quantized checkpoints.

Self-review notes (AI-assisted development)

  • The meta-tensor loading fix was found by the first real-checkpoint run; NunchakuLiteTesterMixin.test_nunchaku_lite_quantize_on_load exists but no test class subclasses it yet, so quantize-on-load still lacks an e2e regression test against a real model — known gap, happy to wire a small-model test if reviewers want it in this PR.
  • A smooth_overrides mechanism (injecting precomputed per-layer smoothing vectors) was built for ablations and deliberately left out to keep the API surface minimal.
  • CPU-side unit tests validate the packing math against reference unpackers (15 tests).

rootonchair and others added 7 commits August 25, 2026 18:40
Support `pre_quantized=False` in NunchakuLiteQuantizationConfig: targeted
linears of an unquantized checkpoint are quantized at load time with
data-free SVDQuant (weight-span smoothing, rank-r SVD low-rank branch,
int4/nvfp4 group quantization) and packed directly into the kernel layout
SVDQW4A4Linear consumes — no calibration data needed.

The math lives in quantizers/nunchaku/data_free.py, which is pure torch and
stays importable without the `kernels` package; quantization happens per
weight in create_quantized_param so peak memory stays near the quantized
model size. Packed outputs are tensor-for-tensor identical to
DeepCompressor's Nunchaku W4A4 converter (verified against it on random
weights; qweight byte-identical). `awq_w4a16` targets are not supported in
this mode and raise. CPU tests validate shapes, round-trip reconstruction
error, bias packing, and the quantizer flow; a gated GPU mixin test runs
quantize-on-load end to end where kernels are available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
When `pre_quantized=False` and `svdq_w4a4.targets` is omitted, the quantizer
now infers targets from the model at load time: every nn.Linear whose
dimensions satisfy the Nunchaku packing constraints is selected, minus
modules matched by the new `modules_to_not_convert` config option or listed
in the model's `_keep_in_fp32_modules`. Explicit target lists keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
Auto-inference no longer needs a hand-written modules_to_not_convert list:
targets are restricted to the model's repeated transformer-block stacks
(identical-class nn.ModuleLists), which structurally excludes embedders,
final projections, and modulation heads, and adaLN-style linears inside
blocks are skipped via default ("norm", "modulation") name patterns. An
explicit modules_to_not_convert replaces the default patterns. For
FLUX.2-klein-9B the zero-config inferred target set matches the curated
list exactly (144 targets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
Clearer pairing with the svdq_w4a4 `targets` field, and avoids implying the
bnb/torchao semantics of keeping modules in high precision at load: the
option only filters data-free target inference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
Match the bitsandbytes loader contract: filter the load-time-produced packed
parameter names out of missing_keys via update_missing_keys, and remove the
consumed `weight`/`bias` checkpoint keys from unexpected_keys inside
create_quantized_param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
The module implements the SVDQuant math (smoothing, low-rank split,
quantization, kernel packing) as opposed to utils.py's kernel runtime;
name it after the algorithm. Data-free stays in the function names,
where it describes the mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
Name the integration test after the loader mechanism (pre_quantized=False)
rather than the algorithm mode, and rename the companion class attribute to
quantize_on_load_config_dict to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAtDkGmAw2fzbcvjfC79w
@github-actions github-actions Bot added documentation Improvements or additions to documentation quantization tests size/L PR with diff > 200 LOC labels Aug 26, 2026
rootonchair and others added 3 commits August 31, 2026 13:03
…he model

load_model_dict_into_meta skipped any checkpoint key not present in the
model's state dict before consulting the quantizer. In data-free mode the
replaced SVDQW4A4Linear modules no longer have a `weight` parameter, so the
checkpoint's `weight`/`bias` keys were silently dropped, the packed
parameters stayed on the meta device, and dispatch_model failed with
"Cannot copy out of meta tensor".

Give a quantize-on-load quantizer (pre_quantized=False) the chance to claim
such keys and materialize the module's packed parameters from them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
The weight-span smoothing exponent was hard-coded to 0.5. Make it an
optional config field (default unchanged) so the smoothing strength can
be tuned: 0 disables smoothing, 1 fully flattens per-channel weight
spans. Only valid in data-free mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
Measured on a 19B video DiT: int4 at rank=128 recovers ~2.7 dB over
rank=32 for ~16% more transformer memory, and the optimal smoothing
strength decreases as rank grows - note the interaction so users sweep
the exponent when raising rank instead of assuming the 0.5 default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
@rootonchair

rootonchair commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Experimental validation of data-free quantize-on-load

Evidence appendix for the claims in the PR description. All quality numbers use a seed-matched protocol: identical prompts, seeds, and GPU per comparison, scored as PSNR/SSIM against a bf16 reference — so they measure pure quantization-induced divergence. All measurements were taken with guidance genuinely disabled; note that on current main, guidance_scale=1.0 does not disable guidance for LTX-2 (see #14650) — any quality measurement of these models is contaminated without those fixes or explicit flags.

1. Data-free vs calibrated checkpoints (LTX-2.3-Distilled v1.1, 19B, t2v, 5 seeds)

Calibrated = published checkpoints produced offline with per-layer grid-searched smoothing (scored on calibration activations) + GPTQ residual rounding, on identical targets/rank/group size. Same-GPU bf16 references.

run GPU PSNR vs bf16 SSIM
data-free nvfp4 (rank 32) RTX PRO 6000 17.33 ± 4.07 0.60
calibrated nvfp4 RTX PRO 6000 15.34 ± 5.59 0.52
data-free int4 (rank 32) A100 13.29 ± 2.97 0.42
calibrated int4 A100 12.62 ± 3.26 0.39

Data-free matches or nominally exceeds calibrated on both precisions (consistent across three protocol variants). An ablation transplanting the calibrated checkpoints' per-layer smoothing vectors into the data-free pipeline changed nothing (13.18 vs 13.71 dB, within noise) — the calibrated pipeline's extra machinery does not help at the trajectory level on this model. The practical difference is load time only: ~40 s (pre-quantized) vs ~15–22 min of on-load SVDs (19B model).

2. Tuning: smooth_exponent and rank (int4, same protocol)

Smoothing-exponent sweep (at the default rank 32) — flat within noise; the default 0.5 is fine and the knob alone is not a quality lever:

smooth_exponent 0.1 0.25 0.5 (default)
PSNR vs bf16 13.39 ± 3.19 12.53 ± 1.94 13.29 ± 2.97
SSIM 0.44 0.41 0.42

Rank sweep (at the default exponent 0.5) — rank moves quality substantially:

rank 32 (default) 64 128
PSNR vs bf16 13.29 ± 2.97 15.11 ± 4.47 13.65 ± 2.47
SSIM 0.42 0.52 0.45
transformer size 10.6 GB 11.2 GB 12.3 GB

Full grid — the two knobs interact: the rank-128 optimum sits at a weaker exponent (0.25), plausibly because strong smoothing and a large SVD branch compete for the same outlier energy. Each row is the exponent sweep at a fixed rank; each column the rank sweep at a fixed exponent:

PSNR / SSIM e=0.0 e=0.1 e=0.25 e=0.5
rank 32 13.39 / 0.44 12.53 / 0.41 13.29 / 0.42
rank 64 13.25 / 0.43 13.68 / 0.46 14.27 / 0.47 15.11 / 0.52
rank 128 13.93 / 0.48 14.57 / 0.53 15.99 / 0.55 13.65 / 0.45

Best cell: rank 128 / exponent 0.25 — +2.7 dB over the defaults for ~16% more transformer memory and ~unchanged inference time (wins on 5/5 samples). Practical guidance documented in this PR's quantization docs: when raising rank, sweep the exponent rather than assuming 0.5.

Both knobs are set in the svdq_w4a4 section:

transformer = LTX2VideoTransformer3DModel.from_pretrained(
    "rootonchair/LTX-2.3-Distilled-v1.1-Diffusers",
    subfolder="transformer",
    quantization_config=NunchakuLiteQuantizationConfig(
        svdq_w4a4={
            "precision": "int4",
            "group_size": 64,
            "rank": 128,            # low-rank branch size (multiple of 16, or 0 to disable)
            "smooth_exponent": 0.25,  # optional; defaults to 0.5
        },
        pre_quantized=False,
    ),
    torch_dtype=torch.bfloat16,
    device_map="cuda",
)

smooth_exponent controls the data-free weight-span smoothing. A per-input-channel scale s_j = 1 / absmax(W[:, j]) ** smooth_exponent is computed, the weight columns are multiplied by it (W' = W · diag(s), equalizing per-channel magnitudes) before the SVD and residual quantization, and at runtime the kernel divides the corresponding activation channels by the same s — so W' · (x / s) = W · x exactly, and the output needs no correction. Smoothing never changes the linear map; it only relocates where the 4-bit quantization error lands across channels. 0.0 disables smoothing, 1.0 fully flattens per-channel weight spans. It is the weight-side analogue of SmoothQuant's exponent — with no calibration data there is no activation term, which is why it is a single scalar rather than a per-layer searched (α, β) pair.

3. Image-to-video

Same protocol, conditioning image + prompt + seed matched, LTX2ImageToVideoPipeline:

run PSNR vs bf16 SSIM frame-0 adherence to conditioning
bf16 33.70 dB
data-free int4, r128/e0.25 21.91 ± 2.45 0.86 33.45 dB
data-free int4, r64/e0.5 21.61 ± 1.94 0.85 33.34 dB

Conditioning collapses the divergence: quantized i2v is near-reference (~22 dB / 0.86 SSIM vs ~16 dB / 0.55 unconditioned), and the conditioning pathway itself is unaffected (frame-0 adherence equal to bf16; the ~34 dB ceiling is the model's own CRF-33 image recompression).

4. Second architecture: FLUX.2-klein-9B (image, 4-step distilled)

Auto-inference selected all 144 eligible block linears with zero configuration (fused SwiGLU/QKV-MLP projections included; dims-based exclusions correct). nvfp4: 18.1 ± 3.3 dB / SSIM 0.70 vs bf16; int4: 16.4 ± 2.3 / 0.61; the smoothing exponent is quality-neutral on this architecture across 0.0–0.75.

Hardware caveat (pre-existing, kernel-level — not introduced here): the int4 kernel path is ~3–4× slower than nvfp4 on sm_120 (reproduces with published pre-quantized checkpoints; e.g. 427 s vs 113 s per five 121-frame clips). On A100 (sm_80) int4 performs normally.


Quantize-on-load cost for reference (one torch.linalg.svd per target):

model targets GPU quantize-on-load pre-quantized load
FLUX.2-klein-9B 144 RTX PRO 6000 ~3.5 min
LTX-2.3-Distilled 19B 1,344 RTX PRO 6000 ~14.5 min ~40 s
LTX-2.3-Distilled 19B 1,344 A100 ~22 min ~50 s

Times are effectively independent of precision, rank, and smooth_exponent — the full SVD dominates and is shared by all configurations (across the entire rank/exponent grid on the A100, load times span 1327–1341 s, ~1%). Tuning those knobs costs nothing at quantize time.

Per-sample raw data (frames, metrics, seeds) for every table is retained and available on request.

rootonchair and others added 3 commits September 1, 2026 20:09
Data-free SVDQuant (weight-span smoothing, SVD, group quantization) is
pure PyTorch and never invokes a CUDA kernel, but validate_environment
unconditionally required a GPU and the kernels package, and
quantizers/nunchaku/utils.py fetched the CUDA kernel package eagerly at
import time even though `ops` is only used by SVDQW4A4Linear.forward at
real inference time. Both are now scoped to when they actually matter:

- validate_environment returns early for `pre_quantized=False` loads
  with no CUDA available; the real-inference (`pre_quantized=True`) path
  is unchanged.
- `ops` in nunchaku/utils.py is now a lazy proxy that defers the
  kernels-package/network fetch to the first actual kernel call, so the
  module imports without the `kernels` package or a GPU.
- is_serializable is now True. `_process_model_before_weight_loading`
  builds a save-ready config after target inference (pre_quantized=True,
  data-free-only knobs like smooth_exponent stripped - post_init rejects
  them once pre_quantized is True) and assigns it as both a dict item and
  an attribute on `model.config`, since `model.config` is a FrozenDict
  whose attribute assignment only shadows via instance __dict__ and is
  invisible to save_pretrained's dict-based serialization.

Together this lets a GPU-less machine quantize-on-load and
`save_pretrained` a checkpoint that loads normally via the existing
pre_quantized=True path elsewhere. Verified with a real (non-stubbed)
round trip: CPU quantize -> save_pretrained -> reload, packed tensors
bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
_process_model_before_weight_loading passed
`self.quantization_config.exclude_targets or ()` to
infer_data_free_targets, turning the config's own None default into an
explicit empty sequence. infer_data_free_targets treats None as "use
the norm/modulation defaults" and any explicit sequence (including an
empty one) as "use exactly this instead" - so a config that never sets
exclude_targets silently got zero exclusions through the quantizer,
unlike calling infer_data_free_targets directly with the argument
genuinely omitted. Pass the config's value through as-is and let its
own None-handling apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
transformer.save_pretrained() only writes the bare transformer, which
isn't loadable via DiffusionPipeline.from_pretrained the way "Load a
quantized pipeline" above it demonstrates. DiffusionPipeline.save_pretrained
just calls each registered component's own save_pretrained (verified: the
quantized transformer round-trips bit-identical through it, same as the
model-level path), so save the whole pipeline instead - one call produces
a self-contained, directly reloadable checkpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4NMaGamTgkHjL9EYqNZ7K
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation models quantization size/L PR with diff > 200 LOC tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant