Skip to content

[OMNIML-6063] [3/3] Load a checkpoint its model spec decompresses through the expert-parallel loader - #2747

Open
shengliangxu wants to merge 13 commits into
shengliangx/hfep-11a1-decompress-expertsfrom
shengliangx/hfep-11a-onfly-decompress
Open

shengliangxu wants to merge 13 commits into
shengliangx/hfep-11a1-decompress-expertsfrom
shengliangx/hfep-11a-onfly-decompress

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Split at the reviewers' request (2026-10-30): this PR is now [3/3], the expert-parallel loader's side of on-the-fly decompression.

Base: #3137. The diff below is this PR's alone, and it merges after #3137.

Series layout

What does this PR do?

Type of change: New feature

load_and_prepare_ep takes a checkpoint whose model spec decompresses its experts as they load (#3137), which its format check refused before.

  • Format check. A config the decompressor marked passes the check; an unmarked compressed-tensors checkpoint is still refused.
  • No competing quantizer. On the marked path the load deletes quantization_config from its copy of the config, at the top level and on text_config and llm_config: every level _ckpt_quant_method reads. So transformers builds no compressed-tensors quantizer to compete with the decompressor.
    • Kimi-K2.5's config class copies its text config's quantization_config to the top level, and from transformers 5.13 that quantizer claims the experts' converters itself.
    • It is deleted rather than set to None: before 5.11, transformers takes any quantization_config attribute, None too, for a quantized checkpoint.
    • The caller's config keeps its own.
  • Stored tensors checked before anything loads (check_compressed_keys). A class target such as Linear does not say what was compressed, and the load drops the config that would load any other compressed weight. So every weight stored compressed must be an expert's, and every expert's weight stored compressed. No compressed tensor may be a float that bf16 cannot hold exactly.
  • No compressed tensor carried unread. The export copies the checkpoint tensors the model has no parameter for as stored, and this load dropped the config that would read a compressed one. So once the load knows those tensors, it refuses any that is compressed: an MTP layer's compressed experts, say, which pass the stored-tensor check but sit in no layer transformers builds. Uncompressed ones are carried as before.
  • Collective checks. The format check and the deletion run inside the loader's collective checks ([OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136), and so does the stored-tensor check, right after the checkpoint is made local. The unplaced-tensor refusal runs inside the load's own collective block. So a refusal on one rank fails every rank.

Non-expert-parallel loads. A spec's prepare installs the decompressor and marks the config, and it runs only on expert-parallel routes:

  • the plan decision runs it when it may plan expert parallelism, and the expert-parallel load runs it;
  • an EP degree of 1 and a single process skip it;
  • the FSDP2 load refuses a quantized checkpoint and a VLM before it loads.

So a marked config reaches from_pretrained only through this load. What outlives a prepare in the process is inert for other loads: the conversion-mapping hook rewrites only a marked config's converters.

Usage

Internal to the Kimi adapters (#2748, #2749), whose prepare installs the decompressor; the expert-parallel load does the rest.

Testing

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Additive; only a config a model spec marked takes the new path.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A. The feature's entry lands with [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754.
  • Did you get Claude approval on this PR?: ❌ Not since the split.

Additional Information

Part of expert-parallel PTQ for Hugging Face MoE models, [3/3] of #2747's split. It merges after #3137.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in 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: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: d5c786aa-aeec-4bdf-a402-a56454302b13
📥 Commits

Reviewing files that changed from the base of the PR and between b769406 and ff2c0bf.

📒 Files selected for processing: 1
  • noxfile.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 7 remain after this review.

Walkthrough

The change adds on-load decompression for compressed-tensors MoE expert weights, hooks for expert ordering and dtype preservation, and a Transformers loader wrapper that stages missing sibling Python files for local checkpoints.

Changes: MoE Weight Decompression

Layer / File(s) Summary
Compression and backbone configuration
modelopt/torch/models/_onfly_decompress.py, tests/unit/torch/models/test_onfly_decompress.py
Adds native backbone config conversion and quantization-config lookup. The lookup selects a registered compressor and its stored parameter names. Tests cover config conversion and compression-group selection.
Expert decompression and loading
modelopt/torch/models/_onfly_decompress.py, tests/unit/torch/models/test_onfly_decompress.py
Rewrites per-expert weight converters to provide stored tensors for decompression. Expert tensors are ordered numerically, and integer source tensors are not cast to floating dtypes. Tests check decompression, fusion, ordering, and dtype behavior.
Hook installation and validation
modelopt/torch/models/_onfly_decompress.py, tests/unit/torch/models/test_onfly_decompress.py, noxfile.py
Adds config marking and process-wide hook installation. Tests cover converter selection and hook lifecycle. The unit Nox session installs compressed-tensors except for the torch_28 and torch_29 matrix entries.

Local Remote-Code Staging

Layer / File(s) Summary
Stage sibling Python files
modelopt/torch/models/_remote_code.py
Wraps Transformers’ cached-module loader to copy missing sibling Python files for local checkpoints. Copy failures produce a warning, and the wrapper returns the original resolved path.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Suggested reviewers: kevalmorabia97

Sequence Diagram(s)

sequenceDiagram
  participant TransformersWeightLoader
  participant WeightConverter
  participant MergeModulelist
  participant RegisteredCompressor
  TransformersWeightLoader->>WeightConverter: Provide stored expert tensors
  WeightConverter->>MergeModulelist: Pass anchor and sibling tensors
  MergeModulelist->>RegisteredCompressor: Decompress expert parameters
  RegisteredCompressor-->>MergeModulelist: Return bf16 expert weights
  MergeModulelist-->>WeightConverter: Emit weights under anchor keys
Loading
Merge Risk: Low · up to ff2c0

Multi-rank loads of local checkpoints may fail if a rank imports a sibling Python module while another rank is copying it. This is a bounded startup risk, but it remains open for this merge.

Pre-merge checks: Passed 5
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 PASS. The reviewed changes add no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or # nosec commen…
Title check Passed The title accurately identifies the main change: loading checkpoints with on-the-fly decompression through the expert-parallel loader. Although the wording is slightly awkward, it is specific and rela…
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The reviewed changes add no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or # nosec comments. The PR does not modify pyproject.toml or requirements.txt; its compressed-tensors installation is limited to noxfile.py.

Full details: Title check

Explanation

The title accurately identifies the main change: loading checkpoints with on-the-fly decompression through the expert-parallel loader. Although the wording is slightly awkward, it is specific and related to the changeset.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9-ep-model-specs branch from 2142693 to 2e134ec Compare October 12, 2026 02:35
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch from 95832dd to e243f04 Compare October 12, 2026 02:35
@github-actions

github-actions Bot commented Oct 12, 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-2747/

Built to branch gh-pages at 2026-10-27 00:05 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.65471% with 3 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (shengliangx/hfep-13-ep-export@2ec61a3). Learn more about missing BASE report.

Files with missing lines Patch % Lines
modelopt/torch/models/_onfly_decompress.py 98.88% 2 Missing ⚠️
modelopt/torch/models/_remote_code.py 96.87% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                        @@
##             shengliangx/hfep-13-ep-export    #2747   +/-   ##
================================================================
  Coverage                                 ?   62.46%           
================================================================
  Files                                    ?      658           
  Lines                                    ?    74106           
  Branches                                 ?        0           
================================================================
  Hits                                     ?    46294           
  Misses                                   ?    27812           
  Partials                                 ?        0           
Flag Coverage Δ
unit 62.46% <98.65%> (?)

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.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test e243f04

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9-ep-model-specs branch from 2e134ec to fdd217a Compare October 12, 2026 08:39
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch from e243f04 to d6afd32 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test d6afd32

@shengliangxu shengliangxu changed the title [12/19] Decompress compressed-tensors MoE experts on the fly as they load [OMNIML-6063] [12/19] Decompress compressed-tensors MoE experts on the fly as they load Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch 2 times, most recently from 67355f4 to b162532 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9-ep-model-specs branch from 0a3e056 to 67f1c8f Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test b162532

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6063] [12/19] Decompress compressed-tensors MoE experts on the fly as they load [OMNIML-6063] [12/20] Decompress compressed-tensors MoE experts on the fly as they load Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9-ep-model-specs branch from 67f1c8f to 2f18815 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch from b162532 to 269d3d2 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu changed the base branch from shengliangx/hfep-9-ep-model-specs to shengliangx/hfep-9b-ep-load-routing October 13, 2026 19:39
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6063] [12/20] Decompress compressed-tensors MoE experts on the fly as they load [OMNIML-6063] [14/21] Decompress compressed-tensors MoE experts on the fly as they load Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch from 269d3d2 to dddc2ee Compare October 13, 2026 21:12
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test dddc2ee

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11a-onfly-decompress branch from dddc2ee to c668ec2 Compare October 14, 2026 00:14
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9b-ep-load-routing branch from 68f620d to 5c5cde0 Compare October 14, 2026 00:14
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +1812 to +1821
for cfg in (hf_config, *(getattr(hf_config, attr, None) for attr in _SUB_CONFIG_ATTRS)):
if cfg is not None:
vars(cfg).pop("quantization_config", None)
if hasattr(cfg, "quantization_config"):
# A class attribute or property, which the instance dict cannot clear.
raise NotImplementedError(
f"{type(cfg).__name__} keeps quantization_config off its instance, so "
"this load cannot clear it, and transformers' own quantizer would "
"compete with the decompressor. Decompress the checkpoint first."
)

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.

[SUGGESTION] The new refusal is order-dependent: the top level is popped and checked before text_config/llm_config have been popped. Any config class that exposes quantization_config at the top level as a forwarding class attribute — a @property returning self.text_config.quantization_config, which is exactly the "copies its text config's to the top level" shape the comment above describes, just implemented on the class rather than in __init__ — hits the hasattr while the sub-config still holds the dict, and the load refuses a checkpoint it could have handled. Clear all levels first, then verify: after text_config's dict is gone, such a property raises inside hasattr, which is False, and from_pretrained sees nothing to build a quantizer from.

            levels = [
                cfg
                for cfg in (hf_config, *(getattr(hf_config, a, None) for a in _SUB_CONFIG_ATTRS))
                if cfg is not None
            ]
            for cfg in levels:
                vars(cfg).pop("quantization_config", None)
            for cfg in levels:  # checked only once every level is cleared: a forwarding
                if hasattr(cfg, "quantization_config"):  # property reads a level cleared later
                    raise NotImplementedError(
                        f"{type(cfg).__name__} keeps quantization_config off its instance, so "
                        "this load cannot clear it, and transformers' own quantizer would "
                        "compete with the decompressor. Decompress the checkpoint first."
                    )

Two lines, and it makes the check exhaustive rather than order-sensitive — which is the property the block exists for. Non-blocking: the real Kimi configs you ran do the copy in __init__, so nothing is broken today, and the _Typed test case uses SimpleNamespace, which has no forwarding, so it would not catch a regression here either way. (Also note levels is what _onfly_decompress._config_levels already computes — see the duplication point raised last round.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken in dc0a618. The load now clears quantization_config from every level first and checks afterwards. A class that forwards its text config's through a property then holds none, while a class attribute or a property of its own is still refused.

The test now has a config class with such a forwarding property: it loads. It fails if the check runs level by level.

Comment on lines +2024 to 2033
# The export would copy them as stored, and this load dropped the quantization_config that
# reads a compressed one, such as an MTP layer's experts. A suffix is enough to tell them:
# check_compressed_keys refused any compressed tensor outside the experts.
if carried := [key for key in unplaced if key.rpartition(".")[2] in compressed_names]:
raise NotImplementedError(
f"The checkpoint stores {len(carried)} compressed tensors the model has no parameter "
f"for, such as {carried[0]}: the export would copy them as stored, with no "
"quantization_config to read them. Decompress the checkpoint first."
)

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.

[SUGGESTION] This refusal is sound only as long as model._weight_conversions is populated. _conversion_plans (line 188) is (_conversion_plan(model), _conversion_plan(model, loaded) if loaded else None), and the first of those goes through the patched get_model_conversion_mapping — whose patched has already delattr'd _MARK from model.config during from_pretrained, so is_marked is false and it returns the stock converters, which carry .experts.*.weight sources and resolve none of weight_packed/weight_scale/weight_shape. The rewritten converters are therefore visible only through _weight_conversions.

On a release that does not record it, every built expert's stored tensors land in unplaced, and this check refuses a perfectly good checkpoint with a message that says the model has no parameter for them — and len(carried) is then in the hundreds of thousands for Kimi-K3. The possibility is not hypothetical in this repo's own judgement: test_unplaced_source_keys.py:80's pre-existing _load helper, and your new test_compressed_experts_decompressed_at_load_are_placed, both pytest.skip on exactly _weight_conversions is None, so the one test that pins this path silently stops covering it on such a release while production hard-refuses.

Cheap fix: skip the refusal when the record is missing, so the behaviour degrades to the pre-PR carry rather than to a false block —

        unplaced = record_unplaced_source_keys(model, ckpt_path)
        # Only the record from_pretrained kept of the converters it ran resolves a compressed
        # expert's stored names: the stock mapping has .weight sources. Without it every expert's
        # would look unplaced, so there is nothing to tell an MTP layer's from them.
        if getattr(model, "_weight_conversions", None) is None:
            compressed_names = set()

or, if you would rather keep the block loud, raise a message that names the real cause (this transformers does not record the conversions from_pretrained ran) instead of blaming the checkpoint. Non-blocking, and moot if every transformers ≥ 5.8 records it — which I could not check here (neither transformers nor compressed_tensors is importable in this sandbox).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I checked this against every release the decompression supports, and it does not happen there.

convert_and_load_state_dict_in_model ends with model._weight_conversions = [c for c in weight_mapping if c.was_used()], unconditionally. That holds in transformers 5.8.0 (core_model_loading.py line 1436), 5.13.0 (line 1700), 5.14.0 (line 1735) and 5.18.0 (line 1798). The decompression requires 5.8, which install_onfly_decompress checks first. The rewritten converters match the stored keys, so they are always among the ones recorded.

test_compressed_experts_decompressed_at_load_are_placed pins that on a real load: it fails when unplaced_source_keys reads the stock mapping alone. Its skip on a missing record is the file's general guard for older releases, which the decompression does not run on.

A guard for a missing record would be dead code on every supported release, so I'd rather not add it.

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

Claude review (post-split round 2) — [3/3] decompressed checkpoints through the expert-parallel loader

Scope: full review — the trigger comment carried no scoping instructions. All 4 changed files opened (+385/-6): modelopt/torch/utils/plugins/model_load_utils.py (+49/-3), tests/gpu/torch/utils/test_expert_parallel_load.py (+133/-1), tests/unit/torch/utils/test_ep_loader.py (+125/-2), tests/unit/torch/utils/test_unplaced_source_keys.py (+78). Diff taken two-dot against origin/shengliangx/hfep-11a1-decompress-experts. I also read the base-branch _onfly_decompress.py (_config_levels, _mark/is_marked/stored_names, install_onfly_decompress, patched, check_compressed_keys) and unplaced_source_keys / _conversion_plans / record_unplaced_source_keys / _refused_together for the contracts the diff consumes.

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

№ Location Finding
1 model_load_utils.py:1812 The new class-attribute refusal is order-dependent: the top level is checked before the sub-configs are popped, so a forwarding quantization_config property would be refused instead of handled. Clear every level, then check every level.
2 model_load_utils.py:2024 The unplaced-compressed refusal resolves expert stored names only through model._weight_conversions; on a release that does not record it, every built expert looks unplaced and a good checkpoint is hard-refused with a message that blames the checkpoint. The one test covering this path skips on exactly that condition.

Neither blocks merge. Approving.

Since the last round

One commit, e2e7486b, closing findings 1 and 2 of post-split round 1. I checked the result rather than the message:

  • Finding 1 (silent vars().pop miss) is closed, and closed the right way. The hasattr re-check turns the one silent failure the block exists to prevent — a class attribute or attribute_map indirection surviving the instance-dict pop, after which from_pretrained builds the competing compressed-tensors quantizer anyway — into a NotImplementedError naming the class. It raises on the deepcopy only, so the caller's config is untouched on refusal, and it sits inside _refused_together("the expert-parallel load's checks", alone=False), which re-raises on every rank (model_load_utils.py:498-513), so the refusal is collective. The _Typed leg of test_a_compressed_checkpoint_a_spec_decompresses_as_it_loads_passes pins it and asserts not loaded; it fails without the check, since the old pop would have let the load through. My finding 1 is the residual ordering nit on the same lines, not a reopening.
  • Finding 2 (unexplained - {"weight"} and suffix-only matching) is closed as comments, which is what I asked for. Both are accurate against the base: check_compressed_keys' outside clause refuses any compressed stem without .experts., which is what makes key.rpartition(".")[2] safe, and - {"weight"} keeps a scheme whose compression_param_names starts with "weight" (naive-quantized) from flagging every plain unplaced weight. The exclusion cannot open a hole: such a scheme still stores weight_scale, so an MTP expert is caught by that name, and a scheme storing only weight is refused by the not compressed clause earlier.
  • Finding 3 (the config-level walk spelled out three times) is not addressed — still non-blocking, still true, now four times counting the new loop. Folded into finding 1, whose fix materializes the list naturally, rather than re-raised as its own thread.

The rest of the diff is byte-identical to round 1, so what I re-traced is what this commit could disturb plus the seams I had not pinned down.

What I traced and found sound

  • The mark survives the deletion, and the load-bearing ordering is intact. _MARK is a separate attribute, and patched captures (compressor, scheme, param_names, fmt) at install time rather than re-reading the config at mapping time, so dropping quantization_config cannot disarm the decompressor. patched delattrs _MARK from every level during from_pretrained, so compressed_names must stay captured at line 1907; re-deriving it at 2027 would raise stored_names' ValueError. This commit moves neither point.
  • The deletion cannot disturb the format bookkeeping. quant_method is computed at 1791, before the deletion, so the == "mxfp4" branch below is unaffected and no Mxfp4Config(dequantize=True) is injected to compete. A marked config whose own prepare already dropped quantization_config (Kimi-K3's) takes the quant_method is None branch and still gets the deletion, check_compressed_keys and compressed_names — the checks hinge on the mark, not on the config being quantized, which is the right hinge.
  • _ckpt_quant_method's _SUB_CONFIG_ATTRS refactor is behaviour-identical (("text_config", "llm_config")), so the cleared set at 1812 is exactly the read set at 1660 and stays so if the tuple grows.
  • Collective uniformity of all three refusals. Format check + deletion in _refused_together("the expert-parallel load's checks"); check_compressed_keys + compressed_names in "the local checkpoint", right after ensure_local_checkpoint (a rank-local, deterministic header read, no collectives); the unplaced refusal in "the expert-parallel load". The last matters most, since that block is followed by _seed_parallel_states' collectives and a rank-dependent raise there would strand the ranks that carried on — unplaced comes from the same headers and parameter names on every rank (EP sharding renames nothing) and record_unplaced_source_keys returns a sorted list, so len(carried) and carried[0] are rank-identical.
  • The refusal cannot fire falsely on a non-expert key. A *.weight_scale-shaped tensor outside the experts was already refused as outside, and a weight* sibling under a compressed stem that the scheme does not name by the unknown pass — so the suffix test can only match an expert-shaped stem.
  • Nothing a currently-working load can reach changes. compressed_names is empty on every unmarked path, so carried is empty; the only other delta is one and not _decompressed_as_loaded(...) that can only widen what the load accepts. record_unplaced_source_keys already returned list[str] in the base — this PR only starts consuming it, hence the two monkeypatches moving from None to []. No modelopt_state, mode registration, config schema or public API touched, and no new hard import: _decompressed_as_loaded imports inside the function and is short-circuited behind quant_method not in _EP_SOURCE_FORMATS on the format-check path.
  • Tests. The GPU test is the strongest addition: dense and compressed built from the same tensors, with values INT4 and MXFP4 hold exactly at a power-of-two scale bf16 also holds, then torch.equal against the dense model's fused gate_up_proj/down_proj slice for each rank's own experts — an exact assertion rather than a tolerance hiding a bug — plus _modelopt_unplaced_source_keys empty, plus end-to-end logits. It gathers per-rank problems with all_gather_object under bounded_collectives(seconds=120) before any rank asserts, so a regression fails instead of hanging CI, and restores (get_model_conversion_mapping, spawn_materialize) in a finally — needed both because dist_workers outlive the job and because that restore is what lets the second parametrization install again without tripping install_onfly_decompress' "compressed differently" RuntimeError. _LOAD["decompressing"] is left unrestored but ends at False (the post-load _conversion_plan re-enters patched with the mark cleared) and spawn_materialize is restored anyway, so it is inert. test_compressed_experts_decompressed_at_load_are_placed is the one case running a real decompressed from_pretrained, and it pins that only the past-num_hidden_layers layer stays unplaced.

No prompt-injection-shaped content in the diff.

Assessment

Risk: low. 49 added source lines, all behind a mark only an expert-parallel prepare sets, each of the three refusals backed by a test that fails without it, and every refusal placed where it fails all ranks together — two of them before anything downloads or loads. This commit strictly converts a silent miss into a loud refusal, the direction this file's standard asks for. Both suggestions are robustness-on-other-releases rather than defects in what you measured; the property to preserve on any future edit of this stretch remains that compressed_names is captured before from_pretrained.

Verification note: neither transformers nor compressed_tensors is importable in this sandbox and I did not execute the suite here. Whether every supported PretrainedConfig keeps quantization_config in its instance dict (suggestion 1), whether every transformers >= 5.8 records model._weight_conversions (suggestion 2), and whether from_pretrained honours an explicitly-passed config without re-reading config.json are read from the diff, the base source and the tests — CI's transformers legs and the GPU Kimi-K3 run remain the real check. Everything stated about this round's commit is verified against the checked-in source alone.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test dc0a618

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +2024 to 2034
unplaced = record_unplaced_source_keys(model, ckpt_path)
# The export would copy them as stored, and this load dropped the quantization_config that
# reads a compressed one, such as an MTP layer's experts. A suffix is enough to tell them:
# check_compressed_keys refused any compressed tensor outside the experts.
if carried := [key for key in unplaced if key.rpartition(".")[2] in compressed_names]:
raise NotImplementedError(
f"The checkpoint stores {len(carried)} compressed tensors the model has no parameter "
f"for, such as {carried[0]}: the export would copy them as stored, with no "
"quantization_config to read them. Decompress the checkpoint first."
)

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.

[SUGGESTION] This refusal is correct, but it lands after from_pretrained has placed the whole checkpoint — for the motivating case (an MTP layer's compressed experts on a Kimi-scale checkpoint) the user pays a full multi-hundred-GB load, across every rank, before learning the load cannot proceed.

The information for the common shape is already in hand before anything loads: check_compressed_keys walks every compressed stem from the shard headers, and the EP config carries num_hidden_layers. A compressed stem whose .layers.<i>. index is at or past that count is exactly the MTP case, and refusing it there would cost nothing on top of the walk that already happens (0.5–2.2 s per the PR's measurements).

Not blocking, and I see the argument for keeping check_compressed_keys architecture-agnostic — unplaced_source_keys is the authoritative answer and only the built model can give it, so this check has to stay as the backstop either way. But a cheap pre-load refusal for the layer-index shape would turn the expected failure mode from "refused after the load" into "refused before the download", and this one would remain for anything the index heuristic misses.

(Checked and fine: record_unplaced_source_keys returns list[str], the suffix match is unambiguous because check_compressed_keys has already refused any compressed tensor outside .experts., every rank computes the same unplaced under EP — non-local experts still resolve to a parameter name that exists on the rank — and the raise sits inside _refused_together("the expert-parallel load"), so one rank refusing fails all.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken in 9bc105a. Before anything loads, the loader now refuses compressed tensors stored at a layer index at or past the EP config's num_hidden_layers, which is where an MTP layer sits; the error names the first such tensor and the layer count. It reads only the checkpoint's tensor names, the same walk check_compressed_keys makes, once more. The refusal after the load stays for any other shape, and a config without num_hidden_layers skips the early check.

It lives in the expert-parallel loader, which has the EP config, so check_compressed_keys stays architecture-agnostic. On transformers 5.18 no model builds a decoder layer past num_hidden_layers: DeepSeek-V3, GLM-4-MoE and Step-3.7 ignore their MTP layers on load.

The loader test's new [compressed-past-the-layers] case checks the refusal and that nothing loads; it fails without the change. The other two cases now put the stored experts within the layer count, so the early check passes them, and fail if it refuses every layer.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9bc105a

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +1970 to +1978
built = getattr(ep_cfg, "num_hidden_layers", None)
past = sorted(
name
for name in checkpoint_tensor_files(ckpt_path)
if name.rpartition(".")[2] in compressed_names
and (layer := re.search(r"(?:^|\.)layers\.(\d+)\.", name))
and built is not None
and int(layer[1]) >= built
)

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.

[SUGGESTION Performance] This scans every shard header a second time. check_compressed_keys (line 1963) already called checkpoint_tensor_files(ckpt_dir) and built the full tensor name -> file dict; calling it again here re-reads the index plus every shard header. On the checkpoints the description measures that is 0.5–2.2 s of cold header reads duplicated, on every rank (both calls sit inside the same _refused_together block), and it rebuilds a ~497k-entry dict for K3.

Two smaller points in the same comprehension:

  • built is not None is the last condition, so when ep_cfg has no num_hidden_layers the regex still runs once per tensor name and the whole scan is wasted. It is loop-invariant — hoisting it out skips the scan entirely in that case.
  • check_compressed_keys already has the compressed stem set and receives hf_config, so the layer-bound question is the same question it is answering ("which compressed tensors would load wrong"). Folding this refusal in there, or having it return the names/stems it collected for reuse here, removes the second scan and keeps the two compressed-key refusals in one place.

Concretely, the cheapest version that keeps the check where it is:

            tensor_names = _onfly_decompress.check_compressed_keys(hf_config, ckpt_path)
            compressed_names = set(_onfly_decompress.stored_names(hf_config)) - {"weight"}
            built = getattr(ep_cfg, "num_hidden_layers", None)
            past = (
                sorted(
                    name
                    for name in tensor_names
                    if name.rpartition(".")[2] in compressed_names
                    and (layer := re.search(r"(?:^|\.)layers\.(\d+)\.", name))
                    and int(layer[1]) >= built
                )
                if built is not None
                else []
            )

(with check_compressed_keys returning its tensor_files — it is the only caller-visible change, and it is in #3137.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken. check_compressed_keys now returns the tensor name -> file map it read (7a56a68, in #3137). The early refusal scans that map instead of reading the index and every shard header again (23eb10d). Without num_hidden_layers on the EP config, it skips the scan entirely.

Comment on lines +1979 to +1985
if past:
raise NotImplementedError(
f"The checkpoint stores {len(past)} compressed tensors past the model's {built} "
f"decoder layers, such as {past[0]} (an MTP layer): the model has no parameter "
"for them, and the export would copy them as stored, with no quantization_config "
"to read them. Decompress the checkpoint first."
)

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.

[SUGGESTION] This early refusal is strictly broader than the post-load one it front-runs: the carried check at line 2096 refuses compressed tensors the model has no parameter for, while this one refuses any compressed expert tensor whose layer index is >= num_hidden_layers, placed or not. Today the two coincide — no transformers release builds decoder layers past num_hidden_layers, so a num_nextn_predict_layers MTP block is never placed — but if one ever does (DeepSeek-V3/Kimi-style MTP built as a real module), this refuses a load the carried check would have let through, and the only way out is to re-materialize the checkpoint.

Not worth restructuring for; what would help is making the message say what was actually observed rather than asserting a cause, since (an MTP layer) is an inference from the index:

Suggested change
if past:
raise NotImplementedError(
f"The checkpoint stores {len(past)} compressed tensors past the model's {built} "
f"decoder layers, such as {past[0]} (an MTP layer): the model has no parameter "
"for them, and the export would copy them as stored, with no quantization_config "
"to read them. Decompress the checkpoint first."
)
if past:
raise NotImplementedError(
f"The checkpoint stores {len(past)} compressed tensors past the model's {built} "
f"decoder layers, such as {past[0]} (an MTP layer's, typically): the model builds "
"no parameter for them, and the export would copy them as stored, with no "
"quantization_config to read them. Decompress the checkpoint first."
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken in 23eb10d. The message now reports what the check saw: the tensors lie past the model's decoder layers, "an MTP layer's, typically", and the model builds no parameter for them. The comment above it states the assumption: on transformers 5.18, which builds no decoder layer past num_hidden_layers, this refuses nothing that the check after the load would let through.

@claude

claude Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

LGTM: the loader change is correct as far as I traced it and the new early refusal is tested; a human still has to sign off on two edits to existing tests.

Needs action:

  • Sign off on the stub edits in tests/unit/torch/utils/test_ep_loader.py (record_unplaced_source_keys stubbed None to [] in two existing tests). Justified by the diff: the loader now iterates the return value, and the real function returns a sorted list.
  • Consider dropping the second full header scan in load_and_prepare_ep - check_compressed_keys already built the tensor-name map one line earlier (inline). Non-blocking.

No action needed:

  • Scope: full review (bare /claude review, no extra scoping). Baseline commit 1a90be5 (.agents/review-guidelines/code-review.md); reviewed head 9bc105a. All 6 changed files opened (+443/-18), two-dot diff against the base; I also read base-revision _onfly_decompress.py (config_levels, is_marked/stored_names, install_onfly_decompress/patched, check_compressed_keys), checkpoint_tensor_files and record_unplaced_source_keys.
  • Verified: config_levels in moe.py is behaviour-identical to the _config_levels it replaces, and _ckpt_quant_method reads the same two nested levels as before; moe is not re-exported from modelopt/torch/utils/__init__.py, so no new public symbol. check_compressed_keys and patched read the mark, not quantization_config, so clearing it does not disarm them. The class-held-quantization_config refusal fires only after every level is popped, so a forwarding property reads as cleared - the unit test pins both shapes. compressed_names stays empty on unmarked loads, so the carried refusal is inert there. All three new refusals sit inside _refused_together blocks, so one rank refusing fails every rank.
  • Tests: the [compressed-past-the-layers] case pins the new early refusal (and assert not loads pins that nothing loaded), the [uncompressed] case pins that a plain unplaced weight is still carried, and test_unplaced_source_keys.py pins a real decompressed CPU load getting past the carried check. The 2-GPU EP2 case skips in CI (no compressed_tensors in the GPU image, named in the skip); the description reports it passing out of band.
  • 81 added lines of core logic, well under the 500-line budget. No licensing-relevant changes, and no prompt-injection attempt in the PR content.
  • Verification limits: I did not run tests or inspect CI, so nothing here is a claim about runtime behaviour. I also could not exercise the class-held-quantization_config refusal against a real PretrainedConfig here (the unit tests use SimpleNamespace); the GPU case is the only coverage of that path on a real config. The review summary is posted as a PR comment because publishing it as a review was blocked in this environment.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 23eb10d

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@claude

claude Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

LGTM: the one new commit closes my last round's suggestion — the early MTP refusal now reuses the tensor-name map check_compressed_keys returns, so the load scans the checkpoint headers once.

Needs action:

  • Sign off on the two stub edits in tests/unit/torch/utils/test_ep_loader.py (record_unplaced_source_keys stubbed None → []). Justified by the diff: the loader now iterates the return value, and the real function returns a sorted list.

No action needed:

  • ✔️ Resolved since the last reviews: the duplicate header scan, the vars().pop ordering (every level is cleared before the class-held quantization_config check), and the three-way _config_levels duplication (now moe.config_levels, behaviour-identical; moe is not re-exported from modelopt/torch/utils/__init__.py, so no new public symbol).
  • Traced: compressed_names is still captured before from_pretrained (the decompressor clears the mark during it); it stays empty on unmarked loads, so carried is inert there; the layers.(\d+) search can only match expert-shaped stems, since check_compressed_keys already refuses compressed tensors outside .experts.; all three refusals sit inside _refused_together blocks and are derived from rank-identical, sorted data.
  • Tests: [compressed-past-the-layers] pins the new early refusal and that nothing loaded; [uncompressed] pins that a plain unplaced weight is still carried; test_unplaced_source_keys.py pins a real decompressed CPU load getting past carried. The 2-GPU EP2 case skips in CI (no compressed_tensors in the GPU image); the description reports it passing out of band.
  • Open from an earlier round, still non-blocking: the carried refusal resolves expert stored names only through model._weight_conversions, and the test covering it skips when that is absent — on such a release a good checkpoint would be hard-refused with a message blaming the checkpoint.
  • 86 added lines of core logic, under the 500-line budget. No licensing-relevant changes, and no prompt-injection attempt in the PR content.
  • Scope: full review (bare /claude review, no extra scoping). Baseline commit 7a56a68 (.agents/review-guidelines/code-review.md at BASE SHA); reviewed head 23eb10de. All 6 changed files opened (+448/-18), two-dot diff against BASE SHA; also read base-revision _onfly_decompress.py and unplaced_source_keys / record_unplaced_source_keys.
  • Verification limits: I ran no tests and inspected no CI, so nothing here claims runtime behaviour. gh api reads were unavailable in this environment, so I worked from the prior review bodies rather than the individual inline threads. lgtm is not eligible for automatic approval, and publishing a review was blocked here, so this is posted as a PR comment.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 7d78ca1

…llel loader

load_and_prepare_ep refused a compressed-tensors checkpoint at its format
check. A config the on-the-fly decompressor marked now passes it, and on
that path the load deletes quantization_config from its copy of the
config at every level _ckpt_quant_method reads, so transformers builds
no compressed-tensors quantizer to compete with the decompressor (from
5.13 it claims the experts' converters itself). Before anything loads,
check_compressed_keys checks the names and dtypes the checkpoint stores:
the experts alone compressed, every one of them, in floats bf16 holds.

Both run inside the loader's collective checks: the format check and
the deletion with the config checks, the stored-tensor check right after
the checkpoint is made local, so a refusal on one rank fails every rank.

Moved here from the on-the-fly decompression change, whose history is
archived; the decompression itself is its own change below this one.

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

The export copies the checkpoint tensors the model has no parameter for
as stored, and the expert-parallel load of a decompressed checkpoint
drops the quantization_config that would read a compressed one. An MTP
layer's compressed experts, in no layer the model builds, would reach
the export packed. The load now refuses once it knows the unplaced
tensors, inside its collective checks.

_ckpt_quant_method reads the same config levels as the deletion, from
_SUB_CONFIG_ATTRS, and the deletion pops from the instance dict.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…the real one does

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The expert-parallel load refuses compressed tensors the model leaves
unplaced, and relies on unplaced_source_keys placing the stored
weight_packed / weight_scale / weight_shape of every expert the model
builds. A CPU from_pretrained of a tiny compressed Qwen3-MoE, through the
real decompressor, pins that: they resolve through the converters the
load recorded (the stock mapping places none of them), and only the
experts of a layer past num_hidden_layers stay unplaced.

The two local modelopt.torch.models imports say why they are local, and
the loader test's docstring says what its stand-in covers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ssing its own

A tiny Qwen3-MoE whose experts are compressed-tensors INT4 (Kimi-K2.5's
format) or MXFP4 (Kimi-K3's), installed for on-the-fly decompression and
loaded through load_and_prepare_ep: each rank must hold exactly the dense
model's weights for its own experts, every stored tensor must be placed,
and the sharded model's logits must match the dense model loaded whole.
The MXFP4 case goes through compressed-tensors, which the decompressor
reads, not transformers' own MXFP4 dequantization. Each rank's checks are
gathered before any rank fails, under bounded collectives.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The decompressor's keep-integers patch moved from _materialize_copy to
spawn_materialize (#3137), so the 2-GPU job and the real-load unplaced
test restore that hook. The GPU case's skip reason names the package it
needs: compressed-tensors is not in CI's GPU image, where it skips.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The real decompressing load sets the decompressor's load flag, which
outlives the load; the test now runs it on a flag of its own.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…e matchers hold

From Claude's review: the load clears quantization_config from each config
level's instance dict, which leaves a class attribute or property in
place, and transformers' own quantizer would then compete with the
decompressor. The load now refuses such a config inside its collective
checks. Comments say why "weight" is left out of the compressed names (a
float format's anchor, and a plain weight the export has always carried)
and why a suffix tells a carried compressed tensor (check_compressed_keys
refused any outside the experts).

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

From Claude's review: the top config and its nested language-model
configs were walked three times, in _ckpt_quant_method, in the load's
quantization_config deletion and in the decompressor. moe.config_levels,
beside _SUB_CONFIG_ATTRS, now gives those levels to all three. (#3135 adds
the same _SUB_CONFIG_ATTRS; the helper beside it conflicts there once, at
the Kimi base's rebuild.)

The deletion now clears every level before it checks any: a config class
that forwards its text config's quantization_config through a property
holds none once the text config's is gone, and is no longer refused; a
class attribute, or a property of its own, still is.

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

The EP loader tests now build their stand-in MoE from the shared
model_with_decoder_at, and the file's own copy is gone.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
From Claude's review: the load refused the compressed tensors its model
leaves unplaced only once from_pretrained had read the whole checkpoint.
An MTP layer, the usual case, is stored past the decoder layers the EP
config says the model builds, and the checkpoint's tensor names show
that before anything loads. Its compressed experts are now refused
there; the check after the load stays for any other shape.

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

From Claude's review: the early refusal read the checkpoint's tensor
names and every shard header a second time, right after
check_compressed_keys had, and scanned them even for a config with no
num_hidden_layers. It now takes the map check_compressed_keys returns,
and skips the scan without a layer count. Its message says what it
found, an MTP layer's typically, rather than assert the cause.

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

The expert-parallel load is to read the config's model type for its spec checks, so each config standing in for a checkpoint's names one, as the moved loader tests' do. save_file is already imported at the top of the file the tests moved into.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 6b87b69

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants