Skip to content

fix(export): honor sub-model scope_prefix in quant-aware reverse rename (NVBug 6525511) - #2076

Merged
yueshen2016 merged 5 commits into
mainfrom
fix/nvbug-6525511-6525597
Aug 6, 2026
Merged

fix(export): honor sub-model scope_prefix in quant-aware reverse rename (NVBug 6525511)#2076
yueshen2016 merged 5 commits into
mainfrom
fix/nvbug-6525511-6525597

Conversation

@yueshen2016

@yueshen2016 yueshen2016 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix

Fixes NVBug 6525511 / OMNIML-5599: FP8 PTQ of llava-1.5-13b on transformers>=5.12 produces a checkpoint vLLM refuses to load:

ValueError: There is no module or parameter named 'vision_model' in LlavaForConditionalGeneration

A cross-architecture sweep run for this PR shows google/gemma-3-4b-it is broken the same way (884/884 keys mangled) and is fixed by the same change, though no bug was filed for it.

Root cause

transformers collects conversion mappings recursively and tags each sub-model's transforms with the sub-module path they belong to, then matches only keys under that prefix (WeightTransform._scoped_match: strip scope_prefix → match → re-attach):

transform.scope_prefix = scope_prefix
transform.base_model_prefix = model.base_model_prefix

LlavaForConditionalGeneration therefore carries the vision tower's own PrefixChange — "add a vision_model. prefix" — scoped to model.vision_tower:

[5] rev=PrefixChange
    scope_prefix    = 'model.vision_tower'
    source_patterns = ['^(?:(?!vision_model\.))(.+)$']
    target_patterns = ['vision_model.\1']

_build_reverse_rules read rev.source_patterns / rev.target_patterns raw and discarded scope_prefix. Read as an unscoped regex, that pattern means "any key not already starting with vision_model." — i.e. everything. All 758 llava-1.5-13b tensors were moved under a bogus top-level vision_model.:

model.language_model.layers.0.self_attn.q_proj.weight
  -> vision_model.model.language_model.layers.0.self_attn.q_proj.weight
lm_head.weight
  -> vision_model.lm_head.weight

This is the same class of defect as #2032 (NVBug 6525534), but that fix's shadowing heuristic cannot catch it: it drops a rule whose target is an existing namespace, whereas this rule invents one (vision_model exists nowhere in the module tree).

The fix

RenameRule carries scope_prefixes; _sub_scoped applies a scoped rule only to keys under one of them — stripping the prefix before the match and re-attaching after, mirroring transformers' own semantics (trying base_model_prefix + scope_prefix before scope_prefix). The same scoping flows through build_reverse_name_mapper, so exclude_modules (which lists the BF16 vision tower) stays aligned with the weights. _drop_shadowed_prefix_renames skips scoped rules, which are already confined to their subtree.

Converter-derived rules (_expert_leaf_renames, _dense_split_rule) match by module suffix rather than an anchored pattern, so they cannot be confined to a subtree the same way. A survey of 9 architectures (LLaVA, LLaVA-Next, Gemma-3, Gemma-4, Qwen2-VL, Qwen3-VL-MoE, Llama-4-Scout, Mixtral, DeepSeek-V2-Lite) found every WeightConverter has scope_prefix=None — transformers only scopes WeightRenaming/PrefixChange — so the case is unreachable today. Rather than emit rules that could silently reach a sibling namespace if that ever changes, a scoped converter now raises QuantConversionUnsupportedError and the caller falls back to in-memory names with a warning.

Testing

Name-level oracle against the real hub checkpoint. Exported names must equal the original checkpoint's keys. All 758 llava-hf/llava-1.5-13b-hf state-dict keys round-trip exactly:

MISSING (hub key not produced): 0
SPURIOUS (name not in hub)    : 0
exported: {language_model: 363, vision_tower: 391, multi_modal_projector: 4}
hub     : {language_model: 363, vision_tower: 391, multi_modal_projector: 4}

Pre-fix, 758/758 keys were mangled. This check involves no vLLM.

Cross-architecture regression sweep — old (unscoped) vs new (scoped) mapping over each model's real state dict:

Result Models
Identical (no behavior change) Llama-3.2, Qwen2.5, Qwen3, Mistral, Phi-3, SmolLM2, gpt-oss-20b, DeepSeek-V2-Lite, Mixtral-8x7B, gemma-2-2b, gemma-4-31B, Qwen2-VL-2B
Differs (fixed) llava-1.5-7b (686/686), gemma-3-4b-it (884/884)

Note Qwen2-VL carries a scoped rule yet is unchanged — the fix only bites where a scoped rule would have wrongly matched.

End-to-end, on the exact image from the bug report (vllm/vllm-openai:v0.26.0, verified vllm.__version__ == 0.26.0, transformers 5.14.1): real FP8 PTQ (general/ptq/fp8_default-kv_fp8_cast, --calib_size 512) on llava-1.5-13b, then served with the bug's exact api_server command.

PTQ_EXIT=0
top-level namespaces: {'language_model': 923, 'multi_modal_projector': 4, 'vision_tower': 391}
KEYS UNDER BOGUS vision_model.* : 0
occurrences of "no module or parameter named 'vision_model'": 0
Loading weights took 5.58 seconds
Model loading took 13.2 GiB and 7.45 seconds

The reported failure is gone and weight loading completes.

  • pytest tests/unit/torch/export116 passed, 1 skipped (3 new regression tests)
  • pre-commit run --files ... → all hooks pass

Additional Information

Out of scope, for whoever picks up the QA ticket. After this fix the bug's exact repro hits a different error: llava-1.5-13b ships "dtype": "float16" and vLLM's FP8 kernel requires BF16 output (RuntimeError: For FP8 input, output must have dtype BF16). This is not an export defect — it also occurs with kv_cache_dtype=auto, i.e. with the FP8 KV cache entirely out of the picture, and the same checkpoint loads and generates correctly under --dtype bfloat16 (" Paris. with a population of about 2,249,03"). QA will need --dtype bfloat16.

NVBug 6525597 (gemma-4-31B-it, assert layer.k_scale > 0.0) is unrelated to this PR: Gemma4 exposes zero transformers conversions, so this change provably does not touch it (confirmed identical in the sweep above). Re-tested separately on vLLM 0.26.0 with that bug's environment (transformers 5.5.0, TP=1, batch_size 8) it did not reproduce — the server reached Application startup complete with zero asserts — but that is tracked outside this PR.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved Hugging Face exports for multimodal models with nested prefix conversions.
    • Ensured vision-tower prefixes apply only within the correct model scope.
    • Prevented unrelated language-model and head parameters from being incorrectly renamed.
    • Preserved scoped behavior for module mappings and wildcard exclusions.
    • Added safer handling for unsupported scoped weight conversions.
  • Tests

    • Added regression coverage for scoped prefix handling across weights, module names, exclusions, and unsupported conversion scenarios.

…25511)

LLaVA PTQ on transformers>=5.12 exported every tensor under a bogus
top-level 'vision_model.' namespace, so vLLM refused the checkpoint with:

    ValueError: There is no module or parameter named 'vision_model'
    in LlavaForConditionalGeneration

Root cause: transformers collects conversion mappings recursively and tags
each sub-model's transforms with 'scope_prefix' (the sub-module path), then
matches only keys under that prefix -- see WeightTransform._scoped_match.
LlavaForConditionalGeneration therefore carries the vision tower's own
PrefixChange ('add a vision_model. prefix'), scoped to 'model.vision_tower'.

_build_reverse_rules read rev.source_patterns/target_patterns raw and dropped
scope_prefix, so that unanchored rule matched every key in the state dict:
all 758 llava-1.5-13b tensors were moved under 'vision_model.', including
'vision_model.language_model.*' and 'vision_model.lm_head.*'.

RenameRule now carries scope_prefixes and _sub_scoped applies a scoped rule
only to keys under one of its prefixes -- stripping the prefix before the
match and re-attaching it after, mirroring transformers' own semantics. The
same scoping is applied to quant-config module names via
build_reverse_name_mapper so exclude_modules stays aligned with the weights.
_drop_shadowed_prefix_renames now skips scoped rules, which are already
confined to their subtree and cannot reach a sibling namespace.

Verified against the real llava-hf/llava-1.5-13b-hf checkpoint: all 758
in-memory keys now map exactly onto the 758 hub keys (0 missing, 0 spurious)
across all three namespaces (language_model 363, vision_tower 391,
multi_modal_projector 4). Pre-fix, 758/758 were mangled.

Signed-off-by: James Shen <yueshen@nvidia.com>
A transform with scope_prefix == "" must still match every key (transformers
treats the empty prefix as an always-matching fallback in _scoped_match).
Dropping it from the candidate list would have confined such a rule to
base_model_prefix. Does not arise from the current transformers collection
path (root models leave scope_prefix None), but keeps the translation faithful.

Signed-off-by: James Shen <yueshen@nvidia.com>
@yueshen2016
yueshen2016 requested review from a team as code owners August 5, 2026 08:47
@yueshen2016
yueshen2016 requested a review from jenchen13 August 5, 2026 08:47
@yueshen2016

Copy link
Copy Markdown
Contributor Author

Follow-up evidence: the exported checkpoint is fully functional, and the residual float16 failure is conclusively a vLLM kernel constraint unrelated to this fix.

Same PTQ output loaded in vLLM 0.26 under three configurations:

dtype kv_cache_dtype Result
bfloat16 fp8 GENERATION: " Paris. with a population of about 2,249,03"
bfloat16 auto ✅ same correct output
float16 auto RuntimeError: For FP16/BF16 input, output must have the same dtype as inputs. For FP8 input, output must have dtype BF16

The decisive data point is the third row: float16 fails with kv_cache_dtype=auto as well, i.e. with the FP8 KV cache entirely out of the picture. So the residual error is FP8-weight + fp16-activation kernel support in vLLM, not the KV-cache path and not tensor naming. Under bfloat16 the checkpoint loads and answers the prompt correctly.

Worth noting for whoever picks up the QA ticket: llava-1.5-13b ships "dtype": "float16" in its config, so the bug's exact repro command will still fail at this later point until it is served with --dtype bfloat16. That is a serving-flag/vLLM-support matter rather than an export defect.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 5a82ccc9-cdda-48a5-a3a2-cebae64e81e7

📥 Commits

Reviewing files that changed from the base of the PR and between 3ad180d and 66bcbef.

📒 Files selected for processing (1)
  • CHANGELOG.rst
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.rst

📝 Walkthrough

Walkthrough

The export conversion pipeline applies scoped PrefixChange rules only within matching sub-model namespaces. Reverse weight, module-name, and configuration mappings preserve these scopes. Tests cover sibling namespaces, wildcard exclusions, and unsupported scoped WeightConverter reversals.

Changes

Scoped reverse conversion

Layer / File(s) Summary
Scope-aware rename engine
modelopt/torch/export/quant_aware_conversion.py
RenameRule stores scoped prefixes. Compiled helpers apply prefix stripping and restoration only within matching namespaces. Scoped rules remain excluded from global shadowing checks.
Reverse conversion integration and validation
modelopt/torch/export/quant_aware_conversion.py, tests/unit/torch/export/test_quant_aware_conversion.py, CHANGELOG.rst
Reverse weight, module-name, and configuration mappings use scoped rules. Scoped WeightConverter reversals raise QuantConversionUnsupportedError. Regression tests cover vision-tower keys, sibling namespaces, wildcard exclusions, and fallback behavior.

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

Suggested reviewers: jenchen13

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the export bug fix for scoped sub-model prefixes in quant-aware reverse rename.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed The PR adds no prohibited security patterns, # nosec comments, example Python changes, or dependency changes; existing matches are outside the PR diff.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/nvbug-6525511-6525597

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

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-06 03:26 UTC

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

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

Inline comments:
In `@modelopt/torch/export/quant_aware_conversion.py`:
- Around line 444-448: Update converter-derived rule generation in
_expert_leaf_renames() and _dense_split_rule() to retain and apply the
converter’s non-empty scope_prefix and base_model_prefix values when matching
tensors. Ensure the generated RenameRule and SplitRule use scope-aware matching
so scoped SplitModulelist and Chunk conversions do not affect sibling tensors,
and add regression coverage for sibling tensors.

In `@tests/unit/torch/export/test_quant_aware_conversion.py`:
- Around line 329-330: The local imports of PrefixChange in
tests/unit/torch/export/test_quant_aware_conversion.py at lines 329-330 and
369-370 need brief comments explaining that transformers.core_model_loading is
an optional dependency and pytest.importorskip() guards the tests before
importing it. Add the same rationale at both import sites without changing their
behavior.
🪄 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: 20ac793b-448c-4349-a90a-6d5c74cdc9df

📥 Commits

Reviewing files that changed from the base of the PR and between fed1980 and b3c3dbf.

📒 Files selected for processing (3)
  • CHANGELOG.rst
  • modelopt/torch/export/quant_aware_conversion.py
  • tests/unit/torch/export/test_quant_aware_conversion.py

Comment thread modelopt/torch/export/quant_aware_conversion.py
Comment thread tests/unit/torch/export/test_quant_aware_conversion.py
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.06%. Comparing base (2d4be28) to head (66bcbef).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #2076       +/-   ##
===========================================
+ Coverage   67.17%   78.06%   +10.89%     
===========================================
  Files         521      521               
  Lines       59857    59885       +28     
===========================================
+ Hits        40206    46749     +6543     
+ Misses      19651    13136     -6515     
Flag Coverage Δ
examples 43.04% <59.45%> (-0.20%) ⬇️
gpu 58.58% <59.45%> (+37.42%) ⬆️
regression 14.96% <13.51%> (+0.07%) ⬆️
unit 55.41% <100.00%> (+0.01%) ⬆️

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.

@yueshen2016

Copy link
Copy Markdown
Contributor Author

/claude review

…ports

Addresses CodeRabbit review on #2076.

1. Converter-derived rules (_expert_leaf_renames, _dense_split_rule) match by
   module suffix rather than an anchored pattern, so unlike WeightRenaming they
   cannot be confined to a sub-model subtree. Surveying 9 architectures
   (LLaVA, LLaVA-Next, Gemma-3, Gemma-4, Qwen2-VL, Qwen3-VL-MoE, Llama-4-Scout,
   Mixtral, DeepSeek-V2-Lite) every WeightConverter has scope_prefix=None --
   transformers only scopes WeightRenaming/PrefixChange -- so the case is not
   reachable today. Rather than emit rules that silently reach a sibling
   namespace if that ever changes, raise QuantConversionUnsupportedError so the
   caller falls back to in-memory names with a warning.

2. Annotate the two local PrefixChange imports in the tests, naming the reason
   (optional dependency guarded by pytest.importorskip) per the repo guideline.

pytest tests/unit/torch/export: 116 passed, 1 skipped.

Signed-off-by: James Shen <yueshen@nvidia.com>
Comment on lines +333 to +346
scope = getattr(rev, "scope_prefix", None)
if scope is None:
return ()
scope_dot = f"{scope}." if scope != "" else ""
base = getattr(rev, "base_model_prefix", None) or ""
base_dot = f"{base}." if base != "" else ""
# Deduplicate while preserving order. An empty candidate is kept: it only arises for
# ``scope_prefix == ""`` and, matching transformers, acts as the always-matching
# fallback that applies the pattern to the whole key.
seen: list[str] = []
for c in (base_dot + scope_dot, scope_dot):
if c not in seen:
seen.append(c)
return tuple(seen)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] base_model_prefix + scope_prefix builds a combined prefix, but transformers' _scoped_match uses base_model_prefix as an alternative root, not an additional segment — so a scoped rule can silently stop applying.

What's happening. For the llava case in this PR, scope_prefix = "model.vision_tower" and base_model_prefix = "model", so _scope_prefixes returns:

("model.model.vision_tower.", "model.vision_tower.")

The first candidate model.model.vision_tower. is not a real key prefix for this checkpoint — the model. base is already the leading segment of scope_prefix. It never matches, and the second candidate saves the fix. That is why the tests pass. But the two candidates are tried in order and only the first match wins (_sub_scoped returns on the first startswith), so the ordering only happens to be harmless here.

Why it matters. The reason transformers tries base_model_prefix at all is that a state dict may be keyed with or without the base prefix (the classic model. prepend/strip on PreTrainedModel vs PreTrainedModel.model). Those are two alternative spellings of the same subtree, e.g. vision_tower. vs model.vision_tower. — not model. + model.vision_tower.. Concatenating produces a prefix that matches nothing whenever scope_prefix already begins with the base, and misses the case the fallback exists to cover: a scope path relative to the base model (scope_prefix = "vision_tower", keys model.vision_tower.*). In that configuration this code yields ("model.vision_tower.", "vision_tower.") — correct by luck — but for scope_prefix = "model.vision_tower" with keys spelled vision_tower.*, neither candidate matches and the rule is dropped entirely: the vision tower ships unrenamed, missing its vision_model. segment, and the loader then fails on missing keys rather than bogus ones.

Suggested fix. Emit the base-relative and base-absolute spellings as genuine alternatives rather than a concatenation, e.g.:

scope = getattr(rev, "scope_prefix", None)
if scope is None:
    return ()
base = getattr(rev, "base_model_prefix", None) or ""
candidates = [scope]
if base:
    # The state dict may be keyed with or without the base-model prefix; these are two
    # spellings of the same subtree, not nested scopes.
    candidates.append(f"{base}.{scope}" if scope else base)
    candidates.append(scope.removeprefix(f"{base}."))
seen: list[str] = []
for c in candidates:
    c_dot = f"{c}." if c else ""
    if c_dot not in seen:
        seen.append(c_dot)
return tuple(seen)

Please also confirm the intended precedence against the installed WeightTransform._scoped_match — if transformers tries the base-prefixed form first, keep that order, but the candidates should be alternatives either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks -- I checked this carefully because adopting it would change how we agree with the loader, but I have to decline: the premise does not hold for the installed transformers.

You asked me to confirm the intended precedence against WeightTransform._scoped_match, so here it is verbatim from transformers 5.14.1 (the version in the bug's environment, inside vllm/vllm-openai:v0.26.0):

if self.scope_prefix is not None:
    scope_prefix = f"{self.scope_prefix}." if self.scope_prefix != "" else ""
    base_model_prefix = f"{self.base_model_prefix}." if self.base_model_prefix != "" else ""
    # First, try to match the longest sequence, i.e. base_model_prefix + scope_prefix
    if source_key.startswith(base_model_prefix + scope_prefix):
        prefix = base_model_prefix + scope_prefix
    # Then, try to strip the base_model_prefix, in case we load a ForXXX model from BaseModel weights
    elif source_key.startswith(scope_prefix):
        prefix = scope_prefix
    else:
        return None
    key_to_match = source_key.removeprefix(prefix)

transformers does concatenate -- base_model_prefix + scope_prefix literally, with its own comment calling it "the longest sequence" -- and tries that first, bare scope_prefix second. _scope_prefixes reproduces exactly that, same two candidates in the same order. So ("model.model.vision_tower.", "model.vision_tower.") for the llava case is not accidental; it is what the loader computes, and the first candidate failing to match is the intended path, not a near miss the tests happen to survive.

On the scope_prefix = "model.vision_tower" with keys spelled vision_tower.* scenario: you are right that neither candidate matches and the rule is dropped. But transformers takes return None on the very same input, so the loader would not apply the rename either. Emitting a third scope.removeprefix(f"{base}.") candidate would make ModelOpt rename tensors that transformers would not, which is the one thing this code must never do -- exported names have to match what the loader expects. If that two-branch behavior is wrong it is wrong upstream, and diverging unilaterally would turn a shared limitation into a silent mismatch.

One genuine imprecision you surfaced: the upstream docstring says the prefix may be scope_prefix "with one base_model_prefix level stripped or prepended", while the code only implements prepended. The code is authoritative here, and it is what I mirrored.

Leaving this as-is. Your second comment was a real bug and is fixed in 3ad180d.

Comment on lines 368 to +371
for rule in rules:
if rule.scope_prefixes:
kept.append(rule)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] The scope_prefixes-bypass of _drop_shadowed_prefix_renames regresses the #2032 fix for any rule whose scope resolves to an empty/root prefix.

The interaction. _scope_prefixes deliberately keeps an empty candidate for scope_prefix == "" (per the b3c3dbf commit message, "a transform with scope_prefix == "" must still match every key"). So such a rule has scope_prefixes truthy — e.g. ("model.", "") when base_model_prefix = "model", or ("",) when the base is empty — while _sub_scoped's empty candidate matches every key and applies the pattern to the whole key space, exactly like an unscoped rule.

But this early-continue keys off if rule.scope_prefixes: (non-empty tuple), not off whether the scope actually confines the rule. A root-scoped rule therefore takes the bypass and skips the shadowing guard entirely, even though its effective reach is the full state dict. That is precisely the situation #2032 (NVBug 6525534) added the guard for: test_nested_text_prefix_reverse_does_not_capture_vlm_siblings asserts that a ^model.language_model. reverse is dropped on a parent VLM, and build_reverse_name_mapper(model) returns None. If a transformers version ever tags that nested text conversion with scope_prefix = "" instead of leaving it None, the rule is kept, applied globally, and model.visual.* is rewritten to model.language_model.visual.* — reintroducing the earlier bug with no test covering it.

The docstring justifies the bypass as "a rule carrying scope_prefixes is already confined to its sub-model's subtree by _sub_scoped" — true for a non-empty prefix, false for the empty fallback the previous commit intentionally preserved. The two changes are individually reasonable and mutually inconsistent.

Suggested fix. Gate on effective confinement rather than tuple truthiness — a rule is confined only if every candidate prefix is non-empty:

    for rule in rules:
        # An empty scope candidate matches every key, so such a rule is NOT confined to a
        # subtree and still needs the shadowing heuristic.
        if rule.scope_prefixes and all(rule.scope_prefixes):
            kept.append(rule)
            continue

Worth a regression test with scope_prefix = "" on the existing model.visual / model.language_model VLM fixture, asserting siblings are untouched.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch -- accepted and fixed in 3ad180d.

You are right that the two commits were mutually inconsistent. _scope_prefixes deliberately keeps an empty candidate for scope_prefix == "", _sub_scoped matches that against every key, so the rule has whole-key-space reach -- yet if rule.scope_prefixes: is truthy and it took the bypass. Gating on tuple truthiness rather than on actual confinement was the error.

Applied your suggested gate:

    for rule in rules:
        # `all(...)` matters: an empty candidate matches every key, so a rule carrying one
        # is not actually confined and still needs the check below.
        if rule.scope_prefixes and all(rule.scope_prefixes):
            kept.append(rule)
            continue

and corrected the docstring, which claimed confinement for all scoped rules when that only holds for non-empty prefixes.

Also added the regression test you suggested, on the existing model.visual / model.language_model VLM fixture with scope_prefix = "", asserting the sibling vision namespace is untouched -- i.e. that the NVBug 6525534 guarantee still holds by the route you identified. It fails without the all(...) gate.

For the record on reachability: this is not triggerable from the current transformers collection path, which leaves scope_prefix as None for root models (is_root_model = module_name == "" only assigns a scope for non-root sub-models), so no shipping architecture produces scope_prefix == "". But the inconsistency was real and the fix is one line, so it is worth closing rather than arguing about reachability.

pytest tests/unit/torch/export: 117 passed, 1 skipped.

@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 — fix(export): honor sub-model scope_prefix in quant-aware reverse rename

Scope reviewed: full PR (3 files: modelopt/torch/export/quant_aware_conversion.py, tests/unit/torch/export/test_quant_aware_conversion.py, CHANGELOG.rst). All three opened, including the pre-existing _drop_shadowed_prefix_renames / _build_reverse_rules context the change interacts with. Note: transformers is not installed in this review environment, so the findings below are traced from the code and the PR body description of WeightTransform._scoped_match rather than executed against the real library — the precedence question in finding 1 is worth confirming against your installed version.

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

The diagnosis and the overall shape of the fix are right. Threading scope through RenameRule and applying it in _sub_scoped is the correct level to fix this at, the PR body is unusually well-evidenced (758/758 keys round-tripping is a strong oracle), and routing build_reverse_name_mapper through the same _apply_rename_rules path so exclude_modules stays aligned with the weights is the right call — that alignment bug is subtle and silent when it goes wrong.

Both findings concern the base_model_prefix / empty-scope handling in _scope_prefixes, not the core scoping mechanism. They do not affect the llava case this PR fixes (traced: for scope_prefix="model.vision_tower", base_model_prefix="model", the bogus first candidate model.model.vision_tower. simply never matches and the second candidate carries the fix), which is why the tests and the end-to-end run pass. They are latent-correctness issues in adjacent configurations.

  1. [IMPORTANT Compatibility] _scope_prefixes (L333–346)base_model_prefix + scope_prefix builds a combined prefix, but transformers uses base_model_prefix as an alternative root spelling (state dict keyed with vs. without the base prefix), not an extra nested segment. When scope_prefix already starts with the base — the actual llava case — the concatenation yields a prefix matching nothing. In the mirror configuration (scope_prefix="model.vision_tower", keys spelled vision_tower.*) neither candidate matches and the rule is dropped, so the vision tower ships without its vision_model. segment and the loader fails on missing keys instead of bogus ones.

  2. [IMPORTANT Compatibility] _drop_shadowed_prefix_renames bypass (L368–371) — the if rule.scope_prefixes: continue early-out keys off tuple truthiness, but b3c3dbf deliberately keeps an empty candidate for scope_prefix == "", and an empty prefix matches every key. Such a rule is therefore not confined to a subtree yet still skips the #2032 shadowing guard. Gate on rule.scope_prefixes and all(rule.scope_prefixes) instead.

One elaboration shared by both

Both have a common root: whether a conversion regex anchors are written relative to the scope-stripped suffix or to the full key. The fix assumes scope-stripped, correct for llava vision-tower PrefixChange (^(?:(?!vision_model\.))(.+)$ is relative to the tower own root). But combine that assumption with the empty-scope fallback and it turns self-defeating: for a root-owned rule with scope_prefix="", base_model_prefix="model", candidate model. strips the leading model. off model.layers.0.weight, leaving layers.0.weight — against which the ^model\.(?!language_model\.) pattern from test_nested_text_prefix_reverse_still_applies_to_text_model no longer matches, and the rename is silently lost. That test passes today only because the current collection path leaves scope_prefix as None for root models (as the b3c3dbf message notes). The empty-scope fallback added "to keep the translation faithful" is, as written, the one case where the translation is not faithful — either drop it (unreachable via the current path) or make it bypass prefix-stripping entirely rather than routing through base_model_prefix.

Not duplicated here

CodeRabbit already flagged that converter-derived rules (_expert_leaf_renames, _dense_split_rule) discard the converter scope_prefix — I agree it is a real gap and have not re-raised it. It is lower-risk than the WeightRenaming path, since those rules anchor on .experts.<i>.<leaf> / leaf suffixes rather than ^, so they cannot capture the whole key space the way the llava PrefixChange did.

Risk assessment

Low-to-moderate. The change is well-contained, gated behind the existing QuantConversionUnsupportedError fallback to legacy naming, and fixes a total-corruption bug (758/758 tensors mis-namespaced) with genuine end-to-end validation. Both findings are latent rather than active regressions against the current transformers collection path — but finding 2 failure mode is a silent reintroduction of the #2032 bug, and both are small fixes, so they are worth resolving before merge rather than after.

Addresses Claude review on #2076.

_scope_prefixes keeps an empty candidate for scope_prefix == "", and
_sub_scoped matches that against every key -- so such a rule has the reach of an
unscoped one. _drop_shadowed_prefix_renames gated on the tuple being non-empty,
which let a root-scoped rule skip the heuristic entirely and could reintroduce
NVBug 6525534 (a nested text model's ^model.language_model. reverse rewriting
model.visual.*). Gate on every candidate being non-empty instead, i.e. on the
rule actually being confined.

Not reachable from the current transformers collection path, which leaves
scope_prefix None for root models, but the two guards were mutually
inconsistent. Covered by a new regression test.

pytest tests/unit/torch/export: 117 passed, 1 skipped.

Signed-off-by: James Shen <yueshen@nvidia.com>
Comment thread CHANGELOG.rst Outdated
The bug is filed against 0.46.0 (Committed_ModelOpt_0.46.0) and the fix targets
that release, so the entry belongs in the 0.46 section -- next to the related
#2032 nested-VLM-namespace fix -- not 0.47. Also notes that
Gemma3ForConditionalGeneration is affected identically and fixed by the same
change, as found by the cross-architecture regression sweep.

Signed-off-by: James Shen <yueshen@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 (gpt-5.6-sol) — DM the bot to share feedback.

The fix correctly carries transformers sub-model scope into reverse rename rules, applies the same scoped mapping to tensor keys and quantization-config names, and safely rejects scoped converter operations that cannot yet be confined. The regression tests cover sibling isolation, wildcard/config mapping, the existing shadowing guard, and unsupported scoped converters. The change is focused, documented in the changelog, and includes meaningful unit and end-to-end validation.

@yueshen2016
yueshen2016 merged commit 089109d into main Aug 6, 2026
58 checks passed
@yueshen2016
yueshen2016 deleted the fix/nvbug-6525511-6525597 branch August 6, 2026 03:26
@kevalmorabia97 kevalmorabia97 added the cherry-pick-done Added by bot once PR is cherry-picked to the release branch label Aug 12, 2026
kevalmorabia97 added a commit that referenced this pull request Aug 12, 2026
#2159 #2112 (#2179)

## Cherry-picked PRs

- #1975
- #2076
- #2071
- #2093
- #2084
- #2115
- #2133
- #2146
- #2064
- #2159
- #2112

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added PTQ recipes for Nemotron model families and NVFP4 mixer-MLP
quantization.
* Added Qwen3-VL multimodal speculative-decoding support, including
video inputs.
* Added compatibility with multiple vLLM KV-cache layouts and newer
Transformers versions.
* Added a Nemotron 3.5 Lightning quantization-aware distillation
workflow.

* **Bug Fixes**
  * Improved Hugging Face, QLoRA, and PEFT checkpoint exports.
* Improved distributed job shutdown when a process encounters an error.
  * Improved pruning validation and candidate selection for MoE models.

* **Documentation**
  * Updated supported-model lists and recipe paths.
  * Removed Phi-3 Vision and Phi-4 Multimodal quantization support.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Slawomir Kierat <skierat@nvidia.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Signed-off-by: James Shen <yueshen@nvidia.com>
Signed-off-by: Zhiyu Cheng <zhiyuc@nvidia.com>
Signed-off-by: Kinjal Patel <kinjalpravin@nvidia.com>
Signed-off-by: Shiyang Chen <shiychen@nvidia.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
Signed-off-by: weimingc <17592131+meenchen@users.noreply.github.com>
Signed-off-by: Jennifer Chen <jennifchen@nvidia.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Co-authored-by: skierat <skierat@nvidia.com>
Co-authored-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
Co-authored-by: yueshen2016 <39203804+yueshen2016@users.noreply.github.com>
Co-authored-by: Zhiyu <zhiyuc@nvidia.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: kinjalpatel27 <31936134+kinjalpatel27@users.noreply.github.com>
Co-authored-by: sychen52 <41452870+sychen52@users.noreply.github.com>
Co-authored-by: Chenjie Luo <108829653+cjluo-nv@users.noreply.github.com>
Co-authored-by: Wei-Ming Chen <17592131+meenchen@users.noreply.github.com>
Co-authored-by: Jenny Chen <jennifchen@nvidia.com>
Co-authored-by: sugunav14 <178320438+sugunav14@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cherry-pick-0.46.0 cherry-pick-done Added by bot once PR is cherry-picked to the release branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants