feat(quantization): scoped calibration pipelines via algo_cfg [prototype] - #2292
Draft
Fridah-nv wants to merge 6 commits into
Draft
feat(quantization): scoped calibration pipelines via algo_cfg [prototype]#2292Fridah-nv wants to merge 6 commits into
algo_cfg [prototype]#2292Fridah-nv wants to merge 6 commits into
Conversation
Prototype of the flexible-calibration design: assign an ordered calibration
pipeline per scope instead of one model-wide `algorithm`.
config = {
"quant_cfg": [...],
"algo_cfg": [
{"module_name": "*self_attn*", "cfg": ["awq_lite", "mse"]},
{"module_name": "*mlp*", "cfg": ["max", {"method": "gptq"}]},
{"quantizer_name": "*input_quantizer", "cfg": ["max"]},
],
"algorithm": "max", # fallback for anything no entry matches
}
`compile_algo_cfg` lowers the config into ordered scoped stages, reading the
model's structure to resolve globs and validate but mutating nothing. The new
`calibration_plan` mode executes those stages through the existing calibration
functions, gated by a `should_process` write-mask, and records one mode.
`algorithm` lowers through the same path as its all-`"*"` case, so there is no
second engine; with no `algo_cfg` the old path and its saved state are
untouched.
Two upstream bugs found while making pipelines actually sequence, both
reproducible on today's un-scoped `algorithm=[...]` list:
- `_mse_calibrate_weights` never restored the search calibrator it installs, so
any stage after `mse` crashed (`algorithm=['max','mse','max']` -> TypeError).
Now restored in a `finally`.
- `awq_lite` called `enable_stats_collection(model)` directly, so the write-mask
had to reach helper calls inside an algorithm, not just its module loop.
Validation rejects a config before anything runs: unknown algorithm, empty
scope, role mismatch, fusible siblings split across pipelines, a stage whose
every write is overwritten before being read, and repeating an algorithm whose
own output violates its precondition. The last two are what make
`awq_lite -> mse -> awq_lite` wrong.
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
|
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. |
Contributor
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2292 +/- ##
==========================================
- Coverage 79.05% 77.92% -1.14%
==========================================
Files 525 528 +3
Lines 61106 64054 +2948
==========================================
+ Hits 48308 49912 +1604
- Misses 12798 14142 +1344
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…lare it `derive_handoff` reports what state earlier stages already produced; most algorithms have no knob to act on that. Handing `skip_max_init` to one of them made the stage config raise `extra_forbidden`, so every chain ending in `awq_clip` (which also consumes a prior stage's amax) failed to build. Found by sweeping all 121 ordered algorithm pairs through compile-then-run and cross-checking each outcome against the declared capability table: the five `* -> awq_clip` chains crashed on the scoped path while working on the legacy `algorithm=[...]` path, which located the bug in the executor rather than in any algorithm. Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
`gptq()` unconditionally re-derived amax from max before its weight update, so a range search in front of it was discarded and the compiler correctly reported the search as a dead stage. But rounding error is only compensated consistently if GPTQ works against the grid the model actually uses, so the search belongs *before* GPTQ, not after -- which is the order DeepCompressor's QoQ recipes ship (`qoq-gchn.yaml`, `ooo.yaml`: `enable_calib_range` then `enable_kernel_gptq`). - `gptq(..., skip_max_init=False)` guards the initial `max_calibrate`. It also seeds the input quantizers, so it may only be skipped when an earlier stage calibrated them -- which is what the executor's handoff guarantees. - `GPTQCalibConfig.skip_max_init` exposes it. - `ALGO_CAPABILITIES["gptq"]` declares `weight_amax` as an input, so the executor derives the flag and the dead-stage rule stops firing. When nothing produced an amax, GPTQ still initializes its own, so an unsatisfied input is not an error. Tests cover both chains this enables: `mse -> gptq` keeps the searched amax bit-identically while differing from plain GPTQ, and `awq_lite -> mse` refines the amax on AWQ's smoothed weights. One declared token moved 5 of 121 ordered pairs and 105 of 1331 triples from "rejected" to "composing" in the algorithm sweep. Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…dels
All three produced a wrong model with no error, and all three were invisible to
the existing tests because the fixture uses a single-level weight quantizer and
always supplies a forward loop.
1. `_index_model` attached a quantizer only to its *direct* parent, so a
SequentialQuantizer's levels (`<linear>.weight_quantizer.0`) -- the W4A8 /
INT4-AWQ shape -- were grandchildren and belonged to no module scope. A
`module_name` stage left all 28 sub-quantizers uncalibrated where the legacy
path left none. Now walks up to the nearest enclosing quantized linear.
2. `stage_targets` subtracted a `quantizer_name` exclusion's *parent modules*
from the fallback stage, even though such an entry only claims quantizers.
The documented `{"quantizer_name": "*input_quantizer"}` + `algorithm="max"`
pattern gave the fallback 0 modules, so `weight_only_quantize` iterated
nothing and every weight quantizer went uncalibrated -- masked whenever a
forward loop happened to set the amax instead. Exclusions now apply at the
granularity the excluding entry claimed.
3. Four of the eleven declared algorithms never honoured `should_process`:
`local_hessian` and `nvfp4_act_headroom` raise on the unexpected kwarg, while
`svdquant` and `lsq` swallow it via `**kwargs` and run over the whole model,
clobbering other stages. `AlgoCapabilities.supports_scoping` now records this
and compile rejects a scoped stage for those algorithms; `wrapped_calib_func`
only forwards the predicate to functions that declare it, so whole-model use
is unchanged.
Adds two fixtures the suite was missing -- a SequentialQuantizer config and a
weight-only/no-forward config -- plus five regression tests. 35 tests in
test_algo_cfg.py; full unit suite 862 passed, 1 skipped.
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
`awq()` runs `awq_lite` then `awq_clip` internally when given `awq_full`, so the scoped-plan surface should be able to express that composite as an ordinary two-stage pipeline. It can, bit-identically on every weight quantizer. This is the strongest available evidence that stage sequencing reproduces what an algorithm already does inside itself -- stronger than "the result differs from either stage alone", which only shows something happened. The test also compares against `awq_lite` on its own so it cannot pass by `awq_clip` silently doing nothing. Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
`granularity` was declared for all eleven algorithms and read by nothing. That
left a hole: a module-level algorithm under a `quantizer_name` scope that
selects only part of a module compiles clean and then writes outside its scope.
Verified before the fix: `{"quantizer_name": "*weight_quantizer", "cfg":
["awq_lite"]}` compiled, `role_quantizers` reported 0 writable input quantizers,
and the run wrote `pre_quant_scale` to all 14 of them. The write-mask cannot stop
it -- a `quantizer_name` scope resolves to its parent modules, `awq_lite` gates
on the module name, and then writes `module.input_quantizer` directly.
The declared write-set being wrong is the worse half: `effective_produces` and
`_token_overlap` are derived from it, so the compiler *understates* what such a
stage writes and would miss a genuine conflict with a later input-quantizer
stage.
Validation now requires a module-level algorithm's scope to be closed under
module ownership -- every quantizer of every module it touches must be in scope.
`module_name` scopes and whole-model `quantizer_name="*"` are closed by
construction, so the legacy lowering and every shipped example are unaffected.
Full suite 865 passed, 1 skipped; all four demos pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Type of change: new feature
Adds an opt-in
algo_cfgkey that assigns an ordered calibration pipeline per scope, instead of a single model-widealgorithm. This expresses two things the current surface cannot: different algorithms for different parts of the model (parallel), and an ordered pipeline on the same targets where each stage consumes the previous one's mutated weights/scales (sequential).Usage
An
algo_cfgentry has the same{<selector>, "cfg": ...}shape as aquant_cfgentry:quant_cfgentries carry quantizer attributes,algo_cfgentries carry the ordered algorithms. Exactly one selector per entry —module_name(module/weight-level algorithms, role implied) orquantizer_name(when the role must be chosen explicitly).What changed
algo_cfg.py(new) — the compile half.compile_algo_cfg(config, model)lowersalgo_cfg+algorithminto an ordered list ofAlgoStages. Pure: it reads the quantized model's structure to resolve globs and validate, but mutates nothing, runs no forward and touches no data. So bad configs fail before any expensive calibration, it is testable without running a model, and the plan is a pure function of(config, structure)— hence identical on every rank, which is what keeps predicate scoping from desynchronizing collectives.ALGO_CAPABILITIES— a declared table per algorithm (granularity,role,requires,produces,requires_absent,supports_scoping, forward traits).derive_handoff()— a stage whose inputs an earlier stage already produced is told to skip its own initialization (skip_max_init), derived from the declared capabilities rather than a hard-coded algorithm pair.plan_hash()— excludes provenance, so equivalent plans written two ways hash the same; intended as the rank-identical-plan assert.mode.py— the execute half. Acalibration_planmode whoseconvertcompiles the plan, then runs each stage in order through the existingwrapped_calib_funcwith ashould_processwrite-mask built from the stage's scope. Records one mode; restore is the generic quantizer-state snapshot, unchanged.config.py—AlgoCfgEntry,CalibrationPlanConfig,QuantizeConfig.algo_cfg/.strict,MseCalibConfig.skip_max_init,GPTQCalibConfig.skip_max_init;need_calibrationconsidersalgo_cfg.model_quant.py—calibrate(..., algo_cfg=, strict=);quantizepasses them through.model_calib.py—should_processwrite-mask threaded into the module-iteration points ofmax/mse/awq/awq_clip/gptq/smoothquant. DefaultNonemeans "whole model", i.e. today's behaviour. The mask gates writes only and never toggles enable-state, so the activations search-based algorithms see are unchanged.algorithmlowers through the same path as its all-"*"case, so there is no second engine. With noalgo_cfgthe old path, its numerics and its saved state are untouched.GPTQ can now consume a preceding range search
gptq()previously re-derived amax from max unconditionally, so a range search in front of it was discarded and the compiler correctly reported the search as a dead stage. But rounding error is only compensated consistently if GPTQ works against the grid the model actually uses, so the search belongs before GPTQ — which is the order DeepCompressor's QoQ recipes ship (qoq-gchn.yaml:enable_calib_rangethenenable_kernel_gptq).gptqnow declaresweight_amaxas an input and takesskip_max_init; the executor derives the flag.mse → gptqkeeps the searched amax bit-identically while differing from plain GPTQ. Declaring that one token also moved 5 of 121 ordered algorithm pairs and 105 of 1331 triples from "rejected" to "composing".Chains verified to work
Three chains are exercised end to end and pinned by tests. All numbers are from a seeded CPU toy
model — these establish that sequencing is correct, not that any chain improves accuracy.
awq_lite → awq_clipawq_fullon every weight quantizertest_awq_full_is_exactly_its_two_stage_pipelineawq_lite → msetest_awq_then_mse_refines_the_smoothed_weightsmse → gptqtest_gptq_preserves_a_preceding_range_searchThe first is the strongest signal in the PR:
awq()already runsawq_litethenawq_clipinternally when asked for
awq_full, and the plan surface reproduces that composite exactly as anordinary two-stage pipeline. An existing bundled algorithm is a pipeline; this just makes it one
the user can write. (The test also compares against
awq_litealone, so it cannot pass byawq_clipdoing nothing.)The second is the cheap-refinement case: MSE re-searches the clipping range on AWQ's smoothed
weights with no forward pass, where
awq_clipneeds a full search pass. Whether it matchesawq_clipin accuracy is exactly the first experiment to run on a real model.The third is the ordering the prior art ships (DeepCompressor's QoQ runs its range search before
the GPTQ kernel) and is what the
skip_max_initwork above enables.Two more compile clean but were not executed here:
gptq → mse(the reverse ordering, worthrunning as the A/B control) and
smoothquant → gptq(the most common chain in llm-compressor;smoothquantfinds nothing to smooth on the toy model, so it proves nothing at this scale).Upstream bugs fixed along the way
Both reproduce on today's un-scoped
algorithm=[...]list — not artifacts of the scoped plan, but they block sequencing.msecrashed._mse_calibrate_weightsassignedweight_quantizer._calibratorto its search calibrator and never restored it, so the next stage that collects stats re-entered a spent calibrator:algorithm=['max','mse','max']→TypeError: unsupported operand type(s) for *: 'NoneType' and 'Tensor'. Now restored in afinally.awq_litecalibrated the whole model regardless of scope, because it callsenable_stats_collection(model)directly. The write-mask has to reach helper calls inside an algorithm, not just its top-level module loop.Selector rules, and what enforces them
Exactly one selector per entry is enforced at config-construction time by
AlgoCfgEntry._normalize_entry(a pydantic before-validator), so a malformed entry fails beforethe model is ever consulted.
The semantic half —
module_namefor module-level algorithms,quantizer_namewhen the rolemust be chosen explicitly — is enforced by two compile-time rules:
quantizer_namescope matches only the wrong role is rejected(
mseon*input_quantizerwrites nothing);every module it touches has to be in scope.
The second rule closes a hole worth calling out, since it is the kind of thing this whole design
is supposed to prevent.
{"quantizer_name": "*weight_quantizer", "cfg": ["awq_lite"]}used tocompile clean while
role_quantizersreported zero writable input quantizers — and the run thenwrote
pre_quant_scaleto all 14 of them. The write-mask cannot stop that: aquantizer_namescope resolves to its parent modules,
awq_litegates on the module name, and then writesmodule.input_quantizerdirectly. The worse half is thateffective_producesand_token_overlapare derived from the declared role sets, so the compiler understated what thestage writes and would have missed a real conflict with a later input-quantizer stage.
Closure is the right formulation rather than a blunt "module granularity forbids
quantizer_name",because
algorithm="awq_lite"lowers to aquantizer_name="*"stage — whole-model andmodule_namescopes are closed by construction, so the legacy path and every shipped example areunaffected.
Known limitation: four algorithms cannot be scoped yet
local_hessianandnvfp4_act_headroomtake noshould_process;svdquantandlsqswallow it via**kwargsand would run over the whole model, clobbering other stages.AlgoCapabilities.supports_scopingrecords this and compile rejects a scoped stage for them with a clear message rather than mis-calibrating; whole-model use is unchanged. Adding realshould_processsupport to those four is follow-up work.Testing
tests/unit/torch/quantization/test_algo_cfg.py— 38 tests: lowering, plan-hash equivalence, every validation rule, derived handoff, write-mask, enable-state untouched, single recorded mode,mse → gptqpreserving the searched amax,awq_lite → mse, and three fixture shapes (single-level quantizer, SequentialQuantizer, weight-only/no-forward).pytest tests/unit/torch/quantization/ --ignore=.../plugins→ 865 passed, 1 skipped. (plugins/does not collect in my env:test_diffusers_wan_conv3d.pyfails to import diffusers — pre-existing, unrelated.)Backward compatibility.
algorithm="max"and the equivalentalgo_cfgcompile to the same plan hash and produce bit-identical amax. Note this was initially verified too narrowly — only for a single-level weight quantizer with a forward loop. A code review found two cases where equivalence broke: SequentialQuantizer configs (W4A8 / INT4-AWQ) under amodule_namescope, where sub-quantizers are grandchildren of the linear and were reachable by no module scope; and the weight-only/no-forward path, where aquantizer_nameentry stripped the fallback stage of its modules andweight_only_quantizeiterated nothing. Both are fixed and both now have regression tests with the fixture shapes that were missing.Not covered: distributed (no multi-GPU available — the rank-identical-plan property is structural but untested; a 2-GPU TP/EP test is still needed), shared-forward batching across independent stages,
auto_quantizeper-layer algorithm, GPU-only algorithms (gptq/svdquantcompile and validate; onlygptqwas executed, on CPU), and any accuracy measurement — nothing here shows a chain improves a model, only that chains are expressible and correct.Before your PR is "Ready for review"
algo_cfgis opt-in; without it the old path, numerics and saved state are unchanged.CONTRIBUTING.md: N/A — no copied code, no new dependencies.test_algo_cfg.py(38 tests).Additional Information
Draft on purpose. The config surface (
algo_cfg, per-entrycfg) and how much of the capability contract belongs in the first cut are what I would most like feedback on before polishing this for merge.Open items from an internal review, not yet addressed:
derive_handoffuses any-overlap where it needs superset coverage (a narrow producer can wrongly setskip_max_initon a wider consumer); the fused-sibling check skips groups where only some members are matched by an entry; a derived handoff overrides an explicitly written kwarg;algo_cfgcombined withlayerwise.enable=Truebuilds the predicate from full-model names but is applied to subtree-relative ones; andcompile_algo_cfgrecomputes the model index per call, which will not scale to per-layer configs on large models.🤖 Generated with Claude Code