Skip to content

PTQ reliability fixes: checkpoint resume + exclude_modules sentinel corruption - #2129

Open
wyattearp wants to merge 14 commits into
NVIDIA:mainfrom
wyattearp:ptq-reliability-fixes
Open

PTQ reliability fixes: checkpoint resume + exclude_modules sentinel corruption#2129
wyattearp wants to merge 14 commits into
NVIDIA:mainfrom
wyattearp:ptq-reliability-fixes

Conversation

@wyattearp

@wyattearp wyattearp commented Aug 10, 2026

Copy link
Copy Markdown

Summary

Three fixes found while quantizing nvidia/Nemotron-Cascade-2-30B-A3B (NemotronH hybrid Mamba-Attention MoE) to NVFP4 via hf_ptq.py.

  1. examples/hf_ptq/hf_ptq.py: adds --save_quantized_state/--restore_quantized_state. Lets a failed export step be retried without redoing calibration (calibration on a 30B MoE is the expensive part; export/serialization bugs shouldn't force re-running it).

  2. modelopt/torch/utils/dataset_utils.py: adds an optional checkpoint_every/checkpoint_fn hook to _forward_loop/create_forward_loop, so long calibration runs can checkpoint periodically. (Not yet wired into hf_ptq.py's calibration call — that's a natural follow-up, intentionally left out of this PR to keep it scoped.)

  3. modelopt/torch/export/quant_aware_conversion.py: build_reverse_name_mapper's sentinel-strip logic (_map) silently accepted a mangled sentinel instead of raising. In our export, a reverse-rename rule matched past its intended boundary and corrupted the appended sentinel before it could be stripped cleanly (observed output was an unrelated, unsubstituted placeholder token, not the real sentinel — direct evidence of a rule mismatch, not a calibration gap). Effect: 100% of exclude_modules entries (77/77) were corrupted in our exported config.json. vLLM then couldn't match any excluded layer against its weight-loading logic, defaulted every quant-aware fused linear (e.g. Mamba's in_proj, a MergedColumnParallelLinear) to quantized-width allocation, and crashed loading the correctly-unquantized-but-mislabeled weight — a bare AssertionError deep in vLLM with no useful diagnostic pointing back at the real cause.

    Fix extracts the strip logic into _strip_sentinel_or_raise(), which now raises QuantConversionUnsupportedError on a failed strip — exactly the exception build_reverse_name_mapper's own docstring already documents for this case, just not previously implemented. No caller changes needed: both call sites in unified_export_hf.py already wrap this in a broad try/except that falls back to safe in-memory names with a warning — this fix just makes that existing safety net actually trigger, turning a silent, deployment-time failure into a loud, immediate one at export time.

All three built test-first (Red → Green). 26 tests total across the two touched test files (2 new for the checkpoint flags, 3 new for the calibration checkpoint hook, 3 new for the sentinel fix), no regressions on pre-existing tests.

Test plan

  • pytest tests/examples/hf_ptq/test_hf_ptq_args.py
  • pytest tests/unit/torch/utils/test_dataset_utils.py
  • pytest tests/unit/torch/export/test_quant_aware_conversion.py
  • End-to-end: reproduced the original crash against nvidia/Nemotron-Cascade-2-30B-A3B NVFP4 export without the fix, confirmed it's caught at export time with the fix applied.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added options to save and restore quantized model state during Hugging Face post-training quantization.
    • Restore mode skips calibration and proceeds directly to export.
    • Added periodic checkpoint callbacks during dataset processing.
    • Improved compatibility when exporting Nemotron-H model configurations.
  • Bug Fixes

    • Invalid quantization name mappings now fail with a clear error.
    • Added validation for incompatible quantized-state options and malformed checkpoint settings.
  • Tests

    • Added coverage for quantized-state handling, checkpoint callbacks, Nemotron-H compatibility, and invalid mappings.

…orruption

Three fixes, found while quantizing nvidia/Nemotron-Cascade-2-30B-A3B to NVFP4:

1. examples/hf_ptq/hf_ptq.py: --save_quantized_state/--restore_quantized_state
   flags. Lets a failed *export* be retried without redoing calibration.

2. modelopt/torch/utils/dataset_utils.py: checkpoint_every/checkpoint_fn hook in
   _forward_loop/create_forward_loop. Periodic-checkpoint mechanism (not yet
   wired into hf_ptq.py's calibration call -- follow-up, not done here).

3. modelopt/torch/export/quant_aware_conversion.py: build_reverse_name_mapper's
   sentinel-strip logic (_map) silently accepted a mangled sentinel instead of
   raising, when a reverse-rename rule's `.` (matched as "any path separator
   char") ate into the appended sentinel before it could be stripped. In our
   export this corrupted 77/77 exclude_modules entries with an unsubstituted
   placeholder (`\x00backbone.pt_name_sentinel`, not matching the real sentinel
   `\x00modelopt_name_sentinel` -- direct evidence a rename rule rewrote it).
   vLLM couldn't match any excluded layer, defaulted every quant-aware fused
   linear (e.g. Mamba's in_proj, a MergedColumnParallelLinear) to quantized-width
   allocation, and crashed loading the correctly-unquantized-but-unlabeled
   weight. Extracted the strip logic into _strip_sentinel_or_raise(), which now
   raises QuantConversionUnsupportedError on a failed strip -- exactly the
   exception build_reverse_name_mapper's own docstring already promised for
   this case. No caller changes needed: both call sites in unified_export_hf.py
   already wrap this in a broad try/except that falls back to safe in-memory
   names with a warning: this fix just makes that safety net actually trigger,
   converting a silent, deployment-time failure (bare AssertionError in vLLM,
   no useful diagnostic) into a loud, immediate one at export time.

All three built test-first (Red -> Green). 26 total tests passing across the
two touched test files (2 new for the checkpoint flags, 3 new for the sentinel
fix), no regressions on pre-existing tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZby2EsEkVQKwwNKFDpwBm
@copy-pr-bot

copy-pr-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds quantized-state persistence to HF PTQ, NemotronH configuration restoration, strict reverse-name sentinel validation, and periodic checkpoint callbacks for dataset forward loops.

Changes

Quantized-state persistence

Layer / File(s) Summary
HF PTQ state restore and save
examples/hf_ptq/hf_ptq.py, tests/examples/hf_ptq/test_hf_ptq_args.py
The example adds save and restore options. Restore mode skips calibration and proceeds to export. Save mode persists calibrated state. Validation rejects conflicting and AutoQuantize combinations. Tests cover these paths.

NemotronH configuration compatibility

Layer / File(s) Summary
NemotronH schema restoration
modelopt/torch/export/plugins/hf_checkpoint_utils.py, tests/unit/torch/export/test_hf_checkpoint_utils.py
Deployment sanitization reconstructs legacy fields for recognized NemotronH layer types. It preserves unknown or malformed schemas with warnings. Tests cover schema variants.

Reverse name mapping validation

Layer / File(s) Summary
Sentinel suffix validation
modelopt/torch/export/quant_aware_conversion.py, tests/unit/torch/export/test_quant_aware_conversion.py
Reverse name mapping requires the internal sentinel to remain an exact suffix. Corrupted sentinels raise QuantConversionUnsupportedError.

Forward-loop checkpoint callbacks

Layer / File(s) Summary
Periodic forward-loop checkpoints
modelopt/torch/utils/dataset_utils.py, tests/unit/torch/utils/test_dataset_utils.py
Forward-loop APIs accept a checkpoint interval and callback. The callback runs after completed intervals. A zero interval disables checkpointing. Tests cover direct and factory-created loops.

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

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant hf_ptq
  participant ModelOpt
  participant Export
  CLI->>hf_ptq: Select save or restore option
  hf_ptq->>ModelOpt: Restore state or quantize model
  ModelOpt-->>hf_ptq: Return restored or calibrated state
  hf_ptq->>ModelOpt: Save calibrated state when requested
  hf_ptq->>Export: Export model
Loading

Suggested reviewers: meenchen, cjluo-nv, kevalmorabia97

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.95% which is sufficient. The required threshold is 80.00%.
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 The PR adds no torch.load(weights_only=False), allow_pickle=True, hardcoded trust_remote_code=True, eval/exec, or # nosec; it also changes no dependency manifest.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main PTQ reliability changes: checkpoint resume support and exclude_modules sentinel corruption handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

wyattearp and others added 2 commits August 10, 2026 14:06
…on export

Root cause: transformers now ships native NemotronH support
(transformers/models/nemotron_h/configuration_nemotron_h.py) with a newer
config schema -- layers_block_type as a real stored field, with
hybrid_override_pattern/num_hidden_layers as read-only properties derived
from it. Many NemotronH checkpoints on the Hub (e.g.
nvidia/Nemotron-Cascade-2-30B-A3B) still ship their own older, bundled
trust_remote_code configuration_nemotron_h.py written against the *opposite*
schema: hybrid_override_pattern/num_hidden_layers as real fields, with
layers_block_type as a computed property that has no setter.

When ModelOpt loads such a checkpoint for PTQ, transformers' AutoConfig
resolves the model via its own newer native class (no trust_remote_code
needed once a model_type is natively registered), silently converting the
config to the new schema in memory. save_pretrained's plain to_dict() only
serializes __dict__, so neither hybrid_override_pattern nor num_hidden_layers
(both properties on the native class) makes it into config.json -- but
layers_block_type does, since it's a real field there. The result: an
exported config.json in the new schema, sitting next to a copied
configuration_nemotron_h.py file that only understands the old one. Loading
the export back through its own bundled class then fails outright
(AttributeError: property 'layers_block_type' has no setter) or silently
loses the pattern/layer-count metadata.

Reproduced on three separate nvidia/Nemotron-Cascade-2-30B-A3B NVFP4 exports
in a row; each one required hand-patching config.json afterward.

Fix: sanitize_hf_config_for_deployment now reconstructs
hybrid_override_pattern from layers_block_type using the exact inverse of
transformers' own NemotronHConfig._list_to_pattern mapping (linear_attention
-> M, moe -> E, full_attention -> *, mlp -> -), restores num_hidden_layers as
len(layers_block_type), and drops the now-redundant layers_block_type /
mtp_layers_block_type fields the old schema's class doesn't expect. No-op for
non-NemotronH exports, no-op if hybrid_override_pattern is already present
(nothing was dropped), and warns without guessing if layers_block_type
contains an unrecognized value.

Built test-first (Red -> Green): 4 new tests (restores correctly, ignores
other model types, no-ops when already legacy schema, warns on unrecognized
layer type), 19/19 passing in the touched test file, 165/165 passing across
the full export test suite -- no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZby2EsEkVQKwwNKFDpwBm
@wyattearp
wyattearp marked this pull request as ready for review August 11, 2026 04:12
@wyattearp
wyattearp requested review from a team as code owners August 11, 2026 04:12
@wyattearp
wyattearp requested a review from meenchen August 11, 2026 04:12

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/hf_ptq/hf_ptq.py (1)

1352-1365: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Move restore handling before calibration preparation.

At Line 1352, restore handling occurs after batch-size probing, calibration dataloader creation, and pre_quantize. A retry with --restore_quantized_state still depends on calibration inputs and performs model work before export. This can fail when the original dataset is unavailable and defeats the export-retry path. Resolve restore mode before calibration setup, or skip setup that is not required by export.

The PR objective is to retry export without repeating calibration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/hf_ptq/hf_ptq.py` around lines 1352 - 1365, Move the
`_restore_quantized_state_if_requested` handling before batch-size probing,
calibration dataloader creation, and `pre_quantize`; in restore mode, bypass
calibration-only setup and proceed directly to export. Preserve the existing
`quant_cfg` calibration path and quantized-state save behavior for non-restore
runs.
🧹 Nitpick comments (2)
tests/examples/hf_ptq/test_hf_ptq_args.py (1)

73-88: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise CLI validation through parse_args().

This test bypasses the CLI boundary by calling _restore_quantized_state_if_requested directly. It cannot detect whether parse_args() rejects both flags, and it does not cover state flags supplied with AutoQuantize. Add parser-level tests for both invalid combinations.

As per path instructions, validate CLI/API inputs at boundaries, including incompatible save/restore and checkpoint settings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/examples/hf_ptq/test_hf_ptq_args.py` around lines 73 - 88, Update the
tests around _parse_hf_ptq_args to exercise parse_args() validation instead of
calling _restore_quantized_state_if_requested directly. Add parser-level
coverage for both incompatible save/restore and checkpoint/AutoQuantize flag
combinations, asserting each raises ValueError with the appropriate validation
message before any restore or processing occurs.

Source: Path instructions

modelopt/torch/utils/dataset_utils.py (1)

1188-1189: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Document checkpoint_fn rank semantics.

No production caller currently supplies checkpoint_every or checkpoint_fn. The callback runs on every process that invokes the loop. Document that distributed callbacks must be rank-safe or collective before they write shared checkpoints.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/hf_ptq/hf_ptq.py`:
- Around line 1594-1614: Update examples/hf_ptq/hf_ptq.py lines 1594-1614 to
validate --save_quantized_state and --restore_quantized_state during
parse_args(), rejecting their simultaneous use and rejecting either flag with an
AutoQuantize recipe before calibration begins. Update
tests/examples/hf_ptq/test_hf_ptq_args.py lines 73-88 to exercise these invalid
combinations through parse_args() rather than only the private validation
helper.

In `@modelopt/torch/export/plugins/hf_checkpoint_utils.py`:
- Around line 144-156: Update the pattern reconstruction around
_NEMOTRON_H_PATTERN_CHAR_BY_LAYER_TYPE in hf_checkpoint_utils.py to handle
non-string, non-hashable layer_types entries without aborting export: validate
entries before lookup or handle TypeError alongside KeyError, then issue the
existing warning and preserve the native schema. Add a regression test covering
a list or object layers_block_type entry.

In `@modelopt/torch/utils/dataset_utils.py`:
- Around line 1203-1204: Validate checkpoint settings at the public API boundary
before constructing the dataloader or closure: reject checkpoint_every values
below zero, and require checkpoint_fn to be callable whenever checkpoint_every
is positive. Keep zero as the disabled-checkpoint case, let internal checkpoint
logic trust these validated invariants, and add regression tests covering both
invalid combinations.

In `@tests/examples/hf_ptq/test_hf_ptq_args.py`:
- Around line 58-70: Add a focused end-to-end persistence test alongside the
existing mocked tests, using a small local model and pytest tmp_path with the
real mto.save and mto.restore implementations. Verify state is written, restored
into the model, and remains usable by the export retry path; retain the current
delegation and rejection-flow unit tests unchanged.

In `@tests/unit/torch/utils/test_dataset_utils.py`:
- Around line 306-315: Update test_forward_loop_checkpoints_every_n_steps to
record the completed forward count in the checkpoint callback rather than
appending a constant value, then assert calls == [2, 4]. Preserve the existing
five-batch setup and checkpoint_every=2 configuration.
- Around line 293-296: Update the nested _Model class inside _tiny_loader to
define an __init__ method that calls torch.nn.Module’s initializer via
super().__init__(), while preserving its existing forward behavior.

---

Outside diff comments:
In `@examples/hf_ptq/hf_ptq.py`:
- Around line 1352-1365: Move the `_restore_quantized_state_if_requested`
handling before batch-size probing, calibration dataloader creation, and
`pre_quantize`; in restore mode, bypass calibration-only setup and proceed
directly to export. Preserve the existing `quant_cfg` calibration path and
quantized-state save behavior for non-restore runs.

---

Nitpick comments:
In `@tests/examples/hf_ptq/test_hf_ptq_args.py`:
- Around line 73-88: Update the tests around _parse_hf_ptq_args to exercise
parse_args() validation instead of calling _restore_quantized_state_if_requested
directly. Add parser-level coverage for both incompatible save/restore and
checkpoint/AutoQuantize flag combinations, asserting each raises ValueError with
the appropriate validation message before any restore or processing occurs.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: c1a685ac-2da7-42ca-8b30-083eb4111425

📥 Commits

Reviewing files that changed from the base of the PR and between f10a88d and 4e1aec1.

📒 Files selected for processing (8)
  • examples/hf_ptq/hf_ptq.py
  • modelopt/torch/export/plugins/hf_checkpoint_utils.py
  • modelopt/torch/export/quant_aware_conversion.py
  • modelopt/torch/utils/dataset_utils.py
  • tests/examples/hf_ptq/test_hf_ptq_args.py
  • tests/unit/torch/export/test_hf_checkpoint_utils.py
  • tests/unit/torch/export/test_quant_aware_conversion.py
  • tests/unit/torch/utils/test_dataset_utils.py

Comment thread examples/hf_ptq/hf_ptq.py
Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py
Comment thread modelopt/torch/utils/dataset_utils.py
Comment thread tests/examples/hf_ptq/test_hf_ptq_args.py Outdated
Comment thread tests/unit/torch/utils/test_dataset_utils.py Outdated
Comment thread tests/unit/torch/utils/test_dataset_utils.py Outdated
wyattearp and others added 8 commits August 11, 2026 08:04
Reject --save_quantized_state and --restore_quantized_state together, and
reject either with an AutoQuantize recipe (or the deprecated
--auto_quantize_bits CLI path), at the CLI boundary instead of only at
runtime inside _restore_quantized_state_if_requested. AutoQuantize's search
state is not a single quantized model state, so persistence flags for it
must fail before calibration work begins, not mid-run.

Also exercises the save/restore rejection through parse_args() instead of
only the private helper, per review feedback on PR NVIDIA#2129.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjBHecV84Y6tcMJWKiBRzn
Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
…store

A malformed config.json (e.g. layers_block_type containing a nested list or
dict) raised TypeError from the dict lookup, which the existing KeyError
handler did not catch. That aborted export instead of warning and leaving
the schema untouched, same as the already-handled unrecognized-string case.

Catch TypeError alongside KeyError and add a regression test.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
…ard_loop boundary

A negative checkpoint_every stays truthy under the modulo check inside
_forward_loop, and a positive checkpoint_every with checkpoint_fn=None
would call None once the first interval completes -- both failures only
surface deep inside the loop, mid-run. Validate both at the public
create_forward_loop boundary instead, before the dataloader/closure are
built, and document that checkpoint_fn runs on every process and must be
rank-safe or collective if it persists shared state.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
Matches the other nn.Module test double in this file, which does call
super().__init__() explicitly rather than relying on the implicit default.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
…ount

len(calls) == 2 also passes if checkpoint_fn fires at the wrong batches
(e.g. after 1 and 3 instead of 2 and 4). Record the completed forward count
in the callback and assert calls == [2, 4] so the test actually pins down
where the callback fires.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
--restore_quantized_state retries a failed or interrupted export from a
previously calibrated state, so it should not depend on calibration inputs
at all. Previously the restore check ran deep inside the mono-quantization
branch, after batch-size probing, calibration dataloader construction, and
the pre-quantize generation preview had already run -- defeating the
export-retry path when the original calibration dataset is unavailable.

Check for a requested restore at the top of quantize_main instead, and skip
straight to post_quantize/export when it happens. The generation-preview
args passed to post_quantize are None in this path, which disables the
before/after generation comparison (matching --skip_generate's existing
None-preview behavior) without needing calib_dataloader, which post_quantize
never actually used.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
The existing save/restore tests mock both mto.save and mto.restore, so they
verify delegation and rejection flow but never that ModelOpt state is
actually written, restored, or usable afterward. Add a focused test that
quantizes a small local Linear layer, saves its state through hf_ptq's
--save_quantized_state helper, restores it into a fresh model via
--restore_quantized_state, and confirms the restored quantizer state matches
and the model runs a forward pass.

Also switches the existing mocked save test off a hardcoded /tmp path onto
tmp_path.

Signed-off-by: Wyatt Neal <wyatt.neal+git@gmail.com>
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.

1 participant