Skip to content

Carry unplaced checkpoint weights using the loader's accounting, replacing MTP name-matching - #2427

Open
shengliangxu wants to merge 38 commits into
mainfrom
shengliangx/export-carry-over
Open

shengliangxu wants to merge 38 commits into
mainfrom
shengliangx/export-carry-over

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: bug fix + new tests

Replaces hf_ptq's name-based MTP detection with the Transformers loader's own accounting of what it could not place.

The problem

load_mtp_weights found MTP weights by name — "mtp" in key plus config-derived layer indices — backed by a support matrix of three storage conventions (GLM-5.1 inlined, GLM-4.7 standalone file, Qwen3-Next tail shard). Every architecture that spells it differently is a silent miss, and a miss means a checkpoint exported without a component its own config still advertises. The failure is quiet on both sides: Transformers drops unexpected keys, and vLLM's weight loading is pull-based, so a missing MTP produces no warning at all.

The fix

from_pretrained(..., output_loading_info=True) reports unexpected_keys"keys that are found in the checkpoints, but not expected in the model's architecture" — which is exactly the carry-over set, derived structurally rather than by naming, and already accounting for on-the-fly key conversion that a set re-derived afterwards would have to replay. Record those keys at load; carry them at export. Weights the loader did place go through the normal export path unchanged.

Two mechanisms, disjoint by construction

Measured against a real from_pretrained, an off-index file reports nothing: the loader opens only shards named in model.safetensors.index.json, so it never saw those tensors to call them unexpected. Those are sidecars, not weights — untouched by quantization and absent from the export — so they are copied verbatim, which costs no host memory, preserves the bytes and file layout, and leaves the filename a consumer looks for where it was.

on-disk layout mechanism
inlined layer past num_hidden_layers (GLM-5.1, DeepSeek-V3) extra_state_dict
indexed mtp.* tail shard (Qwen3-Next) extra_state_dict
standalone off-index file (GLM-4.7) copied whole

A tensor is never both copied and carried; that would export it twice, and a test asserts it.

Removed as redundant

load_mtp_weights, mtp_layer_prefixes_from_checkpoint, get_inlined_mtp_prefixes, _load_tensors_matching, _apply_to_model_state_dict, _keys_to_prefixes, _add_mtp_exclusions and its three call sites, the pre-quantization enable: False entries hf_ptq appended to the recipe's quant_cfg, and the dead _mtp_layer_prefixes fallback in _get_num_nextn_predict_layers.

Two deliberate behavioural changes

MTP now follows the recipe instead of being force-excluded by the script — which is what examples/megatron_bridge already does; it has no MTP-specific code at all. Recipes importing configs/ptq/units/default_disabled_quantizers still disable mtp.*, so their behaviour is unchanged; a recipe omitting that unit will now quantize an MTP the model actually built.

quantization_config.ignore can no longer claim a layer is unquantized that the export in fact quantized. That contradiction came from _add_mtp_exclusions firing off a model attribute with no cross-check against quantizer state.

Usage

No API change for callers of export_hf_checkpoint. Within examples/hf_ptq, model loading now goes through a wrapper that records the loader's accounting:

model, loading_info = auto_class.from_pretrained(ckpt_path, output_loading_info=True, **kwargs)
record_unplaced_source_keys(model, ckpt_path, loading_info.get("unexpected_keys"))

Testing

tests/examples/hf_ptq/test_carry_over_layouts.py — 8 tests driving a real from_pretrained against a tiny model, covering each of the three conventions above plus an auxiliary (non-MTP) tower, two layouts at once, a checkpoint with nothing stray, and that indexed shards are never copied. CPU-only: the mechanism is bookkeeping during load, so a GPU adds nothing; the export side already has GPU coverage in tests/gpu/torch/export/test_export_carry_over.py.

The six load_mtp_weights tests are replaced by three on the recording path, and the get_model test doubles now model output_loading_info the way Transformers does.

All passing: 8 layout tests, 82 in the surrounding examples/hf_ptq suite. ruff findings at parity with main on every changed file.

Files named like a main weight shard are excluded from the off-index set whatever the index says — a fixture with an empty weight_map would otherwise have made the source weights look like sidecars and copied them into an export beside the quantized ones.

The algorithm: which weights get carried, and how

Two disjoint sets of source weights reach the export without passing through quantization. They are distinguished by what the loader did with the file, and that difference decides both how each is found and how each is moved.

Set 1 — unplaced weights. The loader opened the file and read the tensor, but the model had no parameter for it, so Transformers reports it in unexpected_keys. An MTP head the recipe did not quantize is the common case. Moved as tensors: located in whichever shard holds them, read, and merged into the exporter's extra_state_dict.

Set 2 — off-index sidecars. The index never names the file, so the loader never opened it and never had the chance to call anything unexpected. GLM-4.7 keeps its MTP head in a standalone mtp.safetensors exactly this way. Moved as files: copied byte for byte, so no host memory is spent re-serialising tensors the export does not otherwise touch.

The index is not an inventory of the checkpoint

This is the part that is easy to get wrong, and it cost a silent data-loss bug during review.

model.safetensors.index.json selects which files the loader opens — not which tensors it sees. Within a file it opens, Transformers enumerates every tensor present and reports the unexpected ones. Verified by experiment against transformers 5.3.0:

case reported in unexpected_keys?
key absent from the index, in a shard the index names for other tensors yes
key in a file the index never names (mtp.safetensors) no — the file is never opened

So a tensor missing from weight_map but sitting inside a main shard is set 1, not set 2. An MTP head stored that way is reported, recorded — and was then silently dropped, because resolution went through weight_map, which by construction has no entry for it. The --vllm_fakequant_export guard shared that lookup, so the check written to refuse exports that drop weights stayed silent in exactly the case it existed for.

locate_source_keys now resolves through the index first (free for everything it lists) and header-scans the shards only for the leftovers — names, never tensor data — warning when a key is in no file at all. The carry and the guard share it, so they cannot disagree again.

Flow

  1. At load. record_unplaced_source_keys stores Transformers' own unexpected_keys on the model (_modelopt_unplaced_source_keys) plus the resolved local checkpoint path. The question asked is "does the model have a parameter for this key", never "is this an MTP head" — so the mechanism is architecture-agnostic.
  2. At export, before dispatch. read_unplaced_weights resolves each recorded key to its shard and reads the tensors, merging them into extra_state_dict. An explicitly passed extra_state_dict wins on a name clash: a caller naming a tensor is more specific than our inference.
  3. Rank behaviour. Only the rank that writes extra_state_dict reads the bytes — the FSDP2 writer emits it from rank 0 alone, so a full read on every rank would be host memory spent and discarded (a DeepSeek-V3-class MTP head is 10 GB+ in bf16). The key list is still resolved on every rank, because get_quant_config runs per rank and the configs must agree.
  4. Recording what was written. export_hf_checkpoint records _modelopt_carried_over_names — the union of carried tensors and the off-index sidecars' tensor names — before get_quant_config runs, because that is the first point that knows what was written rather than what was merely unplaced.
  5. Exclusions. Both sets must reach quantization_config.ignore, or a deployment framework reads the top-level quant_algo and tries to load an original-precision weight as a quantized one (the NVBug 5718750 class). seed_carried_over_exclusions is the single path for this, called by get_quant_config after its per-layer pass and again by the layerwise exporter from finalize() — which snapshots its config during bind(), while calibration is still running, so it cannot see the carried set any earlier.

What is deliberately excluded

  • Files that re-ship indexed weights. Mistral's consolidated.safetensors is a second full copy of the model, and PEFT's adapter_model.safetensors is an adapter. Both are off-index, and copying either would put unquantized weights beside the quantized ones — vLLM's mistral load-format looks for consolidated.safetensors by name, so it is not inert. Caught by name for the known conventions and by tensor-name overlap for the rest.
  • Files named like a main weight shard, whatever the index says, so a broken or partial index cannot make the real weights look like sidecars.
  • Symlinks are not excluded. A Hugging Face snapshot stores every file as a symlink into a sibling blobs/, so refusing links would drop the sidecar of every hub-downloaded checkpoint. resolve_checkpoint_file checks where the link lands — regular file, inside the checkpoint dir or its blob root — rather than whether it is a link.
  • Buffers Transformers recomputes. *.inv_freq is skipped by the fake-quant guard even when a shard provides it: older Llama/Mistral-lineage conversions do list it in the index, and refusing an export over it would reject checkpoints that export correctly today.

How a carried, never-quantized MTP head reaches quantization_config.ignore

Raised in review: _add_mtp_exclusions is gone, and an unplaced weight has no module, so
get_quant_config walks right past it. That was a real gap, not just a documentation one —
fixed here.

The export writes a carried MTP head in its original precision. If it is absent from
exclude_modules, a deployment framework reads the top-level quant_algo and tries to load
eh_proj as an FP8/NVFP4 weight — the same class of failure as NVBug 5718750, where a
transformers>=5.0 MoE router was written in BF16 but never excluded.

The two cases now differ only in why the module is invisible to the quantizer walk:

why invisible handled by
MoE router (tf≥5.0) module exists, never gets a quantizer _get_unquantized_moe_router_names
carried weight no module at all in the live model _get_carried_over_module_names

_get_carried_over_module_names reads the keys the loader recorded as unplaced
(_modelopt_unplaced_source_keys), strips the trailing parameter name — a state-dict key is
<module path>.<parameter> — and dedupes. Those names are seeded into layer_config_dict as
QUANTIZATION_NONE, exactly as the router pass does, so they flow through
process_layer_quant_config into exclude_modules, which convert_hf_config emits as
quantization_config.ignore.

An MTP head the recipe does quantize is unaffected: it has a module, is loaded normally, is
never in the unplaced set, and is reported as quantized. The backward-breaking note above still
holds — MTP follows the recipe instead of being force-excluded — but a head that ends up carried
rather than quantized is no longer silently missing from ignore.

Covered by test_carried_over_weights_are_excluded_from_quantization and
test_carried_over_module_names_strip_parameter_and_dedup.

--vllm_fakequant_export does not carry unplaced weights; it now raises rather than writing a
checkpoint quietly missing them.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ❌ — MTP layers now follow the recipe rather than being force-excluded, and quantization_config.ignore no longer lists layers the export may have quantized (carried, never-quantized weights are still listed -- see below). Shipped recipes are unaffected; see the Changelog entry.
  • 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?: ✅ — under 0.48.0 Backward Breaking Changes.
  • Did you get Claude approval on this PR?: ❌ — not yet run.

Additional Information

Draft: the behavioural change to MTP quantization is the part most worth a second opinion — it aligns hf_ptq with megatron_bridge, which special-cases nothing.

Summary by CodeRabbit

  • New Features

    • Quantization supports enabled operators outside transformer layers, including language-model heads.
    • Exports preserve checkpoint weights not loaded into the quantized model.
    • Additional safetensors sidecar files are copied unchanged into exported checkpoints.
    • Unquantized auxiliary components, such as vision layers, remain available in exported models.
  • Behavior Changes

    • Local recipe files take precedence over built-in recipes.
    • Legacy architecture-specific recipe paths remain supported with warnings.
    • MTP-specific export exclusions are no longer applied.
  • Bug Fixes

    • Preserved weights are no longer incorrectly reported as unquantized.
    • Incomplete exports are rejected when source weights cannot be placed or preserved.

hf_ptq found MTP weights by name: a predicate of `"mtp" in key` plus
config-derived layer indices, backed by a support matrix of three storage
conventions. Every architecture that spells it differently is a silent miss, and
a miss means a checkpoint exported without a component its own config still
advertises -- the failure is quiet on both sides, since Transformers drops
unexpected keys and vLLM's loader is pull-based.

Transformers already computes this set while loading.
`from_pretrained(..., output_loading_info=True)` reports `unexpected_keys` --
"keys that are found in the checkpoints, but not expected in the model's
architecture" -- which is the carry-over set, derived structurally rather than by
naming, and already accounting for on-the-fly key conversion that a set
re-derived afterwards would have to replay.

Record those keys at load; carry them at export. Weights the loader did place go
through the normal export path unchanged.

Removes as now redundant: load_mtp_weights, mtp_layer_prefixes_from_checkpoint,
get_inlined_mtp_prefixes, _load_tensors_matching, _apply_to_model_state_dict,
_keys_to_prefixes, _add_mtp_exclusions and its three call sites, the pre-quant
`enable: False` entries hf_ptq appended to the recipe's quant_cfg, and the dead
_mtp_layer_prefixes fallback in _get_num_nextn_predict_layers.

Two deliberate behavioural changes. MTP now follows the recipe instead of being
force-excluded by the script, which is what examples/megatron_bridge already does
-- it has no MTP-specific code at all. And quantization_config.ignore can no
longer claim a layer is unquantized that the export in fact quantized: that
contradiction came from _add_mtp_exclusions firing off an attribute with no
cross-check against quantizer state.

Recipes importing configs/ptq/units/default_disabled_quantizers still disable
mtp.*, so their behaviour is unchanged.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…sing them

An off-index safetensors file is not different in kind from any other file
save_pretrained does not touch. The loader opens only the shards named in
model.safetensors.index.json (or model.safetensors when there is no index), so a
standalone mtp.safetensors -- how GLM-4.7 ships its MTP head -- is never read,
never quantized, and never reported as an unexpected key. Measured against a real
from_pretrained, not assumed: an inlined layer index, an indexed tail shard and a
non-MTP orphan are all reported; an off-index file yields nothing.

So copy it, rather than reading its tensors into a state dict and writing them
back out. Copying costs no host memory for weights the export does not touch,
preserves the bytes and file layout exactly, and leaves the filename a consumer
looks for where it was -- vLLM finds the MTP sidecar by name.

copy_custom_model_files already copied every non-safetensors sidecar; it excluded
*.safetensors wholesale to avoid re-emitting the unquantized source weights. That
exclusion is right for the shards the loader reads and wrong for the ones it does
not, so the off-index set is copied alongside the other sidecars.

Carry-over via extra_state_dict stays for what it is actually needed for: keys
inside shards the loader did read and could not place. Those share a file with
weights that were quantized, so the file cannot be copied whole. The two
mechanisms are disjoint by construction.

Files named like a main weight shard are excluded from the off-index set whatever
the index says, so an empty or malformed weight_map cannot make the source
weights look like sidecars and copy them into an export beside the quantized ones.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
tests/examples/hf_ptq/test_carry_over_layouts.py drives a real from_pretrained
against a tiny model for each convention the deleted name-matching supported --
an inlined layer past num_hidden_layers (GLM-5.1, DeepSeek-V3), a standalone
off-index file (GLM-4.7), an indexed mtp.* tail shard (Qwen3-Next) -- plus an
auxiliary tower, to show nothing is keyed to the string "mtp". Two layouts at
once asserts a tensor is never both copied and carried, which would export it
twice, and an indexed shard is asserted never to be copied.

CPU-only and small: the mechanism is bookkeeping during load, so a GPU adds
nothing to it. The export side already has GPU coverage in
tests/gpu/torch/export/test_export_carry_over.py.

The six load_mtp_weights tests are replaced by three on the recording path, and
the get_model test doubles now model output_loading_info the way Transformers
does, returning (model, loading_info) only when it is requested.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@copy-pr-bot

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

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

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: fee3e5b5-128e-4798-843c-d601c7c22218

📥 Commits

Reviewing files that changed from the base of the PR and between fea4235 and 79a91d1.

📒 Files selected for processing (1)
  • tests/unit/torch/export/test_get_quantization.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/unit/torch/export/test_get_quantization.py

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


📝 Walkthrough

Walkthrough

Changes

HF checkpoint preservation

Layer / File(s) Summary
Record unplaced checkpoint keys
examples/hf_ptq/example_utils.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/examples/hf_ptq/test_example_utils.py, tests/unit/torch/utils/test_model_load_utils.py
HF loading records sorted unexpected keys, source checkpoint paths, and explicit empty results on the model. Hub IDs resolve to local snapshot directories for provenance.
Carry unplaced weights through export
modelopt/torch/export/plugins/hf_checkpoint_utils.py, modelopt/torch/export/unified_export_hf.py, modelopt/torch/export/quant_utils.py
Export recovers unplaced tensors from safetensors files, copies off-index files without overwriting existing files, and marks carried-over modules as unquantized.
Validate layouts and preservation
tests/examples/hf_ptq/test_carry_over_layouts.py, tests/unit/torch/export/*, tests/gpu/torch/*, tests/_test_utils/torch/distributed/utils.py
Tests cover indexed and inlined tensors, off-index sidecars, byte-preserving export, VLM preservation, quantization metadata, and distributed carry-over behavior.
Remove MTP-specific handling
examples/hf_ptq/hf_ptq.py, modelopt/torch/export/*, CHANGELOG.rst
MTP detection, separate loading, name-based exclusions, and separate export state are removed. The changelog records the new accounting behavior.

Priority: ➖ Normal

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 79a91

No concrete merge-blocking risk remains in the incremental change.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.19% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 103 functions across 15 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
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 No listed security anti-pattern was introduced. The authoritative PR diff adds no unsafe torch.load, numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, builtin eval/exec, …
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: it replaces MTP name matching with loader accounting to carry unplaced checkpoint weights.
✨ 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 shengliangx/export-carry-over

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

@github-actions

github-actions Bot commented Sep 14, 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-2427/

Built to branch gh-pages at 2026-09-18 02:31 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

`test_parallel_load_and_export[cpu_offload=True]` failed with "No backend type
associated with device type cpu": under `cpu_offload` the FSDP2 local shards sit
on CPU, and exporting gathers them via `DTensor.redistribute`, so the all-gather
dispatches on CPU tensors. The harness initialized the PG as bare "nccl", which
registers no CPU backend.

Production never hits this -- `modelopt.torch.utils.distributed.setup` already
initializes "cpu:gloo,cuda:nccl", and `fsdp2_weight_access_and_writeback_context`
explicitly expects the gathered shard to come back on CPU and mirrors it to GPU.
So this was the harness diverging from how modelopt actually sets up the PG, not
a library bug. Init the same way in tests.

CUDA collectives still route to NCCL, so this is additive. Verified against
test_distributed, test_fsdp2_export, test_fsdp2, test_fsdp, test_fsdp_save_restore,
test_transformers_tp and test_dist: no result changes, and the three failures in
that set (test_fsdp2_streaming_export_matches_reference[nvfp4/fp8],
test_transformers_tp) reproduce identically on main.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Two failures in CI, both mine.

sanitize_hf_config_for_deployment used to fall back to counting a model's
_mtp_layer_prefixes when the config carried no num_nextn_predict_layers. This
branch removes the MTP-specific handling that set that attribute, so the fallback
had no producer left and went with it -- but its two tests stayed behind,
constructing the attribute by hand and asserting on a code path that no longer
exists. Nothing in modelopt/ or examples/ sets or reads _mtp_layer_prefixes now,
so the tests go too.

The rest is ruff-format over five files; ruff check was clean but I had not run
the formatter, which pre-commit does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.60714% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.10%. Comparing base (b163567) to head (b72d4ab).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/export/unified_export_hf.py 81.94% 13 Missing ⚠️
modelopt/torch/utils/plugins/model_load_utils.py 57.89% 8 Missing ⚠️
modelopt/torch/export/layerwise_export.py 0.00% 4 Missing ⚠️
...delopt/torch/export/plugins/hf_checkpoint_utils.py 96.22% 4 Missing ⚠️
modelopt/torch/export/quant_utils.py 95.45% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2427      +/-   ##
==========================================
- Coverage   71.49%   69.10%   -2.40%     
==========================================
  Files         590      590              
  Lines       64759    64934     +175     
==========================================
- Hits        46297    44870    -1427     
- Misses      18462    20064    +1602     
Flag Coverage Δ
examples-gpt-oss 13.39% <11.16%> (-0.01%) ⬇️
examples-llm_distill 13.45% <11.16%> (-0.02%) ⬇️
examples-llm_qat 17.66% <14.73%> (-0.02%) ⬇️
examples-llm_sparsity 15.91% <11.16%> (-0.02%) ⬇️
examples-specdec_bench 13.14% <11.16%> (-0.01%) ⬇️
examples-speculative_decoding 17.79% <17.85%> (-0.07%) ⬇️
examples-torch_trt 15.21% <11.16%> (-0.02%) ⬇️
gpu 20.91% <11.16%> (-11.54%) ⬇️
regression 15.14% <12.50%> (+0.29%) ⬆️
unit 57.93% <84.37%> (+0.08%) ⬆️

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.

mypy caught that `keys` is `Any | None` where the except block calls `len` on it,
and it is right that this is reachable: the failure can land before `keys` is
resolved -- a failed safetensors import, or unplaced_source_keys itself raising --
and then the handler throws TypeError from inside itself. That handler exists
precisely so a checkpoint it cannot re-read warns instead of taking the export
down with it, so it must not be the thing that crashes.

The count is now only included when it is actually known.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Codecov put this branch's patch coverage at 12%, and while the carry-over path is
exercised by the GPU and examples tests, those do not feed the unit flag -- and
more to the point, three of these functions had no test naming them at all. They
were only ever reached sideways through an end-to-end export.

off_index_safetensors_files is the worst offender: it reads an index file and
filters a directory listing, so it needs neither a GPU nor a model, and it is
where the nastiest bug in this branch already surfaced -- an empty or partial
weight_map made the real model shards look like off-index sidecars, which would
have copied the unquantized source weights into the export to sit beside the
quantized ones. That is a checkpoint that loads and is quietly wrong. It now has
a parametrized test over empty, absent and partial weight maps.

Also covered: that a sidecar is found and an indexed shard is not, that results
are sorted, that a missing directory is not an error, that the copy preserves
bytes and refuses to overwrite what the export already wrote, that recording an
EMPTY unplaced-key list is an answer rather than "nobody asked" (the export
re-derives only when the attribute is absent), and that the carry-over handler
survives failing before the keys are known -- the len(None) crash mypy caught,
where the handler that exists to keep the export standing would have thrown.

Both bug-guarding tests were checked by reverting each fix and confirming they
fail, then restoring.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
partial-install (torch) installs neither accelerate nor transformers, and two of
the new carry-over tests failed there.

The cause is structural rather than incidental. _carry_over_unplaced_source_weights
imports model_load_utils inside its try block, before it inspects the recorded
keys, and model_load_utils imports accelerate at module scope. With accelerate
missing, every call raises ImportError, gets caught by the best-effort handler,
warns and returns {}.

So one test failed on the returned {} and one on its own explicit import -- but
the more interesting part is that the other four PASSED, by short-circuiting
through the ImportError without touching the logic they are named for. A test
that passes for the wrong reason is worse than one that is skipped, so all six
are now guarded rather than just the two that went red.

This is not a gap in the feature: the loader that records those keys lives in the
same module and needs accelerate too, so on a torch-only install there is nothing
recorded to carry over in the first place.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu
shengliangxu marked this pull request as ready for review September 14, 2026 23:15
@shengliangxu
shengliangxu requested review from a team as code owners September 14, 2026 23:15

@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.

Comment — the design (loader accounting + verbatim sidecar copy) is well argued and reuses existing helpers, but the new carry-over is skipped entirely on the layerwise export path and the fallback misfires on common non-safetensors checkpoints.

Needs action:

  • Move the _carry_over_unplaced_source_weights call in unified_export_hf.py above the LAYERWISE_EXPORTER_ATTR early return (or pass the result into finalize): layerwise export used to receive MTP via extra_state_dict and now drops it. See inline.
  • Guard the provenance fallback so a local pytorch_model.bin checkpoint does not make weight_map_for raise and emit "the checkpoint will be missing them" on every export. See inline.
  • Explain in the PR body how a carried, never-quantized MTP head reaches quantization_config.ignore now that _add_mtp_exclusions is gone — unplaced weights have no module, so get_quant_config cannot list them.
  • Fix @pytest.mark.timeout(600) in tests/gpu/torch/export/test_export_carry_over.py: it decorates the helper _safetensors_meta, not a test; also drop unused _PACKED_DTYPES.
  • De-duplicate the copied-sidecar prints in example_utils.copy_custom_model_files. See inline.

No action needed:

  • Removed load_mtp_weights/_count_mtp_layer_prefixes tests are justified — the code under test is deleted and replaced with new coverage.

Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread examples/hf_ptq/example_utils.py Outdated

@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: 4

Caution

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

⚠️ Outside diff range comments (1)

🟠 Major · Carry unplaced source weights through fake-quant export. · examples/hf_ptq/hf_ptq.py:996-999

996-999: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Carry unplaced source weights through fake-quant export.

When --vllm_fakequant_export is set, export_hf_vllm_fq_checkpoint saves the model-backed state and does not call _carry_over_unplaced_source_weights. That helper reads _modelopt_unplaced_source_keys from the indexed source checkpoint. Therefore, an unplaced source tensor can be omitted from the exported checkpoint. Carry these tensors through the fake-quant exporter, or reject this option when such tensors are present.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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 996 - 999, The vllm fake-quant export
path must preserve unplaced source weights. Update the flow around
export_hf_vllm_fq_checkpoint to carry tensors identified by
_modelopt_unplaced_source_keys from the indexed source checkpoint into the
exported checkpoint, or validate and reject --vllm_fakequant_export when such
tensors exist.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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/example_utils.py`:
- Line 619: Update the checkpoint provenance passed to
record_unplaced_source_keys so it uses the resolved local snapshot directory
from from_pretrained rather than the original Hub ID; ensure
_carry_over_unplaced_source_weights receives a path accepted by weight_map_for
and preserves export of recorded in-index weights.

In `@modelopt/torch/export/plugins/hf_checkpoint_utils.py`:
- Line 342: Update copy_off_index_safetensors to resolve each selected source
path and verify it is a regular, non-symlink file before calling shutil.copy2;
reject invalid or symlinked checkpoint entries rather than copying them.

In `@modelopt/torch/export/unified_export_hf.py`:
- Line 1724: Update export_hf_checkpoint so
_carry_over_unplaced_source_weights(model) runs before the layerwise return
path, and merge its result into extra_state_dict before passing that dictionary
to exporter.finalize. Preserve the existing behavior for non-layerwise exports
and avoid carrying weights after finalize.

In `@tests/gpu/torch/export/test_export_carry_over.py`:
- Line 106: Move the Transformers imports, including AutoModelForCausalLM, from
the worker helper functions to module scope in test_export_carry_over.py. Remove
the now-redundant local imports and preserve the existing worker behavior
without adding a local-import justification.

---

Outside diff comments:
In `@examples/hf_ptq/hf_ptq.py`:
- Around line 996-999: The vllm fake-quant export path must preserve unplaced
source weights. Update the flow around export_hf_vllm_fq_checkpoint to carry
tensors identified by _modelopt_unplaced_source_keys from the indexed source
checkpoint into the exported checkpoint, or validate and reject
--vllm_fakequant_export when such tensors exist.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr.
🪄 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: 9986037d-3b7b-41a8-903e-90346f6b589e

📥 Commits

Reviewing files that changed from the base of the PR and between 3c87751 and d1deefd.

📒 Files selected for processing (16)
  • CHANGELOG.rst
  • examples/hf_ptq/example_utils.py
  • examples/hf_ptq/hf_ptq.py
  • modelopt/torch/export/layerwise_export.py
  • modelopt/torch/export/plugins/hf_checkpoint_utils.py
  • modelopt/torch/export/unified_export_hf.py
  • modelopt/torch/export/unified_export_hf_streaming.py
  • modelopt/torch/utils/plugins/model_load_utils.py
  • tests/_test_utils/torch/distributed/utils.py
  • tests/examples/hf_ptq/test_carry_over_layouts.py
  • tests/examples/hf_ptq/test_example_utils.py
  • tests/gpu/torch/export/test_export_carry_over.py
  • tests/gpu/torch/utils/test_model_load_utils.py
  • tests/unit/torch/export/test_hf_checkpoint_utils.py
  • tests/unit/torch/export/test_unified_export_hf.py
  • tests/unit/torch/utils/test_model_load_utils.py
💤 Files with no reviewable changes (2)
  • modelopt/torch/export/layerwise_export.py
  • modelopt/torch/export/unified_export_hf_streaming.py

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

Comment thread examples/hf_ptq/example_utils.py Outdated
Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread tests/gpu/torch/export/test_export_carry_over.py Outdated
Nine review findings from cjluo-nv and CodeRabbit on #2427.

The substantive one: a weight carried over from the source checkpoint has no
module in the live model, so the quantizer walk in get_quant_config cannot see
it. Its original-precision weight was written to the export but omitted from
exclude_modules -- so vLLM/SGLang read the top-level quant_algo and tried to
load a never-quantized MTP eh_proj as an FP8 weight. This is the same failure
the MoE-router pass already guards against; only the reason the module is
invisible differs (no quantizer there, no module at all here). Record the
carried keys' owning modules as unquantized so they land in exclude_modules,
which convert_hf_config maps to quantization_config.ignore.

Also:
- layerwise export now receives the carried weights (the carry-over block ran
  after the early return, so layerwise exports silently dropped them)
- --vllm_fakequant_export refuses the combination when there ARE unplaced
  weights rather than writing a checkpoint quietly missing them
- non-safetensors checkpoints no longer warn about nothing
- a hub id is resolved to its local snapshot before being recorded
- symlinked checkpoint entries are refused
- sidecars announced once, by the shared loop
- timeout marker moved to the test; dead constant dropped
- transformers imports hoisted to module scope in the GPU test

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>

@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: 1

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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 `@tests/unit/torch/export/test_get_quantization.py`:
- Line 646: Move the _get_carried_over_module_names import from the test
function to module scope, alongside the other imports in
test_get_quantization.py, so import failures occur during test collection.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr
🪄 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: 5d750dae-b17c-4930-955c-a874dad98b38

📥 Commits

Reviewing files that changed from the base of the PR and between f011ec2 and fea4235.

📒 Files selected for processing (8)
  • examples/hf_ptq/example_utils.py
  • examples/hf_ptq/hf_ptq.py
  • modelopt/torch/export/plugins/hf_checkpoint_utils.py
  • modelopt/torch/export/quant_utils.py
  • modelopt/torch/export/unified_export_hf.py
  • tests/gpu/torch/export/test_export_carry_over.py
  • tests/unit/torch/export/test_get_quantization.py
  • tests/unit/torch/export/test_unified_export_hf.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • modelopt/torch/export/plugins/hf_checkpoint_utils.py

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

Comment thread tests/unit/torch/export/test_get_quantization.py Outdated
CONTRIBUTING.md requires module-scope imports unless a local one has a
documented circular-import or optional-dependency reason; this had neither.
Importing it at collection time also makes an import error fail collection
rather than a single test.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Three findings from cjluo-nv on 1f1ed5d.

The off-index set treated every unindexed *.safetensors as an inert sidecar.
It is not: Mistral ships consolidated.safetensors, a second full copy of the
weights, and PEFT ships adapter_model.safetensors. Both slipped through
_IS_MAIN_WEIGHT_SHARD, so an export gained a multi-GB verbatim copy of the
UNQUANTIZED weights beside the quantized ones, with every tensor in it seeded
into exclude_modules. That copy is not inert -- vLLM's mistral load-format
looks for consolidated.safetensors by name, so it can be served in place of
what we quantized.

Both a name rule and a tensor-overlap check are needed, because neither covers
the other: a PEFT adapter's names do not intersect the index, so only the name
rule catches it; a checkpoint free to invent a filename is caught only by
overlap. Overlap reads safetensors headers, never tensor data, and keeps any
candidate whose header will not read -- dropping a real sidecar loses weights,
which is the failure this path exists to prevent.

The layerwise path still missed sidecar exclusions. LayerwiseExporter.bind()
snapshots its quant config during calibration, and finalize() reuses that
snapshot, so _get_carried_over_module_names only ever saw the unplaced-keys
fallback -- which by design excludes off-index sidecars. GLM-4.7's
mtp.safetensors reached a --layerwise_export output with nothing in ignore:
NVBug 5718750 again, third path. seed_carried_over_exclusions re-seeds in
finalize(), after the name reversal since carried names are already source
names.

Also: _resolved_local_dir uses the module-level snapshot_download, and
_without_reshipped_weights uses the module-level safe_open rather than
re-importing it -- this module already imports safetensors at module scope, so
the local import and its guard were dead code.

test_layerwise_finalize_sees_the_carried_keys pins the ordering the layerwise
fix rests on: the carried set must be recorded before finalize() is called.
LayerwiseExporter.finalize() is otherwise only exercised by a GPU test, and
this is the property whose silent breakage produced the bug twice.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A monkeypatch.setattr call added in 4b1d84a fit on one line. My local gate
format-checks an explicit file list that predates that test, so it never saw
the file; CI runs pre-commit --all-files and did.

Signed-off-by: Shengliang Xu <shengliangx@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.

Comment — the three prior concerns are fixed, but the new symlink guard in copy_off_index_safetensors skips every sidecar in a Hub-cached checkpoint, which is the common GLM-4.7 case this path exists for.

Needs action:

  • Accept symlinks that resolve to regular files in copy_off_index_safetensors (hf_checkpoint_utils.py): HF snapshot dirs store every file as a symlink into blobs/, so a hub-downloaded mtp.safetensors is now warned-about and dropped. See inline.
  • Add a test in tests/unit/torch/export/test_hf_checkpoint_utils.py for a symlinked sidecar laid out like an HF cache snapshot — no current test covers that shape.
  • Move the function-level off_index_safetensors_files / seed_carried_over_exclusions / torch imports in the new tests (test_hf_checkpoint_utils.py, test_get_quantization.py) to module scope, matching the fix already applied to _get_carried_over_module_names.
  • Refresh the stale module docstring in tests/examples/hf_ptq/test_example_utils.py — it still describes the deleted load_mtp_weights convention tests.

No action needed:

  • ✔️ Resolved since the last review: the over-broad off-index copy (now _IS_WEIGHT_DUPLICATE + _without_reshipped_weights with four tests), the layerwise sidecar exclusions (seed_carried_over_exclusions in finalize()), and the re-imported snapshot_download.

Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py Outdated
Comment thread tests/unit/torch/export/test_get_quantization.py
The symlink guard added in fea4235 rejected every sidecar of every checkpoint
loaded by hub id. A Hugging Face snapshot stores each file as a symlink into
../../blobs/<sha> -- our own GLM-5.3 cache is 150 symlinks and 0 regular files
-- so "not a regular file in the source checkpoint" fired on all of them and
GLM-4.7's mtp.safetensors was dropped with a warning. That is the
silent-missing-MTP failure this PR exists to prevent, introduced while fixing
an information-disclosure finding.

No test could have caught it: every fixture in the suite writes real files, so
nothing modelled the layout that actually ships. Two tests now do, one for the
hub-cache shape and one for a link out of the tree.

Use shard_cast_utils.resolve_checkpoint_file rather than a new check. It
already draws this line and is already tested against both shapes: strict
resolve, regular file, and containment in the checkpoint dir or its sibling
blobs/. Checking only that the target resolves to a regular file -- the obvious
repair -- would have accepted a link to /etc/passwd and quietly dropped the
protection the guard was added for.

The audit that followed found the same not-checking elsewhere:

- Three function-scope "from safetensors import safe_open" in
  unified_export_hf.py were dead: the module binds safe_open at line 35. Same
  for off_index_safetensors_files, which has no circular-import reason.
- The comment on the partial-install guard blamed safetensors. It is a
  module-scope dependency there and always present; the optional import is
  model_load_utils, which pulls transformers and accelerate.
- test_carry_over_is_quiet_without_safetensors_installed patched
  sys.modules["safetensors"], which cannot affect an already-bound name --
  once the dead imports went, it would have passed while asserting nothing.
  Retargeted at the dependency that is genuinely optional.

Also: test imports hoisted to module scope, and the stale module docstring in
test_example_utils.py rewritten -- it still described the deleted
load_mtp_weights convention matrix.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Creating a symlink on Windows needs elevated privileges, so symlink_to raises
there and test_copies_a_symlinked_sidecar_from_a_hub_cache_layout failed the
windows job (and with it unit-pr-required-check).

tests/unit/torch/export/test_shard_cast_utils.py already guards its symlink
tests this way. I had that file open while looking up resolve_checkpoint_file
and did not carry the decorator across.

Signed-off-by: Shengliang Xu <shengliangx@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.

All four prior concerns are fixed — the symlink guard now goes through resolve_checkpoint_file, which accepts hub-cache blobs — so this is ready, but a human still owns the sign-off on a 18-file, +1736/-397 change that deletes existing tests.

Needs action:

  • Sign off as a human: the PR is large and removes the load_mtp_weights / _count_mtp_layer_prefixes / _mtp_layer_prefixes tests. That deletion is justified in the diff — the code under test is gone and replaced by test_carry_over_layouts.py, test_get_quantization.py and test_hf_checkpoint_utils.py coverage.
  • Optional: hoist the three function-scope carryable_source_keys imports and the unified_export_hf as uehf / LAYERWISE_EXPORTER_ATTR imports in tests/unit/torch/export/test_unified_export_hf.py to module scope — same convention already applied to the other two test files.

No action needed:

  • ✔️ Resolved since the last review: the hub-cache symlink regression in copy_off_index_safetensors (plus snapshot/dangling/out-of-tree tests), the function-level imports in test_hf_checkpoint_utils.py and test_get_quantization.py, and the stale test_example_utils.py docstring.
  • Carried names seeded as QUANTIZATION_NONE land in exclude_modules without flipping quant_algo to MIXED_PRECISION — verified against process_layer_quant_config.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py
Comment thread examples/hf_ptq/example_utils.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 — 2 IMPORTANT, 2 SUGGESTION, 0 CRITICAL

Scope: full review (trigger comment carried no scoping instructions). 18 files changed; reviewed all 8 modelopt/ + examples/ source files and CHANGELOG.rst, plus the surrounding context needed to trace the mechanism (process_layer_quant_config, revert_quant_config_names, resolve_checkpoint_file, weight_map_for, LayerwiseExporter.finalize, _export_fsdp2_checkpoint_streaming). Test files were read only where they settled a question about intended behaviour; I did not review them line by line.

The design holds up

I traced the two mechanisms end to end and the disjointness claim checks out: unexpected_keys can only name tensors from files the loader opened, and off_index_safetensors_files only returns files it did not, so nothing is both carried and copied. The exclusion plumbing is also right where it matters — get_quant_config uses setdefault, so a module the quantizer walk already classified wins over the carried-name seeding, and process_layer_quant_config routes QUANTIZATION_NONE entries through _prefix_wildcard_summarize_exclude_modules, which will not glob a quantized sibling. Skipping MIXED_PRECISION in seed_carried_over_exclusions is consistent with process_layer_quant_config not emitting exclude_modules in that case at all. Routing the sidecar copy through resolve_checkpoint_file with max_bytes=None is the right call for the HF-snapshot-symlink shape. Net: replacing a three-convention name-matching table with the loader's own accounting is a clear reduction in surface area, and the removals are genuinely dead.

Findings

IMPORTANT — carried tensors are read on every rank, written only by rank 0 (unified_export_hf.py:1793)

_carry_over_unplaced_source_weights runs at the top of export_hf_checkpoint, before the rank is known, while _export_fsdp2_checkpoint_streaming writes extra_state_dict under if my_rank == 0. Ranks 1..N-1 materialize the full carried set from disk and discard it. A DeepSeek-V3-class MTP head is ~10 GB+ in bf16, so on an 8-process node that is ~8× redundant host RAM held for the whole export — in the one path whose loader comment explicitly says holding these tensors would waste the memory it exists to save. Split "which keys" (all ranks, so get_quant_config agrees) from "read the bytes" (writer rank only); suggested shape inline. Deriving _modelopt_carried_source_keys from the key list rather than from _carried also makes it rank-invariant, which the current line is not.

IMPORTANT — the --vllm_fakequant_export guard fires after the whole PTQ run (hf_ptq.py:1004)

export_quantized runs after quantize_main, so the refusal costs a user the entire calibration + quantization pass before it fires — despite everything it needs being available right after load_model. There is already a fail-fast block for this exact class of incompatibility at hf_ptq.py:898. Secondary: the guard is broader than the MTP case it is written for, and the rotary_emb.inv_freq reasoning in the comment only holds when that buffer is absent from the index — for the older conversions that do list it, it is shard-backed and only stays out of unexpected_keys if the architecture declares it in _keys_to_ignore_on_load_unexpected. Where it does not, a checkpoint that exported fine before now hard-fails.

SUGGESTIONoff_index_safetensors_files leaves indexed_tensors empty in the no-index branch, so _without_reshipped_weights is a no-op there and a second full weight copy under a non-standard name gets copied beside the quantized weights (hf_checkpoint_utils.py:335).

SUGGESTION — the sidecar copy is wired into copy_custom_model_files, which hf_ptq also calls on the TensorRT-LLM export path, where nothing reads an HF-format sidecar. Plus a small asymmetry: _off_index_source_keys lacks the config._name_or_path fallback that _carry_over_unplaced_source_weights has (example_utils.py:1047).

Risk

Moderate, and well-scoped. Both IMPORTANT findings are localized and mechanical to fix; neither changes the design. The backward-breaking behaviour the description flags for a second opinion — MTP following the recipe instead of being force-excluded — is the right call in my view: aligning with megatron_bridge, keeping shipped recipes unaffected via default_disabled_quantizers, and closing the ignore-contradicts-the-export gap are each an improvement, and the CHANGELOG.rst entries state the consequences accurately. The residual exposure is a user with a hand-written recipe that omits that unit, who will silently start quantizing an MTP head; that is discoverable from the changelog, which is the most a breaking change can offer.

The heaviest untested seam is the multi-rank FSDP2 export — the memory finding above lives there, and neither new test file exercises it.

Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread examples/hf_ptq/hf_ptq.py Outdated
Four findings, all verified against the code.

Carried tensors were read on every rank but written only by rank 0
(unified_export_hf_streaming.py:695 guards the extra_state_dict write with
my_rank == 0), while _carry_over_unplaced_source_weights ran unconditionally at
the top of export_hf_checkpoint. Every non-writing rank materialized the full
carried set and dropped it: on 8 ranks with a 10 GB+ MTP head that is ~70 GB of
host RAM, in the path whose own loader comment says holding these tensors
wastes the memory it exists to save. Split name resolution from reading: a
keys_only pass still does the shard lookup, so every rank agrees on the key
list get_quant_config consumes, but only the writing rank loads bytes. Deriving
the keys from carryable_source_keys instead -- as suggested -- would have
dropped the provenance-fallback path, which returns no keys when the loader
recorded none.

--vllm_fakequant_export was rejected at export time, after the whole PTQ run.
Everything the check needs is set by the loader, so it now runs immediately
after load_model. It also ignores keys ending in .inv_freq: older
Llama/Mistral-lineage conversions list that buffer in the index, so it IS
shard-backed, and transformers recomputes it -- refusing an export over it
would reject checkpoints that export correctly today.

_without_reshipped_weights was a no-op for single-file checkpoints:
indexed_tensors stayed empty in the no-index branch, so a second full copy
under an unrecognised name was copied verbatim -- while the docstring promised
overlap would catch exactly that. Read the single file header.

Off-index sidecars were copied into TensorRT-LLM exports, whose checkpoint is
rank<N>.safetensors plus its own config and which never reads them; the
exclude_modules seeding that gives a sidecar meaning happens only inside
export_hf_checkpoint. Gated on the consuming format.

Also: _off_index_source_keys gained the config._name_or_path fallback its
sibling already had, so both halves agree where the source checkpoint is.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A tensor stored inside a main safetensors file but omitted from
model.safetensors.index.json was recorded and then silently dropped. Verified
end to end on a fixture, against transformers 5.3.0:

  recorded unplaced keys: [model.mtp.eh_proj.weight]
  CARRIED keys: []                      -> dropped from the export
  carryable_source_keys: []             -> the fake-quant guard stayed silent

An MTP head is exactly this shape: when MTP is not quantized the model never
declares it, so the loader reports it as unexpected and it reaches
_modelopt_unplaced_source_keys like any other unplaced key. Resolution then went
through weight_map, which by construction has no entry for it, so the key was
skipped without a warning -- and carryable_source_keys shared that lookup, so
the guard written to refuse exports that would drop weights stayed quiet in the
one case it exists for.

The wrong assumption was mine: I treated the index as an inventory of the
checkpoint. It selects which FILES the loader opens; within an opened file every
tensor is enumerated and the unexpected ones reported. Both halves are now
probed and documented on record_unplaced_source_keys, marked as observed
behaviour rather than a published contract -- and nothing depends on it holding,
since keys are located by header scan either way.

_locate_source_keys resolves through the index first (free for everything it
lists) and header-scans the shards only for the leftovers, warning when a key is
in no file at all. The carry and the guard share it, so they cannot disagree.

Two of my own tests caught regressions in this fix, both fixed in the code
rather than by relaxing the assertion:

- reaching for model_load_utils.weight_map_for reintroduced the transformers and
  accelerate imports removed in 1f1ed5d, which would have made the guard fail
  open again in the partial-install environments
- a missing checkpoint directory stopped reporting "could not copy" and fell
  through to "in no safetensors file", reporting an absent checkpoint as an
  absent tensor

Found by review question, not by any of the automated reviewers: every fixture
built its index from the tensors, so index and contents never disagreed.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Carry-over dropped weights the checkpoint index does not list (fixed in 2693416)

Raised in review by @shengliangxu, and none of the automated passes found it — including mine. Worth a note because it changes what the carry-over actually guarantees, and because the wrong assumption behind it is visible in several comments I wrote.

The bug. A tensor stored inside a main safetensors file but omitted from model.safetensors.index.json was recorded as unplaced and then silently discarded. Verified end to end against transformers 5.3.0:

recorded unplaced keys: ['model.mtp.eh_proj.weight']
CARRIED keys:           []            -> dropped from the export, no warning
carryable_source_keys:  []            -> --vllm_fakequant_export guard stayed SILENT

An MTP head is exactly this shape: when MTP is not quantized the model never declares it, so the loader reports it as unexpected and it reaches _modelopt_unplaced_source_keys normally. Resolution then went through weight_map, which has no entry for it, so it was skipped — and because carryable_source_keys shared that lookup, the guard written to refuse exports that would drop weights was disabled in precisely the case it exists for. Silent data loss with the safety net off.

The wrong assumption. I treated the index as an inventory of the checkpoint. It is not — it selects which files the loader opens; within an opened file every tensor is enumerated and the unexpected ones reported. Two probes establish it:

case reported in unexpected_keys?
key absent from index, in a shard the index names for other tensors yes
key in a file the index never names (mtp.safetensors) no — file never opened

The second row is what copy_off_index_safetensors has always handled. The first row is the one I got wrong, and it is the one that looks handled, because the key is reported.

The fix. _locate_source_keys resolves through the index first (free for everything it lists) and header-scans the shards only for leftovers — names, never tensor data — warning when a key is in no file at all. The carry and the guard share it, so they cannot disagree again. Two regression tests cover it.

The loading behaviour is now documented on record_unplaced_source_keys, labelled as observed behaviour against transformers 5.3.0 rather than a published contract. Nothing depends on it holding: recorded keys are located by header scan whether or not the index lists them, and ignored files are copied regardless.

Why six review rounds missed it. Every fixture built its index from the tensors it wrote, so index and contents never disagreed — the failing condition was unreachable in the entire suite. The reviewers and I were all reasoning from the same comment, which asserted the index was authoritative.

Two of my own tests then caught regressions in this fix, both repaired in the code rather than by relaxing assertions: reaching for model_load_utils.weight_map_for reintroduced the transformers/accelerate dependency removed in 1f1ed5d (which would have made the guard fail open in partial-install again), and a missing checkpoint directory briefly started reporting as a missing tensor.

Still open, deliberately. _IS_MAIN_WEIGHT_SHARD filtering means a main-shard-named file the index does not list is skipped silently. That trade is intentional — it prevents a broken index from copying unquantized weights into the export, which is the worse failure — but the skip is silent, and a warning there would be cheap. Happy to add it if reviewers prefer.

partial-install (torch) failed on 2693416 with

  Could not copy 1 unplaced source weight(s) into the export
  (No module named accelerate); the checkpoint will be missing them.

_carry_over_unplaced_source_weights imported unplaced_source_keys at the top of
its try, but only uses it when keys is None. model_load_utils pulls in
transformers and accelerate at module scope, so where those are absent the
import raised and the handler turned it into could-not-copy -- dropping every
carried weight on the RECORDED-keys path, which needs nothing from that module
and is the path hf_ptq actually takes.

This is the fourth instance of one shape: an import required by a single branch,
sitting inside a try whose handler reads a missing dependency as an empty
answer. The three before it were imports I had added; this one was pre-existing
two lines above code I edited repeatedly, and I never audited it because I was
only checking my own additions. Audited the whole carry-over surface this time:
hf_checkpoint_utils has no function-scope imports left, and this is the only one
in unified_export_hf, now inside the branch that uses it.

test_carry_over_works_without_the_loader_dependencies pins the carry itself. The
existing sibling test covered carryable_source_keys, which decides whether the
guard fires -- but the carry is what writes the weights, so the unpinned half was
the one that mattered.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
source_weight_map and locate_source_keys read the safetensors index and shard
headers. That is checkpoint-format knowledge, and it was sitting in
unified_export_hf -- an exporter -- because I put it there while chasing the
partial-install import problem. hf_checkpoint_utils is the module for this, and
it carries no transformers or accelerate at module scope, so the property I was
protecting holds there too.

Both are public now: they are read by the exporter and belong to the format, not
to either caller.

Not moved, deliberately:

- model_load_utils.weight_map_for stays. Relocating it would have utils/plugins
  import from export/plugins, and the two readers are not redundant --
  weight_map_for raises when a checkpoint has no safetensors, source_weight_map
  returns empty, and that difference is load-bearing: it is what separates "the
  checkpoint is missing" from "the tensor is missing" in the carry-over
  warnings.
- shard_cast_utils.resolve_checkpoint_file stays. It is pre-existing, has its own
  tests and other callers, and moving it mid-review buys nothing functional.

The broader consolidation -- one module owning index parsing, shard naming,
snapshot/blob containment and sidecar classification, with model_load_utils and
shard_cast_utils delegating -- is worth doing as its own structural change, not
folded into this one.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
resolve_checkpoint_file decides what counts as a file inside an HF checkpoint:
it resolves symlinks, demands a regular file, and requires the target to sit
under the checkpoint dir or the sibling blobs/ of a hub snapshot. That is
checkpoint-format knowledge, and it lived in a module named for shard casting --
which is why I did not find it before writing a weaker version of it by hand.

Moved with its helpers (_snapshot_blob_root, _allowed_source_roots,
_is_relative_to, _MAX_CHECKPOINT_METADATA_BYTES). shard_cast_utils re-exports
resolve_checkpoint_file, so its nine callers -- including the deepseek and kimi
example scripts -- are untouched. This also removes the import cycle the earlier
commit introduced: the dependency now runs one way, shard_cast_utils ->
hf_checkpoint_utils.

Left in shard_cast_utils on purpose:

- the MXFP4/NVFP4 conversion maths, which is quantization numerics rather than
  checkpoint format
- the aux-file group (link_aux_files, validate_aux_files, validate_paths,
  prepare_output_dir). These ARE checkpoint utilities and belong here by the same
  argument, but link_aux_files overlaps copy_non_safetensor_files_from_ckpt,
  which hf_checkpoint_utils already has. Reconciling two functions that copy a
  checkpoint non-weight files is a behaviour change, not a move, and should not
  ride along inside a relocation commit.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
get_quant_config seeded carried names into layer_config_dict, so they went
through _prefix_wildcard_summarize_exclude_modules and could emerge as
model.mtp*. The layerwise exporter can only act once its config is already
built -- bind() snapshots it during calibration -- so it appended literals. One
model, two exporters, two different-looking quantization_config.ignore.

Both now call seed_carried_over_exclusions: get_quant_config once the per-layer
pass is done, the layerwise exporter from finalize().

Converged on literals rather than wildcards because the summarizer cannot serve
the layerwise path: it needs quantized_layers to guarantee a pattern never
swallows a module that WAS quantized, and process_layer_quant_config pops that
key before returning. Emitting wildcards on the late path would mean a second
summarizer without that guard. A literal cannot over-match by construction. The
visible effect is more entries for a large MTP head and identical output from
both exporters.

test_both_export_paths_exclude_carried_weights_identically pins the agreement --
it is the drift this commit removes -- and
test_seeded_exclusions_are_literal_module_names pins the format.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Two disjoint sets reach the export without passing through quantization, and
the code used one vague vocabulary for both. _carry_over_unplaced_source_weights
did not say which set it returned; _off_index_source_keys reused source keys for
the other set entirely, so the shared words actively misled.

The sets differ by what the LOADER did with the file, which decides how we find
them and how we move them:

  1. UNPLACED -- the loader opened the file and read the tensor, the model had no
     parameter for it. Moved as TENSORS into extra_state_dict.
  2. OFF-INDEX -- the index never names the file, so the loader never opened it.
     Moved as FILES, copied byte for byte.

Defined once in hf_checkpoint_utils, which owns checkpoint-format knowledge. The
block also records the distinction that produced a silent data-loss bug earlier
today: the index is not an inventory of the checkpoint, so a tensor missing from
weight_map but sitting in a shard the index names is set 1, not set 2.

Renames, so unplaced always means set 1 and off-index always means set 2, with
no vague term shared between them:

  _carry_over_unplaced_source_weights -> read_unplaced_weights
  carryable_source_keys               -> carryable_unplaced_keys
  _off_index_source_keys              -> off_index_tensor_names
  _modelopt_carried_source_keys       -> _modelopt_carried_over_names

_modelopt_unplaced_source_keys keeps its name: it spans ten files, it is the
loader own recorded attribute, and it already says set 1 accurately.

Test counts are unchanged on both sides of the rename.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The old name invited the assumption that produced the silent data loss earlier
in this PR: it sounds like "the weights in this checkpoint", when for a sharded
checkpoint it returns weight_map verbatim -- what the loader will look for, not
what the shards physically contain. A tensor present in a shard but absent from
the index is invisible to it, which is exactly the MTP-in-a-main-shard case.

The name now says index, and the docstring leads with the limitation and points
at locate_source_keys for callers asking where a key actually lives. Only the
single-file branch is exhaustive, because there is no index for it to disagree
with.

Also fixes a comment still referring to _source_weight_map, left over from
moving the function out of unified_export_hf.

Test counts are unchanged on both sides of the rename.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>

@Edwardf0t1 Edwardf0t1 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.

Found two P1 correctness issues in the carry-over path. Both were reproduced with real Transformers 5.12.1 loading and CPU HF export; export completed but omitted source weights. The 165 focused tests passed, but their fixtures do not cover these loader behaviors. GPU tests were not run because the installed CUDA build is incompatible with the host driver.

Comment thread examples/hf_ptq/example_utils.py
Comment thread modelopt/torch/utils/plugins/model_load_utils.py
Two P1 findings from @Edwardf0t1, both reproduced against real checkpoints, and
both rooted in the same mistake: treating the loader's unexpected_keys as the
definition of "unplaced".

Transformers filters that report through _keys_to_ignore_on_load_unexpected.
Qwen3-Next ignores ^mtp.*, DeepSeek-V3 and GLM their own MTP prefixes -- so for
exactly the heads this mechanism exists to carry, the report comes back EMPTY.
An earlier revision then treated [] as authoritative, with a comment claiming a
loader that recorded an empty list "has already answered". It had not; the
export shipped without the MTP head and said nothing.

The report can also name post-conversion targets. A fused
...experts.gate_up_proj exists in no shard, so header-scanning cannot recover
it, and one such name stands for several source tensors.

unplaced_keys_for takes the union of two accountings whose blind spots do not
overlap:

- structural (unplaced_source_keys): walks the index and asks whether the model
  has a parameter for each source key, resolving through renames and converters
  first. Unaffected by ignore rules, and source-keyed, so it names tensors that
  exist on disk. Blind to keys the index omits.
- recorded (unexpected_keys): sees keys the index omits, because the loader
  enumerates the contents of every shard it opens. Filtered by ignore rules, and
  may name converted targets.

Together they also cover the un-indexed case fixed in 2693416, so all three
known failure modes fall out of one derivation rather than three patches. Where
transformers and accelerate are absent the structural pass cannot run and the
recorded set stands alone -- degraded, but no worse than before.

Fixing this introduced a regression that the existing tests caught: hoisting the
is_dir() check above the try made an unreadable checkpoint return {} silently
when keys HAD been recorded. Dropping weights without a word is the failure this
path exists to prevent, so the warning is restored -- still quiet when nothing
was recorded, because then there is no reason to think anything is missing.

test_carry_over_handler_survives_failing_before_the_keys_are_known now pins the
new message. That change is deliberate: a structural pass that fails is not the
same as "could not copy N weights", since with nothing recorded we do not know
anything is missing, only that we could not check.

Three regression tests: an architecture whose ignore rules hide its MTP key, a
fused name that must not strand the sources behind it, and the degraded path
without the loader's dependencies.

Not covered, and worth saying plainly: these stub unplaced_source_keys rather
than loading a real Qwen3-Next or Qwen3-MoE checkpoint. They pin the union
logic, not Transformers' actual ignore rules or converters. A real-loader test
is the stronger check the reviewer asked for and is not here yet.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…est doubles

partial-install (torch): model_load_utils imported accelerate at module scope, so
monkeypatch.setattr("...model_load_utils.unplaced_source_keys", ...) (which resolves
the dotted path by importing the module for real) raised ModuleNotFoundError in the
accelerate-less partial-install env, even though the test never calls anything that
needs it. init_empty_weights is only used inside build_meta_causal_lm; moved the
import there so importing the module for its other functions no longer requires it.

multi-version (tf_min): _conversion_plan crashed on model.base_model_prefix for any
model that is not a real PreTrainedModel -- get_model_conversion_mapping() returns
generic, architecture-independent renamings (LayerNorm.gamma, weight_g/_v) for ANY
model, transformers or not, so _conversion_plan does not take its early-return path
and proceeds to build a plan needing an attribute only PreTrainedModel subclasses
carry. On tf_latest (get_model_conversion_mapping exists) this raised and was caught
upstream, silently degrading the union to the recorded set alone -- so the affected
tests only "passed" there by accident, masking the same defect tf_min surfaces
directly (get_model_conversion_mapping is None pre-5.x, so no plan is built at all,
and the structural pass runs against a test double with no matching parameters).
Made the attribute access defensive, matching the existing getattr pattern for
_checkpoint_conversion_mapping two lines above it.

That masking is also why fixing it exposed _ProvenanceModel as an unfaithful double:
its checkpoints carry a sentinel already-placed weight (a.weight / other.weight) that
was never actually a real parameter, so with the crash gone the union's structural
pass correctly starts flagging it as unplaced too, on both transformers versions.
Gave the model real parameters for both names so the fixture matches what it claims
to simulate; this also unblocks two tests that were already failing on tf_min before
this commit (test_carries_a_key_the_index_does_not_list,
test_warns_when_a_recorded_key_is_in_no_shard), not something this fix introduced.

Verified via uv-managed CPU envs for torch_214 x {tf_min=4.57, tf_latest=5.14}, plus
tf_latest with accelerate absent (partial-install simulation): tests/unit/torch/export/
+ tests/unit/torch/utils/test_model_load_utils.py all green in every combination.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…mpty

unplaced_keys_for only warned on a structural-pass exception when recorded was
empty -- so a non-empty recorded set made the failure completely silent, no
warning at all, even though recorded alone is not proof the union is complete
(the structural pass is what catches keys an architectures ignore rules already
dropped from the recorded set). Confirmed directly: monkeypatching
unplaced_source_keys to raise with a non-empty recorded set produced zero
warnings before this change.

This is the same silent-except pattern removed from read_unplaced_weights by
the previous commit, in the one call site that still had it -- and it is what
let the base_model_prefix crash go unnoticed on tf_latest in the first place:
the crash was real, the recorded set happened to be non-empty, so nothing was
ever reported. Warn unconditionally on structural failure instead.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…celerate

model_load_utils imports transformers at module scope too (AutoConfig,
AutoModelForCausalLM, the conversion-mapping try/except), so moving the
accelerate import inline only got partial-install (torch) past its first
missing module -- monkeypatch.setattr on a dotted path still imports the
module for real, and it now fails on transformers instead.

Chasing individual module-scope imports out of model_load_utils one at a
time is the wrong fix here: both tests exist specifically to pin behaviour
of transformers own loader (its ignore rules, its converters), so in an
env with no transformers they have nothing meaningful to assert regardless
of what model_load_utils imports. pytest.importorskip("transformers") says
that honestly. Patching unplaced_source_keys where it is used instead of
where it lives is not an option either: unified_export_hf imports it
inside the function body specifically so the module stays out of its own
import chain, which makes model_load_utils the only patch point a dotted
monkeypatch.setattr can resolve.

Scoped to the two tests, not the module: most of this file does not need
transformers, and a module-level skip would stop exercising the carry-over
logic in exactly the environment partial-install (torch) exists to check.

Verified against the actual partial-install shape this time (torch only,
neither transformers nor accelerate installed) rather than accelerate
missing with transformers still present, which is what let this through
in the previous commit: 194 passed, 0 failed, 22 skipped across
tests/unit/torch/export/ + tests/unit/torch/utils/test_model_load_utils.py.
Confirmed unchanged on tf_min/tf_latest (2 passed, not skipped, where
transformers is present).

Signed-off-by: Shengliang Xu <shengliangx@nvidia.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.

3 participants