Skip to content

[Fix] Calibrate non-decoder modules during layerwise quantization - #2339

Open
realAsma wants to merge 6 commits into
mainfrom
asma/layerwise-lm-head
Open

[Fix] Calibrate non-decoder modules during layerwise quantization#2339
realAsma wants to merge 6 commits into
mainfrom
asma/layerwise-lm-head

Conversation

@realAsma

@realAsma realAsma commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix

Layerwise calibration now calibrates enabled quantizers outside transformer layers, such as lm_head, while hiding decoder layers from the additional calibration traversal. It also fails early when these quantizers are combined with progressive layerwise export, whose in-place conversion makes the required model calibration unsafe.

Usage

No API changes.

Testing

  • pytest_pwd tests/unit/torch/quantization/test_calib.py tests/unit/torch/quantization/test_layerwise_calibrate.py -q — 68 passed
  • Pre-commit on all six changed files — passed
  • git diff --check — passed

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?: N/A
  • Did you get Claude approval on this PR?: N/A

Summary by CodeRabbit

  • New Features

    • Layerwise quantization calibration now supports enabled quantizers outside transformer decoder layers, including model output components such as language-model heads.
    • Calibration preserves module access and model behavior while processing decoder layers.
    • Calibration can account for offloaded models and provides warnings when full-model passes may affect performance.
  • Bug Fixes

    • Improved calibration cleanup and state restoration after errors or completed export workflows.
  • Behavior Changes

    • Export mode now rejects configurations containing unsupported quantizers outside transformer layers.

@copy-pr-bot

copy-pr-bot Bot commented Sep 4, 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 Sep 4, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 35730b10-b05a-43d6-b110-035aba6aba97

📥 Commits

Reviewing files that changed from the base of the PR and between 7f7c46d and 76106e2.

📒 Files selected for processing (4)
  • modelopt/torch/quantization/model_calib.py
  • modelopt/torch/quantization/utils/layerwise_calib.py
  • tests/unit/torch/quantization/test_calib.py
  • tests/unit/torch/quantization/test_layerwise_calibrate.py

Included review availability: Your plan provides up to 12 included reviews per hour; 10 remain after this review.


📝 Walkthrough

Walkthrough

Layerwise calibration now handles enabled quantizers outside transformer decoder layers. It hides decoder subtrees during full-model calibration, integrates the process with export and restoration paths, and adds coverage for calibration, offload, cleanup, and export behavior.

Changes

Outside Quantizer Calibration

Layer / File(s) Summary
Outside quantizer calibrator
modelopt/torch/quantization/utils/layerwise_calib.py
The new calibrator detects enabled quantizers outside decoder layers. It hides decoder subtrees during full-model calibration, supports qdq_from_prev, warns for offloaded models, and restores the original model structure.
Calibration and export integration
modelopt/torch/quantization/model_calib.py
Layerwise calibration invokes the outside quantizer calibrator after restoration and when exported shards are already complete. Export mode rejects enabled quantizers outside transformer layers.
Calibration behavior validation
tests/unit/torch/quantization/test_layerwise_calibrate.py, tests/unit/torch/quantization/test_calib.py
Tests cover quantizer selection, alias restoration, exception cleanup, offload warnings, forward configuration, export rejection, LM-head calibration, and the default calibration setting.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant LayerwiseCalibration
  participant OutsideQuantizerCalibrator
  participant DecoderLayers
  participant OutsideQuantizers
  LayerwiseCalibration->>OutsideQuantizerCalibrator: identify enabled outside quantizers
  OutsideQuantizerCalibrator->>DecoderLayers: hide decoder subtrees
  OutsideQuantizerCalibrator->>OutsideQuantizers: run full-model calibration
  OutsideQuantizerCalibrator->>DecoderLayers: restore decoder subtrees
  LayerwiseCalibration->>OutsideQuantizers: retain calibrated state
Loading

Suggested reviewers: fridah-nv

Merge Risk: ⚪ Minimal · up to 76106

No merge-blocking calibration risk was identified.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: calibrating non-decoder modules during layerwise quantization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed PASS. The authoritative PR diff changes four Python files only: two modelopt calibration modules and two tests. Added modelopt code contains no torch.load(..., weights_only=False), numpy.load(..., all…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch asma/layerwise-lm-head

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2339/

Built to branch gh-pages at 2026-09-10 20:38 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.41270% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 78.65%. Comparing base (7f7c46d) to head (76106e2).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...delopt/torch/quantization/utils/layerwise_calib.py 98.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2339      +/-   ##
==========================================
+ Coverage   75.52%   78.65%   +3.12%     
==========================================
  Files         542      542              
  Lines       63778    63838      +60     
==========================================
+ Hits        48167    50209    +2042     
+ Misses      15611    13629    -1982     
Flag Coverage Δ
examples-diffusers 20.65% <20.63%> (-0.14%) ⬇️
examples-gpt-oss 13.17% <20.63%> (-0.10%) ⬇️
examples-hf_ptq 21.38% <20.63%> (+0.42%) ⬆️
examples-llm_distill 13.24% <20.63%> (-0.11%) ⬇️
examples-llm_eval 17.06% <20.63%> (-0.09%) ⬇️
examples-llm_qat 17.40% <20.63%> (-0.15%) ⬇️
examples-llm_sparsity 15.74% <20.63%> (-0.16%) ⬇️
examples-megatron_bridge 26.12% <20.63%> (-0.51%) ⬇️
examples-specdec_bench 12.92% <20.63%> (-0.09%) ⬇️
examples-speculative_decoding 17.48% <20.63%> (-0.18%) ⬇️
examples-torch_onnx 21.66% <20.63%> (-0.11%) ⬇️
examples-torch_trt 14.95% <20.63%> (-0.12%) ⬇️
gpu 58.47% <61.90%> (+7.68%) ⬆️
regression 14.95% <20.63%> (+0.09%) ⬆️
unit 57.20% <96.82%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread modelopt/torch/quantization/model_calib.py Outdated
@realAsma

realAsma commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

/claude review

Comment on lines +2089 to +2101
decoder_owned_ids = {id(module) for layer in transformer_layers for module in layer.modules()}
has_enabled_outside_quantizer = any(
isinstance(module, TensorQuantizer)
and module.is_enabled
and id(module) not in decoder_owned_ids
for module in model.modules()
)

if export_dir is not None and has_enabled_outside_quantizer:
raise ValueError(
"Layerwise export does not support enabled quantizers outside transformer layers. "
"Calibrate without export_dir, then export the completed model separately."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Performance] The gate is module.is_enabled, not "this quantizer still needs data-driven calibration". Any enabled non-decoder quantizer — including a weight-only quantizer, a top-level type: dynamic quantizer, an MX (MXFP4/MXFP8) quantizer, or one pinned via constant_amax — flips has_enabled_outside_quantizer to True.

Why it matters: for a weight-only recipe that enables lm_head (e.g. INT8_WEIGHT_ONLY_CFG, INT4_BLOCKWISE_WEIGHT_ONLY_CFG, W4A16 AWQ), the only outside quantizer is lm_head.weight_quantizer, which max_calibrate calibrates directly on the weight tensor via weight_only_quantize() — the forward_loop contributes nothing. But the block at line 2232 unconditionally runs calib_func(model, forward_loop, ...), i.e. a full extra pass of the entire calibration dataset through the whole model. On the exact models layerwise calibration exists for (large, accelerate/disk-offloaded), that is the single most expensive thing in the run, and for these recipes it is pure waste. The same applies to a fully-dynamic activation quantizer outside the decoder, which needs no amax at all.

This file already has the precise predicate for the forward question — _needs_activation_forward_for_max_calib() (line 268) — and max_calibrate already accepts skip_forward_without_activation_calib.

Suggested shape: keep the calib_func invocation gated on "some outside quantizer needs any calibration" (weight amax counts), but gate the forward on whether an outside activation quantizer needs data — e.g. compute the flag over the hidden-decoder view and pass skip_forward_without_activation_calib=True for this extra pass when calib_func supports it:

if has_enabled_outside_quantizer:
    ...
    with _hide_modules_from_traversal(model, transformer_layers):
        extra_kwargs = dict(calib_kwargs)
        if calib_func is max_calibrate:
            # Outside quantizers may be weight-only / dynamic / MX; let max_calibrate
            # skip the (full-model, full-dataset) forward when no activation stats are needed.
            extra_kwargs.setdefault("skip_forward_without_activation_calib", True)
        ...

At minimum, please make the export_dir rejection at line 2097 use the narrower predicate too, so recipes whose outside quantizers need nothing data-driven don't lose layerwise export for no reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Published in ed7b4d7. The outside calibration pass now runs only when needed, preserves omitted versus explicit skip-forward behavior, and blocks export whenever that pass would execute the progressively packed model. Focused pre-commit and all 50 layerwise calibration tests pass.

Comment thread modelopt/torch/quantization/model_calib.py Outdated
Comment thread modelopt/torch/quantization/utils/layerwise_calib.py
Comment thread modelopt/torch/quantization/model_calib.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — layerwise non-decoder calibration

Scope: full review (trigger comment had no scoping instructions). 3 files changed (274+/2-); reviewed all three: modelopt/torch/quantization/model_calib.py, modelopt/torch/quantization/utils/layerwise_calib.py, tests/unit/torch/quantization/test_layerwise_calibrate.py. Also read plugins/megatron.py / huggingface.py decoder-layer registration and modelopt_recipes/configs/ptq/units/default_disabled_quantizers.yaml to judge blast radius.

Findings — CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 3

# Severity Where Issue
1 IMPORTANT Performance model_calib.py:2089-2101 Gate is is_enabled, not "needs data-driven calibration" — weight-only / dynamic / MX / constant-amax outside quantizers trigger a full extra pass of the whole calibration dataset through the whole model
2 SUGGESTION model_calib.py:2224-2232 has_enabled_outside_quantizer is a rank-local decision guarding collectives; PP stage asymmetry could hang
3 SUGGESTION layerwise_calib.py:105-124 _ForwardOnlyLayer duplicates _SkipLayer delegation and reaches into its private blocklist; proxy silently discards attribute writes
4 SUGGESTION model_calib.py:2097-2101 Export error message omits the fix most users want (disable the outside quantizer) and doesn't name the offenders

Most impactful

Finding 1 is the only one I would hold the PR for. The bug being fixed is real and the mechanism is sound, but the trigger condition is broader than the need. For a weight-only recipe that enables lm_head, the only outside quantizer is lm_head.weight_quantizer, which max_calibrate calibrates directly on the weight tensor in weight_only_quantize() — the forward_loop contributes nothing, yet the new block runs a complete extra dataset pass over the full (often disk-offloaded) model. This file already has the exact predicate (_needs_activation_forward_for_max_calib, line 268) and max_calibrate already has the skip_forward_without_activation_calib knob. The same over-broad predicate makes the new export_dir ValueError fire for configs where nothing was actually miscalibrated.

What I verified as correct

  • Traversal hiding actually hides. _original is set via object.__setattr__, so it never lands in _modules; enable_stats_collection / finish_stats_collection / _needs_activation_forward_for_max_calib / SharedWeightGlobalAmaxState.attach all walk named_modules() and therefore cannot re-touch decoder quantizers. Decoder _amax survives the extra pass — and the test asserts exactly that.
  • Forward fidelity. forward calls self._original(...), i.e. type(original).__call__, so HF GradientCheckpointingLayer.__call__ overrides and accelerate's _hf_hook-wrapped forward still run. _hf_hook/_old_forward staying on _PROXY_BLOCKLIST correctly keeps accelerate from trying to manage the parameter-free proxy.
  • Slot restoration. Parents come from model.modules(), aliased slots share one proxy keyed by id, proxies are built before the try, and reassigning an existing _modules key preserves insertion order — so nn.ModuleList indexing and ordering are intact on restore. Covered on both the success and exception paths by test_hide_modules_from_traversal_restores_aliases.
  • get_qdq_activations_from_prev_layer semantics match the per-layer loop in both directions (QDQ propagation when True, all decoder quantizers disabled via ExitStack so the tail sees FP when False), and the test pins the tail input to 1.0 / 2.0 accordingly.
  • Offloaded weight writeback is not a gap. The extra pass is not wrapped in persistent_materialization, but the weight-mutating algorithms (gptq, awq_lite, smoothquant, svdquant) each use enable_weight_access_and_writeback internally, so lm_head updates are not dropped on accelerate-offloaded models.
  • Compat blast radius is small. default_disabled_quantizers.yaml already disables *lm_head*, *output_layer*, embeddings, routers, and the vision branch, and MCore's discoverer folds output_layer into the layer list — so the new export_dir error only fires for custom recipes that deliberately enable an outside quantizer. Ordering the check before LayerwiseExporter construction (fail in seconds, not hours) matches the existing convention in this function.
  • Test coverage is genuinely targeted, not incidental: alias restoration on success and error, traversal-hiding assertions, QDQ-vs-FP tail input, decoder amax preservation, plain-list discovery, the no-op path, disk-warning gating across four device maps, and export_dir fail-closed with an assertion that no directory was created.

Risk: low-to-moderate. The mechanism is well-contained and well-tested, and the state-composition story checks out — nothing here can corrupt already-calibrated decoder quantizers. The residual risk is cost, not correctness: as written, a class of common weight-only recipes pays a full extra calibration pass, and loses layerwise export, for calibration work that needs no forward at all. Narrowing the predicate addresses both.

Nothing here duplicates CodeRabbit's pattern gate — no security anti-patterns, style, or typo findings.

Comment thread modelopt/torch/quantization/model_calib.py Outdated
Comment thread modelopt/torch/quantization/model_calib.py Outdated
@realAsma

realAsma commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

/claude review

Comment thread modelopt/torch/quantization/mode.py Outdated
Comment thread modelopt/torch/quantization/model_calib.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — layerwise non-decoder calibration (re-review at ed7b4d7d)

Scope: full review (trigger comment carried no scoping instructions). 4 files changed (453+/8-); reviewed all four: modelopt/torch/quantization/mode.py, model_calib.py, utils/layerwise_calib.py, tests/unit/torch/quantization/test_layerwise_calibrate.py. Because the diff changes a calibration config field's effective default, I also read quantization/config.py (MaxCalibConfig, _coerce_layerwise_input, _gptq_qdq_default), mode.py:get_modelike_from_algo_cfg, model_quant.py:quantize, export/layerwise_export.py (LayerwiseExporter.__init__ / finalize), and the Megatron/HF decoder-layer discoverers to judge blast radius.

Findings this round — CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 1

# Severity Where Issue
1 IMPORTANT Compatibility mode.py:229-230 model_fields_set is not a reliable "the user set this" signal here: get_modelike_from_algo_cfg dumps a config object without exclude_unset=True, so algorithm=mtq.MaxCalibConfig(layerwise={...}) marks every field set, the pop is skipped, and the same logical config raises ValueError under export_dir where the dict form works
2 SUGGESTION model_calib.py:2105 The skip_forward_without_activation_calib=True default is applied only to the non-decoder pass; the per-layer loop still replays every batch through every layer for a weight-only recipe — num_layers x the cost this commit just removed

Prior round: what is resolved

  • Resolved — the previous IMPORTANT (over-broad is_enabled gate forcing a full extra dataset pass for weight-only / dynamic / MX / constant-amax outside quantizers) is exactly what ed7b4d7d fixes. The predicate is now _needs_activation_forward_for_max_calib evaluated under _hide_modules_from_traversal, reusing the existing max_calibrate knob rather than inventing a new one, and test_layerwise_export_allows_weight_only_outside_quantizer / test_layerwise_max_offload_warning_matches_outside_forward pin both the forward count and the warning. It also correctly narrows the export_dir rejection so a weight-only lm_head no longer loses layerwise export.
  • Still open, non-blocking — the three prior SUGGESTIONs are unaddressed: rank-local has_enabled_outside_quantizer guarding max_calibrate's collectives (PP-stage asymmetry; low practical risk, since MCore folds output_layer into the discovered layer list so has_enabled_outside_quantizer is False there); _ForwardOnlyLayer duplicating _SkipLayer's __getattr__ and reaching into its _PROXY_BLOCKLIST; and the export error message not naming the offending quantizers. Finding 1 gives that last one extra weight — with an object-form config the message names lm_head when the actual trigger is a flag the user never wrote.

What I re-verified as correct

  • The export_dir rejection is well-founded, not over-strict. I initially read the gate on outside_calib_runs_forward (rather than outside_calib_needs_forward) as too conservative, but export_layer progressively replaces quantizer modules and leaves the decoder in export form, so any full-model forward after the loop would run over packed weights. Rejecting whenever a forward will run — including the explicit skip_forward_without_activation_calib=False case — is the right predicate, and the check sits before LayerwiseExporter.__init__ so its mkdir never fires (asserted by the tests).
  • The newly-allowed export path produces a consistent checkpoint. finalize() walks non-decoder modules through _dispatch_export_handler, so lm_head is packed from the amax the new pass just wrote, and _calibrate_outside_quantizers() runs before finalize() in both the normal and resume-complete branches. self._quant_config is snapshotted in __init__ before that amax exists, but get_quantization_format keys off is_enabled / num_bits / block_sizes and never _amax, so config.json and the tensors agree — no excluded-BF16-read-as-quantized mismatch.
  • outside_calib_needs_forward has a real use path (it feeds outside_calib_runs_forward), and evaluating the predicate at the top rather than at pass time is safe: it is structural, and the state that changes in between (_amax, SharedQuantState modules added inside layers) is either irrelevant to it or hidden.
  • Traversal hiding still holds. _original set via object.__setattr__ keeps it out of _modules, so enable_stats_collection / weight_only_quantize / finish_stats_collection / _needs_activation_forward_for_max_calib / SharedWeightGlobalAmaxState.attach — all named_modules() walkers — cannot re-touch decoder quantizers. Slots come from every parent's _modules, aliases share one proxy keyed by id, and both the Megatron (list(decoder.layers) + [output_layer]) and HF discoverers return modules that are registered children, so no target is silently missed.
  • get_qdq_activations_from_prev_layer semantics match the per-layer loop in both directions, and set_quantizer_by_cfg_context's preserve_quantizer_attributes_context restores decoder enable state on exit — the test asserts both the tail input value (1.0 vs 2.0) and decoder_quantizer.is_enabled afterwards.
  • The mode.py pop is harmless outside the layerwise path (whole-model max_calibrate already defaults to False) and safe for configs lacking the field (pop(..., None)); MseCalibConfig / LocalHessianCalibConfig do not inherit it.
  • modelopt_state is untouched — no schema, mode-registration, or public-API change, so no migration concern.

Risk: low. The mechanism is well-contained and the regression coverage is genuinely targeted rather than incidental. Finding 1 is a config-plumbing footgun with a real user-visible failure (a hard error under export_dir), not a numerical or state-corruption bug, and the fix is local — move the differing default into MaxCalibConfig instead of inferring intent from model_fields_set.

Nothing here duplicates CodeRabbit's gate — no security anti-patterns, style, or typo findings.

@realAsma

realAsma commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

/claude review

Comment thread modelopt/torch/quantization/model_calib.py Outdated
Comment thread modelopt/torch/quantization/utils/layerwise_calib.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — layerwise non-decoder calibration (re-review at 5ff8457e)

Scope: full review (the trigger comment carried no scoping instructions). 6 files changed (472+/17-); reviewed all six. Because the diff changes the effective default of a public calibration config field, I also read wrapped_calib_func / get_modelike_from_algo_cfg in mode.py, max_calibrate + _needs_activation_forward_for_max_calib in model_calib.py, _SkipLayer in layerwise_calib.py, the per-layer qdq_from_prev capture block, the config-signature handling in algorithms.py, and CHANGELOG.rst to judge blast radius. Note: the local checkout has origin/main ahead of the base of this PR, so I used gh pr diff rather than a two-dot diff — a two-dot diff here shows unrelated reversals.

Findings this round — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3

# Severity Where Issue
1 SUGGESTION model_calib.py:2100-2109 The None sentinel is resolved across three files, and this setdefault yields the inverse of the documented behavior if None survives into calib_kwargs. max_calibrate already treats None as falsy, so the mode.py pop is unnecessary — resolve the sentinel where it is consumed
2 SUGGESTION layerwise_calib.py:129-136 _hide_modules_from_traversal silently no-ops for targets that are not registered _modules children; that no-op degenerates the non-decoder-only pass into a full-model calibration forward and overwrites decoder amax, with no error or warning
3 SUGGESTION CHANGELOG.rst Not in the diff, so noted here rather than inline (see below)

Finding 3 detail. The PR marks the changelog N/A. That is defensible for the bug fix itself: the shipped recipes already disable *lm_head* / *output_layer* via default_disabled_quantizers.yaml, so the miscalibration only bit custom recipes that deliberately enabled an outside quantizer, which is a fair reading of "critical or known bugs from a previous release". What still seems worth documenting is the config change. MaxCalibConfig.skip_forward_without_activation_calib was introduced in 0.46.0 (released 2026-08-18) and that entry says "opt-in, default False", which this PR makes stale. Rather than editing the released entry, add one line under 0.48.0: the field now accepts None (the new default), which behaves as False for whole-model and per-layer calibration and as True for the generated layerwise non-decoder pass. The public type also widened from bool to bool | None, which is visible to anyone asserting on the value.

Prior rounds: what is resolved

  • Resolved — the previous IMPORTANT finding, that model_fields_set is not a reliable "the user set this" signal here, because get_modelike_from_algo_cfg dumps a config object without exclude_unset=True, so algorithm=mtq.MaxCalibConfig(layerwise={...}) raised ValueError under export_dir where the dict form worked. 5ff8457e replaces that intent inference with an explicit None sentinel checked by value, so an explicit None in a dict is handled identically to an omitted key. test_layerwise_export_allows_weight_only_outside_quantizer[algorithm_as_config=True] pins exactly the object-form config that used to fail — a good regression choice.
  • Resolved earlier — the over-broad is_enabled gate that forced a full extra dataset pass for weight-only / dynamic / MX / constant-amax outside quantizers.
  • Still open, non-blocking — the earlier SUGGESTIONs stand and I did not re-post them inline: rank-local has_enabled_outside_quantizer guarding the collectives in max_calibrate (a PP stage with no enabled outside quantizer skips _calibrate_outside_quantizers() entirely while another rank enters forward_loop and the amax all-reduce — a hang, not a slowdown; practical risk stays low because MCore folds output_layer into the discovered layer list, so this needs something like an enabled embedding quantizer on stage 0 only); _ForwardOnlyLayer duplicating the __getattr__ of _SkipLayer and reaching into its _PROXY_BLOCKLIST; the export error message not naming the offending quantizers; and the per-layer loop still replaying every cached batch through every layer for a weight-only recipe (num_layers x the cost this PR removes from the outside pass) because None resolves to False there by design.

What I verified as correct this round

  • The sentinel is genuinely well-contained in-tree. skip_forward_without_activation_calib is declared only on MaxCalibConfig, which has no subclasses, so it can only ever reach max_calibrate; _calib_func = max_calibrate at mode.py:435 is a plain function attribute, so calib_func is max_calibrate at model_calib.py:2101 is True — not a partial or staticmethod wrapper that would silently disable the whole optimization. The model_dump() at algorithms.py:1194 feeds a signature/stored config, not calib kwargs. Finding 1 is future-proofing, not a live bug.
  • No modelopt_state break. Old states carrying false re-validate against bool | None fine; the field is a calibration knob rather than restore-affecting state, and no mode registration, schema key, or public export changed.
  • The qdq_from_prev=False semantics match the per-layer loop exactly. The extra pass uses set_quantizer_by_cfg_context(layer, [{"quantizer_name": "*", "enable": False}]) — the same pattern, weight quantizers included, that model_calib.py:2218-2223 uses when capturing next-layer inputs. Disabling every decoder layer is the right analogue of disabling the one current layer in a full-model forward, and set_quantizer_by_cfg_context operates on the original layer objects, so traversal hiding does not interfere. The test pins the tail input to 1.0 / 2.0 and asserts decoder_quantizer.is_enabled afterwards.
  • Ordering is right in both export branches. The ValueError at 2111 fires before LayerwiseExporter(model, export_dir) at 2149, so no directory is created (asserted). _calibrate_outside_quantizers() runs before finalize() in both the resume-complete early return and the normal tail, and always outside the hiding context — so finalize() walks the real modules and packs lm_head from the amax the pass just wrote. test_layerwise_export_completed_resume_calibrates_weight_only_tail asserts forward_calls == 0 together with a non-None tail amax, which is the right assertion pair for that branch.
  • Traversal hiding still holds, and forward fidelity is preserved. _original set via object.__setattr__ never enters _modules, so enable_stats_collection, weight_only_quantize, finish_stats_collection, _needs_activation_forward_for_max_calib, and SharedWeightGlobalAmaxState.attach — all named_modules() walkers — cannot re-touch decoder quantizers, and the test asserts decoder _amax is unchanged across the extra pass. forward calls self._original(...), i.e. type(original).__call__, so the _hf_hook-wrapped forward from accelerate and the HF GradientCheckpointingLayer.__call__ override both still run; keeping _hf_hook / _old_forward on the blocklist is correct here for the same reason as in _SkipLayer — those hooks live on the original, and the parameter-free proxy must not be managed.
  • Alias and slot restoration. Slots come from the _modules of every parent, aliased slots share one proxy keyed by id, proxies are built before the try, and reassigning an existing _modules key preserves insertion order, so nn.ModuleList indexing survives. Covered on both the success and exception paths.

Risk: low. The mechanism is well-contained, the state-composition story checks out (nothing here can corrupt already-calibrated decoder quantizers), and the regression coverage is targeted rather than incidental. Both code findings this round harden invariants that hold today rather than fixing live defects — approving.

Nothing here duplicates the CodeRabbit gate: no security anti-patterns, style, or typo findings.

🤖 Generated with Claude Code

Comment thread modelopt/torch/quantization/model_calib.py
Signed-off-by: realAsma <akuriparambi@nvidia.com>
Signed-off-by: realAsma <akuriparambi@nvidia.com>
Signed-off-by: realAsma <akuriparambi@nvidia.com>
Signed-off-by: realAsma <akuriparambi@nvidia.com>
@realAsma
realAsma force-pushed the asma/layerwise-lm-head branch from 5ff8457 to a776897 Compare September 10, 2026 18:42
@realAsma

Copy link
Copy Markdown
Contributor Author

/claude review

Comment on lines +172 to +189
decoder_owned_ids = {
id(module) for layer in transformer_layers for module in layer.modules()
}
self.enabled = any(
isinstance(module, TensorQuantizer)
and module.is_enabled
and id(module) not in decoder_owned_ids
for module in model.modules()
)
self.runs_forward = self.enabled
if self.enabled and activation_forward_predicate is not None:
with _hide_modules_from_traversal(model, transformer_layers):
needs_forward = activation_forward_predicate(model)
self.calib_kwargs = dict(calib_kwargs)
self.calib_kwargs.setdefault("skip_forward_without_activation_calib", True)
self.runs_forward = (
needs_forward or not self.calib_kwargs["skip_forward_without_activation_calib"]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Algorithm] The detection treats every enabled non-decoder quantizer as a downstream tail, but the calibration pass is only correct for quantizers that sit after the decoder stack.

Consider a recipe that also enables quantizers upstream of the decoder — a quantized embed_tokens, a multimodal projector / vision tower, or an input projection. Two things go wrong:

  1. Ordering. Those quantizers are live during the entire per-layer calibration loop, but they have no _amax yet (enable_stats_collection is only ever called on the current layer). With _amax unregistered, TensorQuantizer.forward falls through to the dynamic path (cf. extra_repr: if not hasattr(self, "_amax"): return "dynamic"), so every decoder layer is calibrated against dynamic-amax QDQ activations, and then the upstream quantizer is switched to a static amax at the very end. The decoder amaxes no longer match the activations inference will produce.
  2. get_qdq_activations_from_prev_layer=False semantics. The stated contract is "downstream modules receive FP activations". calibrate() honours that for the tail pass by disabling decoder quantizers, but nothing disables upstream outside quantizers during the decoder loop — so decoder inputs already carry QDQ error while the tail sees FP. The two directions are handled inconsistently.

Since lm_head is the motivating case, the cheapest correct fix is to scope this explicitly rather than to "all non-decoder quantizers": either (a) disable all non-decoder quantizers for the duration of the decoder loop, so decoder calibration is against a clean FP prefix and the tail pass is the only place they are active, or (b) partition outside quantizers into pre-/post-decoder by module order and only defer the post-decoder ones, raising/warning for pre-decoder ones. Either way, please state the assumption in the class docstring.

Comment on lines +2097 to +2101
if export_dir is not None and outside_calibrator.runs_forward:
raise ValueError(
"Layerwise export does not support enabled quantizers outside transformer layers. "
"Calibrate without export_dir, then export the completed model separately."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] This rejects on runs_forward, not on "enabled quantizers outside transformer layers", and the two diverge in a way that breaks a previously working configuration.

runs_forward = needs_forward or not skip_forward_without_activation_calib. So for a weight-only outside quantizer (needs_forward=False), a user who explicitly sets skip_forward_without_activation_calib=False — the documented thing to do when the forward_loop has side effects the caller relies on, e.g. materializing ZeRO-3 shards — now gets a hard ValueError for a export_dir run that worked before this PR. test_layerwise_export_rejects_explicit_outside_forward codifies exactly that case, while test_layerwise_export_allows_weight_only_outside_quantizer allows the identical model with the default. Nothing about the export path actually got worse because the user asked not to skip an unnecessary forward.

Two things to fix:

  1. Gate on the real blocker — an outside quantizer that genuinely needs activation data — rather than on the skip_* flag. Something like outside_calibrator.needs_activation_forward (i.e. the predicate result, ignoring the skip flag) keeps the fail-closed guarantee for the case the PR description is about (uncalibrated activation quantizers in the tail) without regressing weight-only + export.
  2. Make the message name the condition it actually checks. As written it claims outside quantizers are unsupported, which the passing test above contradicts — and it will send users hunting for a quantizer to disable when the fix is to drop skip_forward_without_activation_calib=False.

Also worth reconciling the PR description ("Layerwise export_dir now fails early when enabled non-decoder quantizers are present") with the code, which deliberately permits the weight-only case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Addressed in dfde796 with the conservative export contract: layerwise export now rejects whenever any enabled outside quantizer requires the post-layer calibration step, including weight-only cases. The tests and PR description now consistently reflect that condition.

Comment on lines +196 to +218
if self.runs_forward and has_accelerate_offload(self.model):
warn_rank_0(
"Layerwise calibration found enabled quantizers outside transformer layers. "
"The required full-model calibration pass may be slow because CPU- or "
"disk-offloaded decoder weights can be transferred for every batch."
)

with _hide_modules_from_traversal(self.model, self.transformer_layers):
if self.qdq_from_prev:
self.calib_func(self.model, self.forward_loop, **self.calib_kwargs)
return

# Inline import breaks conversion -> utils -> layerwise_calib import cycle.
from ..conversion import set_quantizer_by_cfg_context

with ExitStack() as stack:
for layer in self.transformer_layers:
stack.enter_context(
set_quantizer_by_cfg_context(
layer, [{"quantizer_name": "*", "enable": False}]
)
)
self.calib_func(self.model, self.forward_loop, **self.calib_kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Performance] The warning ("may be slow") understates what happens for every calib_func other than max_calibrate, and it undercuts the reason layerwise calibration exists ("performant layerwise calibration for large models that don't fit on GPU", per the CHANGELOG).

self.calib_func(self.model, self.forward_loop, ...) re-runs the entire algorithm on the whole model, not just an amax collection:

Peak memory is the bigger issue than wall-clock. The per-layer loop keeps roughly two decoder layers resident (_SkipLayer frees the rest, and the meta-device placeholders were added specifically for this). This pass needs every decoder layer live and executing simultaneously, so a model that only fits under layerwise now has to fit — or thrash offload — for one extra full pass. For DeepSeek-R1 / Kimi-K2 class models that is the difference between "runs" and "does not".

Suggestions, in order of preference:

  1. Restrict the outside pass to the amax-only calib funcs (max, and mse/local_hessian if they are cheap enough), and for gptq/awq_lite/svdquant either run plain max_calibrate on the outside quantizers instead of the full algorithm, or raise with a pointer to calibrating the tail separately. lm_head almost never wants GPTQ/AWQ treatment anyway.
  2. If the full algorithm is intentional, say so in the warning — name the number of extra full-model forwards and the fact that all decoder weights must be resident — and gate the warning on more than has_accelerate_offload, since a fully resident model can OOM here too.

Comment thread modelopt/torch/quantization/config.py Outdated
Comment on lines 931 to 946
@@ -938,10 +938,10 @@ class MaxCalibConfig(_SharedStatesConfig, QuantizeAlgorithmConfig):
"dynamic, or MX (MXFP4/MXFP8) quantization. Weight calibration still runs on the "
"weight tensors directly, so the quantized weights are unchanged; only the wasted "
"forward is avoided. "
"Opt-in (default False) because the provided ``forward_loop`` can carry side "
"effects the caller relies on — most notably materializing sharded parameters under "
"DeepSpeed ZeRO-3 — so enable it per-recipe when the calibration data is known to be "
"unnecessary."
"Set False when the provided ``forward_loop`` carries side effects the caller relies "
"on — most notably materializing sharded parameters under DeepSpeed ZeRO-3. The "
"default None behaves as False for whole-model and per-layer calibration, and as True "
"for ModelOpt's generated layerwise non-decoder pass."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] skip_forward_without_activation_calib is a released public config field (CHANGELOG.rst:129, in a shipped section, documented as "opt-in, default False"). Widening it to bool | None and moving the default to None is an observable public-API change with no changelog entry, and the PR checklist marks CHANGELOG as N/A.

Two concrete consequences:

  1. mtq.MaxCalibConfig().skip_forward_without_activation_calib now returns None where it returned False. Truthiness checks survive; anything that round-trips the value through a bool-typed field, a is False comparison, or an external schema does not.
  2. modelopt_state written by this version stores null for the key. An older ModelOpt (where the annotation is bool) will fail pydantic validation on restore() instead of loading. This repo treats that class of change as noteworthy — see the layerwise legacy-key entry at CHANGELOG.rst:78, which explicitly documents a restore-validation break.

Per CLAUDE.md, backward-breaking changes get a CHANGELOG.rst entry; please add a one-or-two-sentence one under the unreleased Backward Breaking Changes section stating the new tri-state default and that checkpoints saved with it do not restore on older releases.

Separately on the mechanism: the None → effective-default resolution is now spread across three files — the None default here, the kwargs.pop() in mode.py:229, and self.calib_kwargs.setdefault(..., True) in layerwise_calib.py:186. Popping the key in wrapped_calib_func means the sentinel is resolved by absence, so max_calibrate's own signature default silently becomes the source of truth and any future caller that forwards config.model_dump() without the pop gets skip_forward_without_activation_calib=None passed straight through. Resolving None in one place (a validator here, or at the top of max_calibrate) would keep the tri-state without that coupling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Addressed in dfde796: the released bool field and False default are restored. model_fields_set distinguishes omission from an explicit False internally, preserving the serialized schema and direct-call behavior.

Comment on lines +107 to +127
class _ForwardOnlyLayer(nn.Module):
"""Hide a layer from module traversal while preserving its forward execution."""

_PROXY_BLOCKLIST = _SkipLayer._PROXY_BLOCKLIST

def __init__(self, original: nn.Module):
super().__init__()
object.__setattr__(self, "_original", original)

def __getattr__(self, name: str):
try:
return super().__getattr__(name)
except AttributeError:
if name in self._PROXY_BLOCKLIST:
raise
return getattr(object.__getattribute__(self, "_original"), name)

def forward(self, *args, **kwargs):
return self._original(*args, **kwargs)


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The docstring says "hide a layer from module traversal", which undersells the blast radius. Because _original is set with object.__setattr__, the proxy has an empty _modules/_parameters, so for the duration of the context the model also loses the decoder layers from named_parameters(), parameters(), buffers(), state_dict(), and therefore from .to(), .cuda(), FSDP/DTensor parameter collection, and any next(model.parameters()).device idiom.

That is fine for the max_calibrate path this PR exercises, but the outside pass hands the model to arbitrary calib_funcs and to the user's forward_loop. A user forward_loop that does inputs.to(next(model.parameters()).device) will now pick the device of whatever non-decoder parameter happens to come first, and an FSDP2-wrapped model will see a parameter set that does not match its flat-param groups.

Please widen the docstring to say explicitly that parameters, buffers and state_dict are hidden too — not only modules() — so the next person reasoning about a new calib_func knows what they are working with. Worth noting the same for _OutsideQuantizerCalibrator.calibrate, since that is where the model is exposed to caller code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Addressed in dfde796: the traversal helper and outside-calibrator docstrings now state that modules, parameters, buffers, and state-dict traversal are hidden during the outside pass.

Comment on lines +181 to +189
self.runs_forward = self.enabled
if self.enabled and activation_forward_predicate is not None:
with _hide_modules_from_traversal(model, transformer_layers):
needs_forward = activation_forward_predicate(model)
self.calib_kwargs = dict(calib_kwargs)
self.calib_kwargs.setdefault("skip_forward_without_activation_calib", True)
self.runs_forward = (
needs_forward or not self.calib_kwargs["skip_forward_without_activation_calib"]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] enabled / runs_forward are derived purely from the local module tree, so they can diverge across ranks whose structure is not homogeneous. Under pipeline parallelism only the last stage owns lm_head / output_layer, so only that rank would take the calibrate() branch — and then call forward_loop(model) and max_calibrate(..., distributed_sync=True) (which all-reduces amax) while the other ranks have already returned. That is a hang, not a wrong number.

Layerwise calibration may already be single-stage-only in practice (the per-layer loop drives forward_loop a different number of times per rank when layer counts differ), in which case this is pre-existing and only needs a note. If layerwise is meant to work under PP, an dist.allreduce/any-across-ranks on enabled and runs_forward before acting on them would make the decision collective and keep the pass symmetric.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — Layerwise non-decoder calibration

Scope: trigger comment was a bare /claude review, so this is a full review. All 6 changed files (508+/12-) were opened: modelopt/torch/quantization/{config.py, mode.py, model_calib.py, utils/layerwise_calib.py} plus both test files. Surrounding context read for max_calibrate / _needs_activation_forward_for_max_calib, TensorQuantizer.forward, _SkipLayer / LayerActivationCollector patch-unpatch, _CheckpointState.full_restore, gptq, and wrapped_calib_func.

Findings

CRITICAL: 0 · IMPORTANT: 3 · SUGGESTION: 2

The core mechanism is sound. I specifically checked and could not find problems in the places most likely to break:

  • _hide_modules_from_traversal alias handling — slots are collected per (parent, child_name) and one proxy is shared per child id, so multi-slot aliases restore correctly, and the finally covers the raising path.
  • Decoder amax preservation — enable_stats_collection / finish_stats_collection / the MoE amax sync / _check_nvfp4_static_tp_supported all traverse named_modules(), so hidden decoder quantizers keep their calibrated _amax.
  • Accelerate offload — self._original(...) goes through the original module's __call__, so its _hf_hook still fires and offloaded weights are still fetched; _PROXY_BLOCKLIST correctly keeps accelerate off the parameter-free proxy. has_accelerate_offload is called before hiding, which is the only ordering that works.
  • Object identity across the run — _cleanup_layers restores the original layer objects and full_restore mutates in place, so self.transformer_layers is never stale by the time calibrate() hides slots.
  • No recursion risk: gptq does not re-read layerwise.enable off the model.
  • Ordering of the _reconcile_export_with_resume early return correctly calibrates the tail before returning, so a completed-resume finalize() no longer writes an uncalibrated tail shard.

Most impactful

  1. Upstream outside quantizers are mis-scoped (layerwise_calib.py:172-189). The detection catches every enabled non-decoder quantizer, but the deferred pass is only correct for ones downstream of the decoder. A quantized embed_tokens / projector runs with _amax unregistered — i.e. on the dynamic path — through the whole per-layer loop, so decoder amaxes are calibrated against activations that differ from inference. It also breaks the get_qdq_activations_from_prev_layer=False contract asymmetrically: decoder quantizers are disabled for the tail pass, but upstream outside quantizers are never disabled for the decoder loop.

  2. export_dir gate keys off the wrong condition (model_calib.py:2097-2101). It rejects on runs_forward, so skip_forward_without_activation_calib=False + a weight-only lm_head now raises even though that combination is fine — and the message blames "enabled quantizers outside transformer layers", which the passing test_layerwise_export_allows_weight_only_outside_quantizer contradicts. This is a regression for the documented ZeRO-3 use of that flag.

  3. The outside pass re-runs the whole algorithm, not just amax collection (layerwise_calib.py:196-218). For gptq that is two extra full-model forwards plus Hessian accumulation; for awq_lite/svdquant an alpha search plus module replacement. Peak memory is the real cost: the per-layer loop keeps ~2 decoder layers resident by design (_SkipLayer + meta placeholders), and this pass needs all of them live at once. For the DeepSeek-R1 / Kimi-K2 class of model that layerwise exists to serve, that can turn "runs" into "OOMs", and the warning only says "may be slow".

Plus two SUGGESTIONs: the _ForwardOnlyLayer docstring understates that parameters/buffers/state_dict are hidden as well as modules() (matters because arbitrary calib_funcs and the user's forward_loop see that model), and enabled/runs_forward are rank-local so they diverge under pipeline parallelism where only the last stage owns lm_head.

Risk

Moderate. The change is well contained and the test coverage is genuinely good — alias restore on success and error, QDQ-vs-FP propagation, decoder amax preservation, offload-warning gating, and the resume/export matrix are all exercised. The residual risk is concentrated in the "all non-decoder quantizers" scoping: for the lm_head case this PR is aimed at, it does the right thing, but the same code path is what a quantized embedding or a multimodal projector will hit, and there it is silently wrong rather than loudly unsupported. Finding 2 is a small, mechanical fix; findings 1 and 3 are mostly about narrowing the contract and saying so.

🤖 Generated with Claude Code

Comment thread modelopt/torch/quantization/config.py Outdated
Comment thread modelopt/torch/quantization/model_calib.py Outdated
decoder_owned_ids = {
id(module) for layer in transformer_layers for module in layer.modules()
}
self.enabled = any(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

can we move this self.enabled inside _hide_modules_from_traversal ? this way way we can simplify this code further and avoid iteration over decoder layer quantizers. Further, can we cache the name to module map during init, then _hide_modules_from_traversal can simply look a decoder modules parent name fro the name to module map. this way multiple _hide_modules_from_traversal does not iterate over the whole model.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Implemented in dfde796: the calibrator now caches unique physical decoder registration slots once, computes enabled while those slots are hidden, and preserves direct plus aliased-parent registrations with shared-proxy restoration. Focused success/error coverage was added.

Signed-off-by: realAsma <akuriparambi@nvidia.com>
@realAsma

Copy link
Copy Markdown
Contributor Author

/claude review

Comment thread modelopt/torch/quantization/mode.py Outdated
So lets wrap them to be compatible with the ModelOpt convert entrypoint.
"""
kwargs = config.model_dump()
if "skip_forward_without_activation_calib" not in config.model_fields_set:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why is this needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Removed this in 76106e2. With both the config field and max_calibrate defaulting to False, the conditional was redundant; I also removed the corresponding model_fields_set test assertions.

Comment thread modelopt/torch/quantization/mode.py Outdated
Comment on lines +229 to +230
if "skip_forward_without_activation_calib" not in config.model_fields_set:
kwargs.pop("skip_forward_without_activation_calib", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This model_fields_set pop has no behavioral effect — it looks like a leftover from the earlier None-sentinel revision.

skip_forward_without_activation_calib is declared only on MaxCalibConfig (config.py:931, no subclasses), whose _calib_func is max_calibrate, and max_calibrate's own default for that parameter is already False (model_calib.py:321). So:

  • dict form ({"method": "max", ...}) → field not in model_fields_set → popped → max_calibrate uses its default False
  • object form (mtq.MaxCalibConfig(...)) → every field is in model_fields_set → not popped → max_calibrate receives False explicitly

Both paths end at False, and nothing downstream inspects calib_kwargs keys (layerwise_calibrate forwards the rest verbatim, and _OutsideQuantizerCalibrator.calibrate passes **self.calib_kwargs unchanged). Your own new test agrees: test_layerwise_max_outside_calibration_uses_configured_forward_behavior expects None and False to produce the same 2 forward calls.

Why it matters: it reads as load-bearing intent inference, and model_fields_set is an unreliable "the user set this" signal here anyway (the object form marks all fields set), so a future change that does branch on presence will silently behave differently for object vs. dict configs. The paired test_calib.py assertions then pin pydantic bookkeeping rather than any behavior.

Suggestion: drop these two lines (and the two model_fields_set assertions in test_calib.py). If a different default for the generated non-decoder pass is wanted later, set it explicitly at the consumption site in _OutsideQuantizerCalibrator.calibrate() rather than inferring it from field-set state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Addressed in 76106e237: removed the redundant field-set branch and the bookkeeping-only assertions. Both config paths now rely on the existing False default.

if exporter is not None and _reconcile_export_with_resume(
exporter, checkpoint_dir, start_layer, num_layers
):
outside_calibrator.calibrate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This call is unreachable-as-effective and can be dropped.

The branch is guarded by exporter is not None, and exporter is only assigned inside if export_dir is not None: (line 2106). But line 2096 now raises whenever export_dir is not None and outside_calibrator.enabled. So reaching line 2132 implies export_dir is not None implies outside_calibrator.enabled is False, and calibrate() returns immediately on if not self.enabled.

It was meaningful in the previous revision, where layerwise export allowed a weight-only outside quantizer and the completed-resume path had to calibrate the tail before finalize(). With the simplified unconditional rejection, it is dead.

Why it matters: it implies the resume-complete export path still calibrates something, which invites a future reader to assume outside quantizers are handled under export_dir — the opposite of what line 2096 enforces. Either remove the call, or keep it and narrow the line-2096 gate back to "the outside pass would actually run a forward" so the call becomes live again for the weight-only case.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — layerwise non-decoder calibration (re-review at dfde7960)

Scope: the trigger comment was a bare /claude review, so this is a full review. All 5 changed files (419+/4-) were opened: modelopt/torch/quantization/{mode.py, model_calib.py, utils/layerwise_calib.py} plus tests/unit/torch/quantization/{test_calib.py, test_layerwise_calibrate.py}. For surrounding context I read max_calibrate / _needs_activation_forward_for_max_calib, MaxCalibConfig and the sibling *CalibConfig classes in config.py, wrapped_calib_func and MaxCalibrateModeDescriptor, _SkipLayer / get_input_activations / get_first_layer_inputs in layerwise_calib.py, set_quantizer_by_cfg_context + preserve_quantizer_attributes_context, and utils/core_utils.py (to check the new module-level import).

Method note: this branch is a single commit on top of 7f7c46d8 and the two-dot diff against origin/main matches gh pr diff --name-only exactly, so the two-dot diff is authoritative this round. I was not able to run the test suite here (command approval denied), so test assertions below are reviewed by reading, not by execution.

Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3

# Severity Where Issue
1 SUGGESTION mode.py:229-230 The model_fields_set pop is a behavioral no-op left over from the reverted None-sentinel revision; the paired test_calib.py assertions pin pydantic bookkeeping rather than behavior
2 SUGGESTION model_calib.py:2132 outside_calibrator.calibrate() in the completed-resume export branch is unreachable-as-effective: that branch implies export_dir is not None, which line 2096 guarantees implies not enabled
3 SUGGESTION layerwise_calib.py:107-136 Not posted inline (overlaps a prior round). Beyond modules() / named_parameters() / state_dict(), hiding also blocks mutation propagation: model.eval() / model.train() / model.to() / model.apply() called by an arbitrary calib_func or by the user forward_loop during the pass silently skip the decoder layers, and attribute writes land on a throwaway proxy. A forward_loop that toggles model.eval() internally leaves decoder layers in whatever mode they were in — dropout-active if the model was in train mode. Worth a sentence in the _ForwardOnlyLayer docstring, and arguably an eval-mode assert before the pass

Both inline findings are the same shape: vestigial code from the earlier revision that now reads as load-bearing. Neither changes behavior today, which is why they are SUGGESTIONs.

What I verified as correct this round

  • The new import is cycle-free. utils/core_utils.py imports only quantization.config and modelopt.torch.utils, so the new module-level from .core_utils import has_accelerate_offload does not reintroduce the nn -> qtensor -> utils -> layerwise_calib cycle the inline imports exist to break; ..nn and ..conversion are correctly kept function-local.
  • Slot discovery and restoration. Slots are collected as (parent, child_name, child) over every parent _modules dict, aliased slots share one proxy keyed by id, proxies are built before the try, and reassigning an existing _modules key preserves insertion order — so nn.ModuleList indexing survives. Restoration is in finally, covered on both the success and raising paths by test_outside_calibrator_hides_and_restores_layer_aliases.
  • Traversal hiding actually hides. _original is set via object.__setattr__ so it never lands in _modules, and the proxy own _modules is empty. Every named_modules() walker in the deferred pass — enable_stats_collection, weight_only_quantize, finish_stats_collection, the MoE amax sync, _check_nvfp4_static_tp_supported, SharedWeightGlobalAmaxState.attach, and the distributed_sync amax reduction — therefore cannot re-touch decoder quantizers, and the test asserts decoder _amax is unchanged across the extra pass.
  • Forward fidelity. forward calls self._original(...), i.e. type(original).__call__, so accelerate _hf_hook-wrapped forwards and the HF GradientCheckpointingLayer.__call__ override both still run; keeping _hf_hook / _old_forward on _PROXY_BLOCKLIST correctly keeps accelerate from managing the parameter-free proxy. has_accelerate_offload(self.model) is evaluated before hiding, which is the only ordering that works, and the warning fires lazily inside the wrapper so it is not emitted when max_calibrate skips the forward — exactly what the parametrized warning tests pin.
  • qdq_from_prev semantics mirror the per-layer loop. The False path disables every decoder quantizer via set_quantizer_by_cfg_context(layer, [{"quantizer_name": "*", "enable": False}]) — the same pattern, weight quantizers included, used at model_calib.py:2218-2223 when capturing next-layer inputs — so the tail sees FP activations; the True path leaves them enabled so the tail sees QDQ error. That context operates on the original layer objects, so hiding does not interfere, and preserve_quantizer_attributes_context restores enable state on exit (asserted).
  • Downstream ordering is sound for the lm_head case this PR targets. get_input_activations / cache_outputs_for_next_layer_calib early-stop via _EarlyStopForwardError at the target decoder layer, so lm_head is never reached during the per-layer loop — it only ever runs in the deferred full-model pass, with the decoder already calibrated. That pass runs after _unpatch_all_layers() and after ckpt.full_restore(...), and always outside the hiding context, so nothing sees a patched or proxied model afterwards.
  • Config plumbing is contained. skip_forward_without_activation_calib is declared only on MaxCalibConfig, which has no subclasses (NVFP4ActHeadroomCalibConfig, MseCalibConfig, LocalHessianCalibConfig all derive from QuantizeAlgorithmConfig directly), so the mode.py pop can neither reach a calib func that would TypeError on the kwarg nor change any default.
  • No mode/state or public-API change. modelopt_state schema, mode registration, and __init__ exports are untouched, so there is no migration concern; the changelog being N/A is defensible since the shipped recipes already disable the affected quantizers.

Residual risk and prior rounds — these are unchanged in substance, so I did not re-post them:

  • Non-decoder scoping is still uniform for upstream and downstream quantizers. A quantized embedding or VLM projector is upstream of the decoder, so it runs uncalibrated (the dynamic amax path, since _amax is unregistered) throughout the per-layer loop and only gets a static amax afterwards — decoder amaxes are then calibrated against activations that differ slightly from inference. Second-order numerically, and not a regression relative to main (where those quantizers were never calibrated at all), but the contract is worth stating in the docstring.
  • The deferred pass needs every decoder layer resident at once, the opposite of the ~2-layers-resident invariant layerwise exists to provide; the warning says "may be slow" rather than "may OOM".
  • enabled is rank-local, so a PP stage with no enabled non-decoder quantizer skips the pass entirely while another stage enters forward_loop and the amax reduction. Practical risk stays low because MCore folds output_layer into the discovered layer list.
  • Blast radius of the new export_dir rejection. With the gate back to plain is_enabled, export_dir plus any enabled non-decoder quantizer is now a hard ValueError, including the weight-only lm_head case the previous revision allowed and which needs no forward at all. Shipped recipes are unaffected (default_disabled_quantizers.yaml disables lm_head, output_layer, embeddings, routers and the vision branch), so this only bites custom configs — e.g. a plain FP8_DEFAULT_CFG on a VLM, where projector / vision-tower Linears are enabled by default and layerwise export would now fail where it previously ran with a silently uncalibrated quantizer. The PR body states fail-early as the intent and the message is actionable, so I read this as a deliberate trade-off rather than a defect — noting it so the choice is explicit.

Risk: low. The mechanism is well contained, the state-composition story checks out (nothing here can corrupt already-calibrated decoder quantizers), and the regression coverage is targeted rather than incidental — alias restore on success and error, traversal-hiding assertions, QDQ-vs-FP tail input, decoder amax preservation, offload-warning gating, the skip-forward count matrix, and export fail-closed with an assertion that no directory was created. Both code findings are cleanups of vestigial code rather than live defects — approving.

Nothing here duplicates the CodeRabbit gate: no security anti-patterns, style, or typo findings.

🤖 Generated with Claude Code

Signed-off-by: realAsma <akuriparambi@nvidia.com>

@cjluo-nv cjluo-nv 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 (claude-opus-5) — DM the bot to share feedback.

Logic looks correct and CPU test coverage is genuinely thorough, but a few things need an owner's call before merge — most notably an unreachable calibrate() call and no offload-path test for the new full-model pass.

Needs action:

  • Remove or comment the outside_calibrator.calibrate() added in the export-resume early-return in model_calib.py: that branch is only reachable when export_dir is not None, which the new ValueError already guarantees enabled is False, so the call can never do work.
  • Add a GPU test in tests/gpu/torch/quantization/plugins/test_accelerate_gpu.py exercising the new pass on a CPU/disk-offloaded model — _hide_modules_from_traversal swaps parent._modules under live accelerate hooks and only the has_accelerate_offload flag is currently monkeypatched.
  • Confirm the new hard ValueError for layerwise export + enabled outside quantizers is acceptable as a silent break for existing configs, and add a CHANGELOG line if so.
  • Confirm the extra full-model forward should run by default even when the outside quantizers are weight-only (see test_layerwise_max_outside_calibration_uses_configured_forward_behavior, expected_forward_calls == 2).
  • Revert the unrelated cosmetic edit in tests/unit/torch/quantization/test_calib.py.

if exporter is not None and _reconcile_export_with_resume(
exporter, checkpoint_dir, start_layer, num_layers
):
outside_calibrator.calibrate()

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.

This call is unreachable in effect: exporter is non-None only when export_dir is not None, and the new check above raises whenever export_dir is not None and outside_calibrator.enabled. So enabled is always False here and calibrate() returns immediately. Drop the line, or add a comment saying why it's kept defensively.

self.enabled = any(
isinstance(module, TensorQuantizer) and module.is_enabled
for module in model.modules()
)

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.

The new pass replaces entries in parent._modules while accelerate/FSDP hooks are live on the real layers. The CPU tests only monkeypatch has_accelerate_offload, so the proxy is never exercised against an actual _hf_hook/AlignDevicesHook layout. tests/gpu/torch/quantization/plugins/test_accelerate_gpu.py already has make_cpu_offloaded_model and disk-offload helpers — please add a case there with an enabled lm_head quantizer.

def test_max_calib_config_skip_is_opt_in():
"""The flag is opt-in (default False) so it does not change behavior for direct callers."""
assert MaxCalibConfig().skip_forward_without_activation_calib is False
default_config = MaxCalibConfig()

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.

This is unrelated churn — hoisting MaxCalibConfig() into default_config doesn't change the assertion or the test. Please revert to keep the diff focused.

@realAsma
realAsma marked this pull request as ready for review September 11, 2026 17:21
@realAsma
realAsma requested review from a team as code owners September 11, 2026 17:21
@Fridah-nv

Copy link
Copy Markdown
Contributor

Is this feature mainly targeting lm_head? For lm_head's case I think there's an optimization to take the last layer's output and feed into the rest of the modules. This saves a full model forward that can be expensive for a offloaded model.
Structurally this also enables VLM and MTP calibration (but practically there are still blockers) the shortcut above might not work for them

)


class _ForwardOnlyLayer(nn.Module):

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.

Do you think it's better to reuse _SkipLayer here? The proxy machinery can be reused and we need a new forward function for this feature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

BB: This is a good point, can you give a minimal design for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

🐝 I’m taking a look and will follow up with a minimal design.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

Minimal design: extract only the shared proxy mechanics into a small _LayerProxy(nn.Module) base. Its __init__ stores _original without registering it, and its __getattr__ applies the existing hook blocklist before forwarding to _original. Then keep _SkipLayer(_LayerProxy) responsible for skip state plus the zero/meta forward, and _ForwardOnlyLayer(_LayerProxy) responsible only for delegating forward to _original. That reuses the machinery without making the forward-preserving case inherit skip semantics. I’d cover the base behavior through the existing alias/blocklist tests rather than add another abstraction or callback strategy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can we have a simpler design -> Just add the forward with hide module and without any meta data manipulation support as well to skip layer ? then for outside we use forward with hide module and without any meta data

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

BB: PTAL #2339 (comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

🐝

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Bot comment.

I read this as folding the two wrappers into _SkipLayer: keep the current metadata-backed zero-output mode for calibrated layers, and add a forward-through-original mode that hides registered modules without creating _LayerCalibState. _hide_modules_from_traversal would use the latter, so _ForwardOnlyLayer disappears. Please confirm that is the intended shape and I’ll implement it.

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.

3 participants