Skip to content

[OMNIML-6370] [4/5] Score AutoQuantize KL candidates in a low-bit context - #3153

Draft
meenchen wants to merge 2 commits into
weimingc/autoquant-finalizefrom
weimingc/autoquant-kl-context
Draft

meenchen wants to merge 2 commits into
weimingc/autoquant-finalizefrom
weimingc/autoquant-kl-context

Conversation

@meenchen

@meenchen meenchen commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: New feature

[4/5] of the sub-4-bit AutoQuantize series (after #3152).

Series (merge in order): #3154 → #3151 → #3152 → #3153 → #3155

At sub-4-bit budgets, scoring each (group, format) with every other group unquantized misjudges which layers matter. On Qwen3.6-35B-A3B both the gradient and KL scorers rate the first 10 layers ~30× more sensitive than layers 20–39, starve the late layers with IQ2_XXS, and lose to uniform IQ2 at every budget.

method={"method": "kl_div", "context": "lowest" | k} scores every candidate, BF16 included, with every other searched group at its lowest-bit (k-th lowest) format. All scores of a group then share the context's base damage, so the linear program over their sum ranks assignments by marginal gain at that operating point (the max-score threshold search is kept for the no-context default). Format shards all calibrate the context format; shard 0 alone scores BF16 so the merge stays exact. The recipe schema accepts method_options: {context: ...} for kl_div.

The search state records the scoring context (scoring_config). Resume and shard merge refuse a checkpoint scored with a different context, and get_auto_quantize_config(state, constraints) re-solves with it. Before this, a re-solve rebuilt the searcher with default options, so a context-scored state went through the threshold solver instead of the LP.

Testing

  • Shard/merge test parametrized over context="lowest" and context=1; asserts BF16 is measured in context and that the budget is spent (fails with the threshold solver).
  • The same test re-solves the merged state with get_auto_quantize_config(state, {"effective_bits": 6.0}) and asserts it matches the unsharded search (fails without the scoring_config fix). AutoQuant + Shapley unit suites pass.
  • Qwen3.6-35B-A3B held-out KL (IQ2 + NVFP4 + FP8 + BF16 experts, sequential-GPTQ finalize):
bits default gradient context="lowest" uniform-GPTQ curve
2.75 0.105 0.101 ≈0.106
3.0 0.094 0.076 ≈0.078
3.25 0.093 0.065 IQ2_S @3.13: 0.064

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ (default unchanged)
  • New PIP dependency: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Oct 30, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@shengliangxu shengliangxu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (gpt-6.1-sol) — DM the bot to share feedback.

Changes requested: checkpoint-only finalization drops the KL context and uses the wrong solver.

Needs action:

  • Persist and restore KL context for offline re-solving in algorithms.py; extend get_auto_quantize_config and HF finalization coverage (see inline).
  • Reject checkpoint reuse and shard merges with incompatible scoring contexts in algorithms.py, with mismatch tests (see inline).
  • Explain in the PR body why context-KL is preferred over the existing Aumann–Shapley path scorer in _auto_quantize_shapley.py.

No action needed:

  • Existing test edits are justified: default cases remain covered, context calibration is shared, and KL now accepts context options.

We use binary search to minimize the max(per-layer score) while meeting the constraint.
A scoring context adds the same base damage to every candidate of every group, so a
threshold on raw scores is meaningless; those scores are ranked by their sum instead.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

[P1] Preserve the context for checkpoint-only re-solves

This dispatch depends on self.config['context'], but context is absent from default_state_dict, so neither the returned search state nor saved checkpoints record it. _resolve_best_recipe() constructs a KL searcher with default_search_config, making mtq.get_auto_quantize_config(state, constraints=...) use the threshold solver instead of this LP branch.

This also breaks the advertised finalization workflow: examples/hf_ptq/hf_ptq.py::_run_auto_quantize_recipe() calls that re-solve whenever finalize_algorithm resumes, even for the original budget. Rerunning the same command can therefore change the context-aware assignment before sequential GPTQ runs.

Please persist the scoring context and restore it into the offline searcher's config, retaining None semantics for legacy KL states. Extend the existing re-solve/finalization coverage to compare against the original LP assignment for both contexts, including merged checkpoints.

"""A searcher for AutoQuantize algorithm that uses KL-Divergence loss based score estimation."""

method_name = "kl_div"
method_config_keys = frozenset({"context"})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

[P2] Validate the scoring context before reusing scores

Context changes what every raw score means, but _AutoQuantizeBaseSearcher.before_search() only checks the method, costs, formats and resolved search setup before skipping scoring for a populated checkpoint. Reusing a default-KL checkpoint with context='lowest', or changing 'lowest' to 1, silently applies the new context's solver to old-context measurements.

Likewise, _SHARD_INVARIANT_STATE does not include context. Because searched-format scores are disjoint and only shard 0 measures BF16 in context, merging shards scored under different contexts can succeed without detecting incompatible measurements.

Please record and validate a context/scoring signature on resume and in shard invariants; the existing Aumann–Shapley scoring-signature mechanism provides a precedent. Add changed-context resume and mixed-context merge cases.

@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from 86b5475 to 70670ad Compare November 2, 2026 23:24
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 1fd2215 to 1658a6f Compare November 3, 2026 20:10
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch 2 times, most recently from 1691197 to a1bd87a Compare November 3, 2026 22:33
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 0986299 to bfecf53 Compare November 4, 2026 05:22
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch 2 times, most recently from e23cc89 to dbf9e33 Compare November 4, 2026 07:06
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from bfecf53 to 117544b Compare November 4, 2026 07:06
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from dbf9e33 to c8c8ae5 Compare November 4, 2026 16:32
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 117544b to 24168a1 Compare November 4, 2026 16:32
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from c8c8ae5 to fc9558b Compare November 4, 2026 18:52
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 24168a1 to 4b2ee1b Compare November 4, 2026 18:52
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from fc9558b to 45e81d8 Compare November 4, 2026 20:20
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 4b2ee1b to 1cdfb9e Compare November 4, 2026 20:20
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from 45e81d8 to eb5da0d Compare November 4, 2026 21:50
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 52ad620 to 92d35d5 Compare November 5, 2026 17:49
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch 2 times, most recently from 60303de to f8c7b37 Compare November 5, 2026 22:36
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 4097d58 to fbf446b Compare November 5, 2026 23:57
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch 2 times, most recently from 7275f38 to fe62ae5 Compare November 6, 2026 00:09
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 177fad8 to 2c167dc Compare November 6, 2026 00:24
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from fe62ae5 to 07e2459 Compare November 6, 2026 00:24
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 2c167dc to e78d3cc Compare November 6, 2026 00:58
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from 07e2459 to d8d6753 Compare November 6, 2026 00:58
meenchen added a commit that referenced this pull request Nov 6, 2026
…from the source weights (#3154)

### What does this PR do?

Type of change: Bug fix / new feature

[1/5] of the sub-4-bit AutoQuantize series (merge in order).

Series (merge in order): #3154 → #3151 → #3152 → #3153 → #3155

GPTQ and imatrix overwrite the weights they quantize. `auto_quantize`
rejected both, and accepting them as-is would let one candidate's
calibration change the weights every later candidate — and the
unquantized scoring reference — sees.

AutoQuantize now snapshots (to CPU) only the weights a GPTQ/imatrix
candidate's calibration rewrites: the parameters its enabled weight
quantizers cover, and for imatrix alone only those imatrix encodes (not
Q8_0; a fused projection when any of its experts is). After such a
candidate calibrates, its GGML quantizers replay the codes it pinned
while the source weights are restored for the next candidate; the
snapshot is freed before sensitivity scoring. After the solve, the
selected candidates' weights are left as their codes decode, as a plain
GPTQ run leaves them, and no candidate is left replaying. GGML fake
quant now returns exactly the reconstruction export decodes, so scoring
and the exported model see the same weights (BF16 included).

Refused before the model is converted: any candidate with an enabled
layerwise `export_dir`, empty included, whether its options are a dict
or a `LayerwiseConfig`, whose export would leave the model in export
form before scoring (`hf_ptq.py` already refused it for AutoQuantize
recipes); and, in a search with a weight-rewriting candidate, any
candidate whose layerwise `checkpoint_dir` resume restores the layers'
weights into new Parameters the source-weight restore cannot reach
(`calib_mutates_weights=False` resumes are accepted). Refused before any
calibration: a weight-rewriting candidate whose weight quantizer is not
a single GGML quantizer or rotates the weight, or whose algorithm list
runs anything besides GPTQ, imatrix and max (AWQ or SmoothQuant would
transform the weight GPTQ encodes); and meta or DTensor weights it would
rewrite, weights stored packed or scaled (a native FP8 checkpoint's), or
ones whose storage another parameter shares (tied weights, or views of
them). These checks need the converted quantizers to know which weights
a candidate rewrites, so `auto_quantize` hands back the unconverted
modules, and the configs that conversion changed (including nested
sub-configs), when the search fails before calibrating any candidate,
and the same model can be searched again. Only a search with a
weight-rewriting candidate saves the modules for this, and it drops them
once these checks pass, so calibration and scoring never hold the
weights conversion replaces (HF Conv1D's transposed copies); other
searches save nothing. A failure during calibration restores the source
weights and drops the modes that calibration added. `auto_quantize`
hands back the pre-quant-scale folding policy on every exit, including a
failure during scoring. Weights a calibration leaves alone (imatrix on
Q8_0) simply quantize the source weight. NVFP4/FP8-only searches take no
snapshot and never slice fused-expert weights after the solve.
`auto_quantize` now accepts `gptq`, `imatrix` and algorithm lists such
as `[gptq, imatrix]`.

#### Why snapshot and replay

Every candidate after a GPTQ/imatrix calibration, and the unquantized
scoring reference, must see the source weights. The alternatives cost
more:
- **Copy the model, or its state dict:** this holds every weight, about
70 GB for Qwen3.6-35B-A3B in BF16. The snapshot holds only the weights a
rewriting candidate quantizes, on the host, and is freed before scoring.
- **Make GPTQ/imatrix leave weights alone:** this would change
calibrators that `mtq.quantize` shares, and the chosen codes would still
have to be kept somewhere.

The GGML quantizer already pins those codes for export, so replaying the
pin makes the scored, selected and exported weights the same codes.

#### Reading order

1. `_AutoQuantizeBaseSearcher.before_search`: snapshot
(`_snapshot_source_weights`; its checks are `_rewritten_weight` and
`_check_restorable`), then for each candidate calibrate, replay its pins
and restore the source weights.
2. `_write_selected_codes_to_weights` (after the solve), and
`GGMLFormat._pinned_rows` / `full_pin` (the replay).
3. `auto_quantize`: the window in which a failure hands back the
unconverted modules, and the folding policy it hands back on every exit.

### Usage

```python
iq2_gptq = {
    "quant_cfg": [{"quantizer_name": "*", "enable": False},
                  {"quantizer_name": "*weight_quantizer",
                   "cfg": {"num_bits": "iq2_s", "block_sizes": {-1: 256}, "backend": "ggml", "effective_bits": 2.5625}}],
    "algorithm": [{"method": "gptq", "block_size": 256, "perc_damp": 0.3, "importance_weighted": True}, {"method": "imatrix"}],
}
model, state = mtq.auto_quantize(model, constraints={"effective_bits": 3.0},
                                 quantization_formats=[(iq2_gptq, "IQ2_S_GPTQ"), mtq.NVFP4_DEFAULT_CFG], ...)
```

### Testing

- New tests in `tests/unit/torch/quantization/test_ggml_gptq.py`,
`test_autoquant.py`, `plugins/test_fused_experts.py` and
`tests/gpu/torch/quantization/test_ggml_gptq_cuda.py`. I reverted each
fix in place and checked that its test fails. They cover:
  - other candidates' scores are unaffected by GPTQ;
- mixed selection: a GPTQ layer next to a BF16 layer, a GGML entry
replacing an earlier INT4 entry, and imatrix on Q8_0;
- the snapshot holds only rewritten weights (a fused `down_proj` that is
disabled or Q8_0), takes a whole fused `gate_up_proj` with one Q8_0
expert, and a BF16 selection hands back the source weights;
- refusals (non-GGML, tied, an aliased view, a rotated GPTQ or imatrix
target, AWQ then GPTQ, a layerwise `checkpoint_dir`, native FP8 weights
(GPU), and fused MoE whose root, nested and experts' configs conversion
changes) hand back the unconverted model and its configs, which searches
again;
- calibration failures (GPTQ rewrite then failure, `[gptq, layerwise
imatrix]` failing midway) restore the weights, the folding policy and
the mode list;
  - the folding policy comes back after a scoring failure;
  - exact BF16 replay;
  - no fused-expert indexing after a search without pins;
  - an `algorithm: []` candidate keeps the saved state restorable.
- Layerwise directories (`test_huggingface.py`), on a tiny Llama with
discoverable decoder layers: an FP8 candidate with an `export_dir`, with
an empty `export_dir`, and with a `checkpoint_dir` beside a Q8_0 GPTQ
candidate, each given as a dict and as a `LayerwiseConfig`, is refused
before conversion, leaving the modules, the weights and the working
directory untouched, and the same model searches again. Without the
refusals, the layers are exported and scoring fails on the packed
weights, or the max candidate's checkpoint is written.
- Conv1D weight lifetime (`test_huggingface.py`): an INT8 search saves
no module state, and a Q8_0 GPTQ search drops it before calibration, so
the weights conversion replaces are dead in every calibration and
scoring forward.
- Regression check against the base commit (RTX 6000 Ada, BF16, FP8 +
INT4 searches): a 24-layer GPT-2 (Conv1D), a 16-layer Llama and a
fused-MoE Qwen3, with gradient scoring and with KL for Llama, give the
same scores, selections and output logits, the same search time (within
4%) and the same memory. On GPT-2 the weights held after conversion are
0 MiB, against 576 MiB before 5acdcc4.
- Unit suites `tests/unit/torch/quantization` + `tests/unit/recipe` on
CPU (16 xdist workers): 2151 passed. The rest fail the same way on
`main` at this branch's base on the test host:
  - diffusers lacks `q_bmm_quantizer` (4 failures);
  - `test_diffusers_wan_conv3d` fails to collect (16 errors);
- one to four `test_nemotron_3_5_super_vl_autoquant_recipe` cases fail
under xdist and pass serially.
- `pre-commit run mypy --all-files` passes; ruff check and ruff format
pass on the touched files.
- Rollback cost, paid only by searches with a GPTQ/imatrix candidate:
saving the module attributes takes 2 MiB and 0.16 s on Qwen3.6-35B-A3B
(837 modules), and 69 MiB on a 24.6k-module non-fused MoE.
- GPU (RTX 6000 Ada): `tests/gpu/torch/quantization -k "auto_quant or
autoquant or ggml or gptq or imatrix"` 57 passed, including
`test_fsdp2_autoquant.py` and the native FP8 refusal. The deepspeed and
onnx packages on the host stop 3 files from collecting.
- Stack restacked on this; the same unit suites at [5/5]: 2148 passed,
with the same failures.
- Qwen3.6-35B-A3B on GB300: searches over IQ2_{XXS,XS,S} (imatrix and
local GPTQ), NVFP4, FP8, BF16; see PR 5 for results.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: ✅
- Did you update Changelog?: ✅
- Did you get Claude approval on this PR?: review comments addressed
(007a287, 83df05c, 848bd95, 357fb38, 59ea14d, 609ed1c,
9f76d8d, 7a21227, 7e028ac, 5acdcc4, f75da89, a9f070f,
1808e13)

### Additional Information

Supersedes #3082 … #3057 and #3113 … #3120 with a smaller design (~720
source lines across the series).

Synced with main by a merge (99f82c8), which keeps the commits the
review threads cite. #3197 made the IQ formats re-encode a weight under
its pinned scales and importance ahead of the pinned-rows lookup that
honours `PIN_OVERRIDES_WEIGHT`, so during a search a GPTQ or imatrix
candidate would have quantized the source weight under its scales
instead of replaying its codes; b7be410 puts the override first there
too (caught by
`test_a_replayed_pin_fake_quantizes_to_exactly_its_decoding_in_bf16`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * AutoQuantize supports GPTQ and iMatrix candidates with GGML formats.
* Selected candidates retain their calibrated quantized results in the
returned model.
* **Bug Fixes**
* Candidate scores are no longer affected by weight changes made while
evaluating other candidates.
* Source weights and quantization settings are restored when candidate
evaluation fails; unsupported weight-rewrite configurations are
rejected.
* Failed conversions before calibration begins restore the model’s prior
state.
* Fake quantization better preserves reconstructed values in narrow data
types.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: weimingc <17592131+meenchen@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from d8d6753 to 80c3352 Compare November 6, 2026 06:13
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch 2 times, most recently from 5e21a27 to a8d97a1 Compare November 6, 2026 21:37
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch 2 times, most recently from 67cd622 to 41c495f Compare November 6, 2026 23:01
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from a8d97a1 to 15b44f5 Compare November 6, 2026 23:01
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from 41c495f to bf155a4 Compare November 7, 2026 02:15
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 15b44f5 to 92431b3 Compare November 7, 2026 02:15
meenchen and others added 2 commits November 6, 2026 19:54
KL-divergence AutoQuantize scores each (group, format) with every other
group unquantized. At sub-4-bit budgets that misjudges which layers
matter: on Qwen3.6-35B-A3B it starves late layers whose damage only
shows once every layer is quantized, and the selection loses to uniform
IQ2 at matched bits.

method={'method': 'kl_div', 'context': 'lowest'} scores each candidate,
BF16 included, with every other searched group at its lowest-bit
format. All scores of a group then share the context's base damage, so
the linear program over their sum ranks assignments by marginal gain at
that operating point; the max-score threshold search is only used
without a context. Format shards all calibrate the context format, and
shard 0 alone scores BF16 so the merge stays exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: weimingc <17592131+meenchen@users.noreply.github.com>
get_auto_quantize_config(state, constraints) rebuilt the searcher with
default options, so a context-scored KL state was re-solved with the
max-score threshold solver instead of the LP the scores need. Save the
options that change what a score means in the search state, check them
on resume and shard merge, and apply them on re-solve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: weimingc <17592131+meenchen@users.noreply.github.com>
@meenchen
meenchen force-pushed the weimingc/autoquant-kl-context branch from 92431b3 to c543ea0 Compare November 7, 2026 05:32
@meenchen
meenchen force-pushed the weimingc/autoquant-finalize branch from bf155a4 to 8653170 Compare November 7, 2026 05:32

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants