Skip to content

Minitron pruning fixes for Nemotron-3.5-Lightning-30B-A3B and Deepseek - #2159

Merged
kevalmorabia97 merged 5 commits into
mainfrom
kmorabia/prune-nemotron-lightning-hf-export
Aug 12, 2026
Merged

Minitron pruning fixes for Nemotron-3.5-Lightning-30B-A3B and Deepseek#2159
kevalmorabia97 merged 5 commits into
mainfrom
kmorabia/prune-nemotron-lightning-hf-export

Conversation

@kevalmorabia97

@kevalmorabia97 kevalmorabia97 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: Bug fix + new feature

Two model families that could not be pruned end-to-end now can:

  • Nemotron-3.5-Lightning (nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16) — a native NemotronHForCausalLM that ships without remote code and carries MTP heads. Fixes a calibration crash and HF-export failures on the modern Megatron-Bridge / transformers stack.
  • DeepSeek-V3 — fixes an MLA Q-LoRA crash during calibration, and adds a candidate_filter search option to mcore_minitron so its MoE-FFN dimensions stay prunable while remaining representable in HF.

Also makes a rank-local failure under pipeline parallelism fail fast instead of stalling.

1. Nemotron Lightning: prune + HF export (examples/megatron_bridge/prune_minitron.py)

  1. MTP calibration crash. On newer Megatron-LM, mtp_process is derived from the hybrid pattern, not from mtp_num_layers. Setting only mtp_num_layers=0 in the calibration provider overrides was insufficient: the provider's finalize() re-appended the MTP suffix to hybrid_layer_pattern (because mtp_hybrid_override_pattern was still set and mtp_use_repeated_layer=True), so mtp_process=True while mtp_num_layers=0 and the calibration forward hit assert self.config.mtp_num_layers > 0. Fix: also clear mtp_hybrid_override_pattern in the calibration overrides so MTP is fully disabled (MTP heads are dropped from the pruned model, as before).

  2. HF export via a config-only bridge (hybrid models only). The old export built a dummy HF model to obtain the bridge, then streamed weights. This breaks on native NemotronH because (a) native NemotronHConfig makes hybrid_override_pattern a read-only property, and (b) transformers 5.12 saves the input embedding under a different key than the bridge mapping expects (backbone.embedding vs backbone.embeddings); the mismatch made build_conversion_tasks drop the embedding task on its owning rank, leaving an owner-less PP placeholder that crashed save_hf_weights with Object must exist on at least one PP rank. Fix: stream weights through a config-only bridge (AutoBridge.from_hf_config(hf_cfg).save_hf_pretrained(...)), available since Megatron-Bridge 0.5.0 (nemo:26.06). A config-only bridge has hf_keys=None, so the embedding task is never dropped, no dummy model is built, and the output uses the canonical HF key names.

    This is restricted to hybrid providers, which are the only models that need it; non-hybrids keep the dummy-model path that CI has always exercised.

    Writing the source artifacts is now rank-0-only. Every rank used to write the source config.json, which races with the pruned config.json that save_hf_pretrained writes from rank 0 alone: a late write from another rank leaves a checkpoint whose config does not match its weights. This reproduced intermittently on both Qwen3 and NemotronH before the fix, and 3/3 clean runs after.

    save_hf_pretrained takes no trust_remote_code argument — it reads the flag off the bridge to fetch the source checkpoint's artifacts, and from_hf_config cannot infer it because AutoConfig.from_pretrained consumes the kwarg rather than storing it on the config. So the flag is set explicitly on the bridge instance; otherwise remote-code models would silently lose it.

  3. Config write-back correctness:

    • hybrid_override_pattern is only written for older remote-code configs that lack layer_types; native configs carry the cadence in layer_types (read-only hybrid_override_pattern is skipped).
    • n_shared_experts is preserved (a fixed count) instead of being re-derived by moe_shared_expert_intermediate_size // moe_ffn_hidden_size, which is DeepSeek-style logic that would corrupt NemotronH's count.

Non-hybrids, VLMs, and Megatron-Bridge builds without config-only export keep the dummy-model path, with a warn_rank_0 when a hybrid has to fall back. The README's transformers<5 workaround is removed: it existed because the dummy-model path broke on transformers 5, and the config-only path handles NemotronH on every supported container.

2. candidate_filter for mcore_minitron (modelopt/torch/prune/plugins/mcore_minitron.py)

DeepSeek-style MoE configs have no explicit shared-expert-size field: they size the shared expert as n_shared_experts * moe_intermediate_size, where moe_intermediate_size is the (also prunable) routed expert size. So only candidates with moe_shared_expert_intermediate_size % moe_ffn_hidden_size == 0 can be written back to HF at all.

Candidates come from a Cartesian product() of independent per-hparam choice lists, so no per-hparam restriction can express a constraint between two hparams. New optional candidate_filter search-config key (default None, so existing behaviour is unchanged): a callable that rejects candidate configs before the metric computation, making the search cheaper rather than more expensive. It receives every supported hparam, with non-searched ones filled in from the model config, so a filter still works when one of its hparams was skipped or had a single choice.

Rejected candidates are not cached, so — like score_func, whose cached scores are reused without re-validation — the filter is assumed unchanged when resuming from a checkpoint.

prune_minitron.py wires this up for DeepSeek-style configs, so both moe_ffn_hidden_size and moe_shared_expert_intermediate_size stay prunable (the search then only picks shared sizes that are a multiple of the routed one). A --prune_export_config that violates the constraint never reaches the filter, so the export path now raises ValueError instead of writing a checkpoint whose config disagrees with its weights.

3. MLA Q-LoRA pruning (modelopt/torch/prune/plugins/mcore_minitron.py)

Pruning any MLA model with q_lora_rank set died during calibration with AttributeError: 'tuple' object has no attribute 'view'.

hidden_size importance estimation blanket-patches every TELayerNormColumnParallelLinear with return_layernorm_output=True to capture post-layernorm activations. When q_lora_rank is set, MCore builds linear_q_up_proj as a TELayerNormColumnParallelLinear — the Q-LoRA layernorm is fused into it, which is why q_layernorm is IdentityOp — so it was patched too, even though its layernorm is over the latent rank, not hidden_size. TE then returns ((out, ln_out), bias) and MCore's q, _ = self.linear_q_up_proj(...) leaves q a tuple.

Isolated by probing the module before and after dynamic conversion:

Setup linear_q_up_proj returns Forward
Before conversion tuple(Tensor, NoneType)
After conversion, no hooks tuple(Tensor, NoneType) OK
After conversion + importance hooks tuple(tuple(Tensor, Tensor), NoneType) AttributeError

So conversion is innocent; registering the importance hooks is the trigger. Fix: exclude MLA's Q/KV up-projections from both the patch and unpatch loops. test_mcore_mla_pruning did not catch this because it builds MLA without q_lora_rank, where MCore uses a plain linear_q_proj and nothing is patched.

4. Fail fast instead of stalling on a rank-local error under PP (modelopt/torch/utils/distributed.py)

A rank raising inside a distributed entrypoint left the whole job stalled until the process group timed out, with no diagnostic output at all: the failing rank blocked in cleanup()'s barrier while its peers blocked in recv_from_prev_pipeline_rank_, and Python only prints a traceback once the enclosing finally returns. A crash on one rank was indistinguishable from a slow job.

  • dist.cleanup() skips the barrier when unwinding from an exception.
  • New dist.abort() prints the traceback, flushes and exits immediately. Skipping the barrier alone is not enough — a stack dump showed the failing rank then blocking in destroy_process_group for the same reason — so the error path must not tear the process group down at all. SystemExit is re-raised rather than aborted, so an intentional exit (e.g. the --score_lower_bound gate) keeps its exit code and prints no traceback. Kept out of cleanup() so no library caller gets a surprise process exit.
  • Called from the entrypoints that wrap main() in try/finally: the five examples/megatron_bridge scripts.

Measured on a 2-GPU PP run whose rank 0 raises during calibration: 10 min timeout kill with no visible error → 31s, exit 1, real traceback. This is a latent, pre-existing issue (the try/finally predates this PR); it only surfaces on a failing PP run, which is why CI never hit it.

Usage

torchrun --nproc_per_node 4 examples/megatron_bridge/prune_minitron.py \
    --hf_model_name_or_path nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 \
    --pp_size 4 \
    --prune_target_active_params 3e9 \
    --output_hf_path /path/to/Nemotron-3.5-Lightning-30B-A3B-Pruned-A3.0B

Testing

  • End-to-end on nemo:26.08.rc6 (4× GB300, transformers 5.12.1, Megatron-Bridge with config-only export): pruning + export complete (EXIT=0, "Saved pruned model … Done!"). The exported checkpoint has canonical plural backbone.embeddings.weight keys, 0 MTP tensors, and a config that reloads correctly (num_hidden_layers=52 from layers_block_type, n_shared_experts=1, num_nextn_predict_layers=0, pruned hidden_size/mamba_*/MoE dims, reconstructed hybrid_override_pattern).

    Pruning search log (--prune_target_active_params 3e9)
                                                                     Top 10 Candidates with Scores
    ┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
    ┃  # ┃ export_config                                                                                                         ┃ active_params ┃ params ┃  score ┃
    ┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
    │  1 │ {'num_layers': 46, 'hidden_size': 2560, 'mamba_num_heads': 56, 'mamba_head_dim': 56, 'num_moe_experts': 104,          │         3.00B │ 23.49B │ 0.5406 │
    │    │ 'moe_ffn_hidden_size': 1856, 'moe_shared_expert_intermediate_size': 3584}                                             │               │        │        │
    │  2 │ {'num_layers': 52, 'hidden_size': 2688, 'mamba_num_heads': 56, 'mamba_head_dim': 48, 'num_moe_experts': 96,           │         3.00B │ 20.09B │ 0.2427 │
    │    │ 'moe_ffn_hidden_size': 1536, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    │  3 │ {'num_layers': 52, 'hidden_size': 2688, 'mamba_num_heads': 48, 'mamba_head_dim': 56, 'num_moe_experts': 104,          │         3.00B │ 21.61B │ 0.2643 │
    │    │ 'moe_ffn_hidden_size': 1536, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    │  4 │ {'num_layers': 52, 'hidden_size': 2560, 'mamba_num_heads': 48, 'mamba_head_dim': 64, 'num_moe_experts': 96,           │         3.00B │ 19.28B │ 0.4552 │
    │    │ 'moe_ffn_hidden_size': 1536, 'moe_shared_expert_intermediate_size': 3712}                                             │               │        │        │
    │  5 │ {'num_layers': 52, 'hidden_size': 2304, 'mamba_num_heads': 64, 'mamba_head_dim': 64, 'num_moe_experts': 104,          │         3.00B │ 22.28B │ 0.5860 │
    │    │ 'moe_ffn_hidden_size': 1856, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    │  6 │ {'num_layers': 52, 'hidden_size': 2560, 'mamba_num_heads': 48, 'mamba_head_dim': 48, 'num_moe_experts': 96,           │         3.00B │ 21.99B │ 0.2294 │
    │    │ 'moe_ffn_hidden_size': 1792, 'moe_shared_expert_intermediate_size': 3328}                                             │               │        │        │
    │  7 │ {'num_layers': 48, 'hidden_size': 2560, 'mamba_num_heads': 56, 'mamba_head_dim': 56, 'num_moe_experts': 104,          │         3.00B │ 23.68B │ 0.5231 │
    │    │ 'moe_ffn_hidden_size': 1792, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    │  8 │ {'num_layers': 46, 'hidden_size': 2560, 'mamba_num_heads': 56, 'mamba_head_dim': 56, 'num_moe_experts': 96,           │         3.00B │ 21.81B │ 0.5042 │
    │    │ 'moe_ffn_hidden_size': 1856, 'moe_shared_expert_intermediate_size': 3584}                                             │               │        │        │
    │  9 │ {'num_layers': 52, 'hidden_size': 2688, 'mamba_num_heads': 48, 'mamba_head_dim': 56, 'num_moe_experts': 96,           │         3.00B │ 20.09B │ 0.2462 │
    │    │ 'moe_ffn_hidden_size': 1536, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    │ 10 │ {'num_layers': 52, 'hidden_size': 2304, 'mamba_num_heads': 64, 'mamba_head_dim': 64, 'num_moe_experts': 96,           │         3.00B │ 20.70B │ 0.5685 │
    │    │ 'moe_ffn_hidden_size': 1856, 'moe_shared_expert_intermediate_size': 3072}                                             │               │        │        │
    └────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────┴────────┴────────┘
    
    ╭──────────────────────────────────────────────────────────────────────── Best Subnet ─────────────────────────────────────────────────────────────────────────╮
    │ export_config  {'num_layers': 52, 'hidden_size': 2304, 'mamba_num_heads': 64, 'mamba_head_dim': 64, 'num_moe_experts': 104, 'moe_ffn_hidden_size': 1856,     │
    │                'moe_shared_expert_intermediate_size': 3072}                                                                                                  │
    │ active_params  3.00B                                                                                                                                         │
    │ params         22.28B                                                                                                                                        │
    │ score          0.5860                                                                                                                                        │
    ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
    
    ╭────────────────────────────────────────────────────── Pruned Model Stats ───────────────────────────────────────────────────────╮
    │ Total Parameters                              22.28B                                                                            │
    │ Active Parameters                             3.00B                                                                             │
    │ Memory (BF16, seq_length=8192, batch_size=8)  weights: 42489.7 MB, kv_cache: 384.0 MB, mamba_state: 190.5 MB, Total: 43064.2 MB │
    ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
    
  • tests/examples/megatron_bridge/test_prune_minitron.pynemotron_h now exports to HF and reloads (previously it stopped at a Megatron checkpoint, since the dummy-model path needed transformers<5), plus an n_shared_experts config assertion; the dead megatron_format branch is gone. It runs on the CI container: verified on nemo:26.06.01 (transformers 5.8.1) and nemo:26.08.rc6.

  • tests/gpu_megatron/torch/prune/plugins/test_mcore_mamba_minitron_pruning.py — the nas_memory_mb search test now passes a candidate_filter and asserts the exact number of rejected candidates (256 of the 512-combo grid) plus the surviving candidates' validity; its expected_top_k goldens are regenerated accordingly. Because moe_shared_expert_intermediate_size is in that test's skip list, this also covers the model-config fallback for hparams that are not in the search space.

Verified on 2 GPUs, on both the CI container (nemo:26.06.01) and nemo:26.08.rc6:

Test Result
test_prune_minitron[qwen3] PASSED on 26.06.01 and 26.08.rc6
test_prune_minitron[deepseek_v3] PASSED (52s) — MLA Q-LoRA + candidate_filter end-to-end
test_prune_minitron[nemotron_h] PASSED on 26.06.01 (58s) and 26.08.rc6 (61s)
test_mcore_mamba_hybrid_pruning_nas_memory_mb PASSED
test_mcore_mamba_hybrid_pruning_nas_params PASSED (unchanged sibling, run to check the regenerated goldens did not disturb it)
2-GPU PP run failing on rank 0 fails in 31s with a real traceback (was a 10 min stall)

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ — candidate_filter defaults to None (existing searches unchanged), and the config-only export is limited to hybrid providers on nemo:26.08+, so dense / MoE / VLM exports keep the path they use today.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A
  • Did you get Claude approval on this PR?: ✅

Additional Information

Enables the Prune + Distill workflow for NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 (native, no-remote-code NemotronHForCausalLM with MTP heads). Pruning-time MTP support was scoped and intentionally deferred — MTP heads are dropped and can be re-derived via a short SFT with mtp_num_layers=1 on the pruned+distilled model.

Enables pruning and HF-checkpoint export of native NemotronHForCausalLM
models that ship without remote code and carry MTP heads (e.g.
NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16) on the modern
Megatron-Bridge / transformers stack:

- Clear mtp_hybrid_override_pattern in the calibration provider overrides.
  On newer Megatron-LM, mtp_process is derived from the hybrid pattern, so
  mtp_num_layers=0 alone left mtp_process=True and calibration tripped
  `assert self.config.mtp_num_layers > 0`.
- Stream pruned weights through a config-only bridge
  (AutoBridge.from_hf_config(...).save_hf_pretrained(...)) when available.
  Its hf_keys=None, so the embedding conversion task is not dropped when
  transformers' saved key differs from the bridge mapping
  (backbone.embedding vs backbone.embeddings), which previously left an
  owner-less PP placeholder and crashed save_hf_weights. VLMs and older
  Megatron-Bridge builds keep the dummy-model path.
- Only write hybrid_override_pattern for older remote-code configs that
  lack layer_types; on native configs it is a read-only property.
- Preserve n_shared_experts (a fixed count) instead of re-deriving it from
  moe_shared_expert_intermediate_size // moe_ffn_hidden_size, which is
  DeepSeek-style logic that corrupts NemotronH's count.

Extends the nemotron_h prune test to export to HF and reload, with an
n_shared_experts assertion, and drops the now-dead megatron_format branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97
kevalmorabia97 requested review from a team as code owners August 12, 2026 06:25
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 829554df-493b-4670-b2fe-3f0225eeb62a

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae34c4 and 30d78d2.

📒 Files selected for processing (1)
  • modelopt/torch/prune/plugins/mcore_minitron.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • modelopt/torch/prune/plugins/mcore_minitron.py

📝 Walkthrough

Walkthrough

The pruning flow adds MoE candidate filtering, validates DeepSeek-style shared-expert configurations, and uses configuration-only HF export when supported. Distributed scripts now abort peers before cleanup on uncaught failures.

Changes

Pruning and HF export

Layer / File(s) Summary
MoE candidate filtering
modelopt/torch/prune/plugins/mcore_minitron.py, examples/megatron_bridge/prune_minitron.py, tests/gpu_megatron/torch/prune/plugins/test_mcore_mamba_minitron_pruning.py
Search accepts a candidate filter for complete candidates. DeepSeek-style candidates are filtered by shared-expert divisibility. Tests verify rejected counts and updated top candidates.
Pruning configuration reconstruction
examples/megatron_bridge/prune_minitron.py
MTP overrides clear hybrid patterns. HF export validates shared-expert divisibility. Hybrid conversion applies only when native layer_types are absent.
HF export path selection
examples/megatron_bridge/prune_minitron.py, examples/megatron_bridge/README.md
Supported non-VLM builds use configuration-only AutoBridge export. VLMs and older builds retain dummy-model fallback export. The README documents container-version requirements and the Megatron checkpoint alternative.
HF export capability and validation
tests/_test_utils/examples/megatron_bridge.py, tests/examples/megatron_bridge/test_prune_minitron.py
Tests detect configuration-only bridge support, always export to HF, reload the model, and validate parameter counts and expected configuration fields.
Distributed failure handling
modelopt/torch/utils/distributed.py, examples/megatron_bridge/*.py
Cleanup skips barriers during exception unwinding. Example scripts abort distributed peers before cleanup when a rank fails.

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

Sequence Diagram(s)

sequenceDiagram
  participant prune_minitron
  participant AutoBridge
  participant HF_output
  prune_minitron->>AutoBridge: from_hf_config(hf_config)
  AutoBridge-->>prune_minitron: config-only bridge
  prune_minitron->>AutoBridge: save_hf_pretrained(...)
  AutoBridge->>HF_output: stream HF weights
Loading

Suggested reviewers: chenhanyu, jenchen13, yueshen2016

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed PR diff adds no unapproved security patterns: no hardcoded trust_remote_code=True, eval/exec, nosec, unsafe NumPy loads, or new dependencies; existing weights_only=False has an inline safety comment.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: Minitron pruning fixes for Nemotron-3.5-Lightning-30B-A3B and DeepSeek.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kmorabia/prune-nemotron-lightning-hf-export

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

@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread tests/examples/megatron_bridge/test_prune_minitron.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py
Comment thread examples/megatron_bridge/prune_minitron.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — findings

Scope: full review (the trigger comment carried no scoping instructions). All 4 changed files reviewed (+70/-42): examples/megatron_bridge/prune_minitron.py, examples/megatron_bridge/README.md, tests/_test_utils/examples/megatron_bridge.py, tests/examples/megatron_bridge/test_prune_minitron.py. No modelopt/ source changed, so no mode-registration or modelopt_state schema surface is affected.

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

What holds up

All three fixes are well-targeted and I could not fault the logic:

  • Fix 1 (MTP calibration) — clearing mtp_hybrid_override_pattern alongside mtp_num_layers=0 addresses the actual root cause (the provider finalize() re-appending the MTP suffix so mtp_process stays true), not the assertion symptom. Consistent with the coding standard on fixing causes rather than side effects.
  • Fix 3a (n_shared_experts) — the has_explicit_shared_size narrowing is right in both directions: NemotronH carries both the count and an explicit moe_shared_expert_intermediate_size, so the count must be preserved; DeepSeek-V3 has no explicit shared-size field, so it still needs the derivation. Neither Qwen3.5-MoE (shared_expert_intermediate_size, no count) nor dense configs are affected.
  • Fix 3b (hybrid_override_pattern) — skipping the write when layer_types is present is consistent with the pre-existing layer_types slicing just above it, which already carries the pruned cadence; writing both would be two sources of truth for the same thing.

Most impactful findings

  1. [IMPORTANT] The nemotron_h test case is now skipped in CI entirely, not just its export assertions. config_only_hf_export_supported() requires nemo:26.08+, but both workflows pin nvcr.io/nvidia/nemo:26.06 (example_tests.yml:156, gpu_tests.yml:53) — as the adjacent "until nemo:26.08 container" TODO confirms. Before this PR the case ran the full prune and asserted the Megatron checkpoint, which covered fix 1 (the crash this PR exists to fix) and fix 3. After it, nothing runs until the container bumps. Only the export leg needs 26.08+, so gating the whole parametrization gives up coverage that does not depend on it. Suggested restructuring is in the inline comment.

  2. [IMPORTANT] The config-only path is gated more broadly than the bug it fixes, and drops trust_remote_code. The condition is "API present AND not VLM", so on 26.08+ every non-VLM export (remote-code Nemotron, DeepSeek, Qwen3.5-MoE, dense Qwen3) switches mechanism — which qualifies the PR description's "existing … exports are unaffected"; that holds on 26.06 only. Separately, args.trust_remote_code is threaded through every other HF call in this block (line 596 and all three fallback calls) but not into from_hf_config / save_hf_pretrained, whose source_path points back at the original repo.

  3. [IMPORTANT] The fallback is a silent path to a known-broken outcome, and its README workaround was deleted. Your new helper documents the dummy-model path as not round-tripping the pruned config, yet a 26.06 + transformers>=5 user now takes it with no log line and no README note — the removed transformers<5 hint was exactly the guidance for that combination. A warn_rank_0 on fallback plus a note scoped to older containers restores discoverability.

The two SUGGESTIONs (unused from_auto_config in the gate; silent floor-division truncation in the DeepSeek n_shared_experts derivation) are inline and non-blocking.

Risk assessment

Low-to-moderate, and confined to the example script — no library code, no checkpoint/state schema, no public API. The end-to-end validation on the 30B target model is convincing for the path it exercised. The residual risk is entirely about unexercised paths: the primary MTP fix has no CI coverage on the container CI actually uses (finding 1), and the export-mechanism switch reaches every non-VLM model on 26.08+ with no test behind it (finding 2). Neither is a correctness defect in what you ran; both are gaps that would let a future regression through silently.

Findings 1 and 3 are cheap to address. Finding 2 may be resolvable with a sentence confirming the config-only bridge never loads remote code.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.16%. Comparing base (a21173a) to head (9907656).

Files with missing lines Patch % Lines
modelopt/torch/utils/distributed.py 71.42% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2159      +/-   ##
==========================================
- Coverage   78.74%   78.16%   -0.59%     
==========================================
  Files         522      522              
  Lines       60368    60392      +24     
==========================================
- Hits        47538    47205     -333     
- Misses      12830    13187     +357     
Flag Coverage Δ
examples-diffusers 20.79% <9.09%> (-0.01%) ⬇️
examples-gpt-oss 13.27% <9.09%> (-0.01%) ⬇️
examples-hf_ptq 21.47% <9.09%> (-0.04%) ⬇️
examples-llm_distill 13.34% <9.09%> (-0.01%) ⬇️
examples-llm_eval 17.10% <9.09%> (-0.01%) ⬇️
examples-llm_qat 17.60% <9.09%> (-0.01%) ⬇️
examples-llm_sparsity 15.92% <9.09%> (-0.01%) ⬇️
examples-megatron_bridge 25.75% <87.87%> (-0.08%) ⬇️
examples-specdec_bench 13.01% <9.09%> (-0.01%) ⬇️
examples-speculative_decoding 17.53% <9.09%> (-0.08%) ⬇️
examples-torch_onnx 21.87% <9.09%> (-0.01%) ⬇️
examples-torch_trt 15.09% <9.09%> (-0.01%) ⬇️
gpu 58.62% <78.78%> (-0.71%) ⬇️
regression 14.90% <9.09%> (+0.06%) ⬆️
unit 55.28% <9.09%> (-0.03%) ⬇️

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.

Follow-up to the NemotronH Lightning prune + HF export fix.

mcore_minitron: add an optional candidate_filter search config option
(default None). Candidates come from a Cartesian product of independent
per-hparam choice lists, so a per-hparam restriction cannot express a
constraint *between* two hparams. The filter runs before the metric
computation, and receives every supported hparam with non-searched ones
filled in from the model config. It is also re-applied to candidates
restored from a search checkpoint, since all_candidates_per_constraint is
cached by constraints alone and would otherwise silently reuse candidates
computed under a different filter.

prune_minitron.py wires this up for DeepSeek-style MoE configs, which size
the shared expert as n_shared_experts * moe_intermediate_size and so can
only represent a shared size that is a multiple of the routed one. A
--prune_export_config never reaches the filter, so the export path raises
instead of writing a config that disagrees with the saved weights.

Review feedback on the export path:
- Set trust_remote_code on the config-only bridge. save_hf_pretrained takes
  no such argument and reads it off the bridge to fetch source artifacts,
  and from_hf_config cannot infer it because AutoConfig consumes the kwarg.
- Warn when falling back to the dummy-model path, and keep the README
  transformers<5 note scoped to containers without config-only export.
- Drop the unused from_auto_config from the capability check.

Tests: nas_memory_mb now exercises candidate_filter (exact rejected count,
surviving candidate validity, regenerated top-k goldens) and covers the
model-config fallback for hparams outside the search space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97
kevalmorabia97 requested a review from a team as a code owner August 12, 2026 08:28
@kevalmorabia97 kevalmorabia97 changed the title Fix Minitron prune + HF export for native NemotronH with MTP heads (Nemotron-3.5-Lightning-30B-A3B) Fix Minitron prune + HF export for native NemotronH (Nemotron-3.5-Lightning); add candidate_filter to mcore_minitron Aug 12, 2026

@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/prune/plugins/mcore_minitron.py`:
- Around line 575-586: Update the candidate-cache flow around
_compute_candidate_metrics and the cache-removal logic at lines 595-602 to
retain every metric-qualified CandidateSubnet regardless of candidate_filter.
Build a temporary filtered view using the current filter only when performing
top-k selection, so resumed searches with no or less restrictive filters can
recover previously rejected candidates; add a checkpoint-resume test covering
that filter change.

In `@tests/_test_utils/examples/megatron_bridge.py`:
- Around line 34-39: Add a brief comment immediately before the local AutoBridge
import in the helper, documenting that Megatron Bridge is optional and
unavailable installations cause the helper to return False.
🪄 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: 90368826-3115-47f6-935b-2c30740677cb

📥 Commits

Reviewing files that changed from the base of the PR and between 12503f1 and 5895390.

📒 Files selected for processing (6)
  • examples/megatron_bridge/README.md
  • examples/megatron_bridge/prune_minitron.py
  • modelopt/torch/prune/plugins/mcore_minitron.py
  • tests/_test_utils/examples/megatron_bridge.py
  • tests/examples/megatron_bridge/test_prune_minitron.py
  • tests/gpu_megatron/torch/prune/plugins/test_mcore_mamba_minitron_pruning.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • examples/megatron_bridge/README.md
  • tests/examples/megatron_bridge/test_prune_minitron.py

Comment thread modelopt/torch/prune/plugins/mcore_minitron.py Outdated
Comment thread tests/_test_utils/examples/megatron_bridge.py Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-12 15:36 UTC

@kevalmorabia97
kevalmorabia97 requested a review from a team as a code owner August 12, 2026 09:03
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

Summary of changes since review

Three groups of work landed in 5895390 and 672e982.

1. Review feedback

Finding Outcome
Config-only export gate too broad Fixed — the reviewer was right. See below.
trust_remote_code dropped on the config-only bridge Fixed. The suggested trust_remote_code= kwarg would have raised TypeError (no such parameter), but the concern was real: save_hf_pretrained reads the flag off the bridge, and from_hf_config leaves it False because AutoConfig consumes the kwarg. Now set on the instance.
Silent fallback + deleted README note Fixed. warn_rank_0 on the fallback, README note restored and scoped to containers without config-only export.
Unused from_auto_config in the gate Fixed — dropped in both places.
n_shared_experts floor-division truncation Fixed, and promoted from a warning to a hard ValueError. This also motivated candidate_filter below.
nemotron_h skipped in CI Postponed with reason — nemo:26.08 lands in ~a week and the case un-skips with that bump; verified locally on 26.08.rc6, PASSED in 61s.
CodeRabbit: unfiltered candidate cache Postponed with reason — see the thread; documented the constraint instead.

2. candidate_filter for mcore_minitron

DeepSeek-style MoE configs size the shared expert as n_shared_experts * moe_intermediate_size, so only candidates whose shared size is a multiple of the routed one are representable in HF. Candidates come from a Cartesian product of independent per-hparam choices, so no per-hparam restriction can express a constraint between two hparams. New optional search-config callable (default None), applied before the metric computation and re-applied to checkpoint-restored candidates.

3. Fail fast instead of stalling on a rank-local error under PP

A rank raising inside a distributed entrypoint stalled the job until the process-group timeout with no diagnostic output: the failing rank blocked in cleanup()'s barrier, peers blocked in recv_from_prev_pipeline_rank_, and Python withholds the traceback until finally returns. Skipping the barrier alone was not enough — a stack dump showed the rank then blocking in destroy_process_group for the same reason — so dist.abort() exits without tearing the process group down. SystemExit is re-raised so the --score_lower_bound gate keeps its exit code.

Measured: 10 min timeout kill with no visible error → 31s, exit 1, real traceback. Pre-existing (the try/finally predates this PR); only observable on a failing PP run, which is why CI never hit it.

Note on the regression this caught

Routing every non-VLM export through the config-only bridge regressed dense models: a pruned Qwen3 was written with hidden_size=24 weights but a config.json still saying 32, so the checkpoint would not reload. I found this only because the dist.cleanup() change prompted a success-path regression run. The gate is now limited to hybrid providers.

Verified on nemo:26.08.rc6 (2× RTX 6000 Ada)

qwen3 PASSED · nemotron_h PASSED (61s) · nas_memory_mb PASSED · nas_params PASSED · failing 2-GPU PP run errors in 31s.

An end-to-end DeepSeek case is not currently possible (Megatron-Bridge's DeepSeek mapping requires q_lora_rank; ModelOpt's MLA pruning does not support the Q-LoRA up-projection) — left as a TODO with candidate_filter covered by the unit test.

@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

@kevalmorabia97
kevalmorabia97 force-pushed the kmorabia/prune-nemotron-lightning-hf-export branch from 672e982 to 2ae34c4 Compare August 12, 2026 09:11
Comment thread examples/megatron_bridge/prune_minitron.py
Comment thread examples/megatron_bridge/prune_minitron.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py
Comment thread modelopt/torch/utils/distributed.py
Comment thread tests/_test_utils/examples/megatron_bridge.py Outdated
Comment thread modelopt/torch/prune/plugins/mcore_minitron.py

@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 — 6 findings (CRITICAL: 0, IMPORTANT: 2, SUGGESTION: 4)

Scope: full review (trigger comment carried no scoping instructions). All 11 changed files opened — modelopt/ (2) first, then examples/ (6), then tests/ (3). Nothing deprioritized.

Most impactful

1. mtp_hybrid_override_pattern is an unconditional provider override with a hard hasattr assert (examples/megatron_bridge/prune_minitron.py:424) — IMPORTANT Compatibility

load_mbridge_model_from_hf asserts hasattr(provider, key) for every override key (modelopt/torch/utils/plugins/mbridge.py:95). mtp_hybrid_override_pattern belongs to the modern Megatron-LM / HybridModelProvider era that mbridge.py already guards behind HAS_HYBRID. If nemo:26.06 providers lack it, this assert fires before any model is built, breaking prune_minitron.py for every architecture — including the qwen3 CI case, which (unlike the new nemotron_h case) is not skipped on 26.06. All the end-to-end validation in the PR body was on nemo:26.08.rc6, so the container CI actually runs is untested for this line. Please either gate the key on hasattr or confirm it exists on 26.06.

2. The config-only export path has no weights-vs-config verification (prune_minitron.py:718-731) — IMPORTANT Export

The ~60 lines of field-by-field hf_cfg write-back above are handed to AutoBridge.from_hf_config(hf_cfg), and whatever save_hf_pretrained emits becomes config.json — unchecked. The PR description documents this exact failure already occurring here (pruned Qwen3: hidden_size=24 weights, config.json saying 32, checkpoint unloadable). The chosen mitigation is an architecture allowlist, but _HYBRID_PROVIDER_TYPES admits every hybrid provider while only native NemotronH was validated, so an unvalidated hybrid gets the same silent mismatch — surfacing only when a user later loads the checkpoint. An assert on the written config after save turns a class of silent corruption into a loud export-time error, and would also make the isinstance(...) restriction safe to relax later.

Suggestions (non-blocking)

  • prune_minitron.py:711 — comment says "Preferred path for all non-VLMs" but the code restricts to hybrids, contradicting its own next paragraph.
  • prune_minitron.py:652-663 — the new ValueError fires after save_artifacts already wrote a config.json, which the early-exit guard in main() then reads as "Pruned model already exists… Exiting…" on the next run — unpruned checkpoint, exit code 0. The constraint is checkable from args up front.
  • modelopt/torch/utils/distributed.py:240-241abort()'s docstring says a re-raised SystemExit shuts the PG down "normally", but the subsequent finally: dist.cleanup() sees non-None sys.exc_info() and skips the barrier. Low practical risk (all ranks reach sys.exit together), but the doc and code disagree. Also suggests an explicit cleanup(barrier=...) escape hatch, since the exc_info heuristic makes behavior depend on ambient interpreter state.
  • tests/_test_utils/examples/megatron_bridge.py:17-18 — module-level from megatron.bridge import AutoBridge makes this shared helper import-fatal without Megatron-Bridge (breaking test_qad.py collection, not skipping) and renders the new function's try/except dead. The sibling qwen35_moe_bridge_supported() deliberately imports inside the try.
  • modelopt/torch/prune/plugins/mcore_minitron.py:589 — "No subnets found fitting the constraints!" is misleading when candidate_filter rejected the whole grid; num_filtered is already in scope.

Verified as correct

  • candidate_filter plumbing — default None keeps existing searches byte-identical; SUPPORTED_HPARAMS fallback via self.model.config correctly covers skipped/single-choice hparams; the filter runs before _compute_candidate_metrics, so it makes the search cheaper; re-application to checkpoint-restored candidates closes the real hole created by caching on constraints alone, and the docstring correctly documents the narrowing-only resume semantics. No lambda reaches state_dict() (BaseSearcher.state_dict only serializes default_state_dict keys), so no pickling hazard from the prune_minitron.py lambda.
  • dist.abort() design — not tearing down the process group at all (rather than just skipping the barrier) is the right call given peers parked in recv_from_prev_pipeline_rank_; flushing both streams before os._exit is necessary since os._exit skips buffer flushing; re-raising SystemExit correctly preserves the --score_lower_bound exit code. Keeping it out of cleanup() so no library caller gets a surprise process exit is the right boundary.
  • _is_deepseek_style_moe narrowing — correctly restricts the n_shared_experts re-derivation to configs with no explicit shared-size field; NemotronH keeps its fixed count (matching the new {"n_shared_experts": 1} test assertion) while the _SHARED_EXPERT_SIZE_FIELDS loop handles its pruned size. Source-config detection correctly unwraps text_config for VLMs.
  • hybrid_override_pattern write-back gate — skipping it when layer_types is present correctly avoids the read-only property on native configs.
  • Mid-main() dist.cleanup() in distill.py / export_distilled_megatron_to_hf.py — composes safely with the new outer except BaseException: dist.abort(); the PG is already gone, so a rank-0 export failure cannot stall.
  • No modelopt_state schema change, no mode registration change, no public API signature change — restore/backward compatibility is unaffected.

Risk assessment

Moderate, concentrated in one line. The library-side changes (candidate_filter, dist.abort/cleanup) are additive, default-off, and sound. Finding 1 is the only thing that could break currently-green CI, and it is a one-line guard away from resolved. Finding 2 is about hardening a path whose failure mode this PR already experienced once — worth adding before the allowlist inevitably widens.

Nothing here duplicates CodeRabbit's style/security surface.

@kevalmorabia97
kevalmorabia97 force-pushed the kmorabia/prune-nemotron-lightning-hf-export branch from 2ae34c4 to 30d78d2 Compare August 12, 2026 09:17
A rank raising inside a distributed entrypoint left the job stalled until
the process group timed out, with no diagnostic output: the failing rank
blocked in cleanup's barrier while its peers blocked in a pipeline recv,
and Python only prints a traceback once the enclosing `finally` returns.

- dist.cleanup(): skip the barrier when unwinding from an exception, since
  peers may be blocked in a collective this rank will never reach.
- dist.abort(): new helper that prints the traceback, flushes and exits
  immediately. Skipping the barrier alone is not enough -- destroy_process_group
  blocks for the same reason (confirmed by stack dump) -- so the error path
  must not tear the process group down at all. SystemExit is re-raised rather
  than aborted: an intentional exit (e.g. the --score_lower_bound gate) is
  reached by every rank, so it needs no traceback and keeps its exit code.
  Kept out of cleanup() so no library caller gets a surprise process exit.

Measured on a 2-GPU PP run whose rank 0 raises during calibration: 10 min
timeout kill with no visible error -> 31s, exit 1, real traceback.

Also narrow the config-only HF export to hybrid models. Routing every
non-VLM export through it regressed dense models: a pruned Qwen3 exported
with hidden_size 24 weights but a config.json still saying 32, so the
checkpoint would not reload. The dummy-model path round-trips the pruned
config for non-hybrids and is what CI has always exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97
kevalmorabia97 force-pushed the kmorabia/prune-nemotron-lightning-hf-export branch from 30d78d2 to 91d6361 Compare August 12, 2026 09:30
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

Update since last review (91d6361)

Correction to a premise I had wrong. I asserted that AutoBridge.from_hf_config requires nemo:26.08+ and built a helper, a skipif, a README note and several description claims on it. It has existed since Megatron-Bridge 0.5.0 (nemo:26.06.00). Consequences, all now fixed:

  • The skipif on nemotron_h never fired — the case has been running on the CI container all along, so no coverage was ever lost (review finding Update README.md #1 is moot on different grounds than I argued).
  • Removed the dead skipif and the config_only_hf_export_supported() helper; tests/_test_utils/examples/megatron_bridge.py is now byte-identical to main.
  • Removed the README transformers<5 note — that workaround existed because the dummy-model path broke on transformers 5, which is exactly what the config-only path fixes, on every supported container.
  • Corrected the "nemo:26.08+" version claims in the warning text and the PR description.

Verified on the CI container, nemo:26.06.01 (mbridge 0.5.1, transformers 5.8.1), 2 GPUs:

Test Result
test_prune_minitron[qwen3] PASSED
test_prune_minitron[nemotron_h] PASSED (58s), via the config-only path, no fallback warning

Re-confirmed after deleting the skip mark: 2 passed, exit 0. Also still passing on nemo:26.08.rc6, along with nas_memory_mb and nas_params.

Other changes from this review round

  • cleanup() now treats SystemExit as the clean path, so the --score_lower_bound gate keeps barrier-then-destroy (the docstring had promised this; the code did not).
  • candidate_filter is no longer re-applied to checkpoint-restored candidates — it matches score_func, whose cached scores are reused without re-validation.
  • mtp_hybrid_override_pattern verified present on nemo:26.06.00/26.06.01 for both MambaModelProvider and GPTModelProvider, so the unconditional provider override cannot trip the hasattr assert.
  • Declined the post-save config assertion: test_prune_minitron already reloads the exported checkpoint, which is how the Qwen3 mismatch surfaced, so it is covered by a test rather than by runtime checks over a field list that will drift.

@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/prune/plugins/mcore_minitron.py
Comment thread examples/megatron_bridge/prune_minitron.py Outdated
Comment thread modelopt/torch/utils/distributed.py Outdated
Comment thread examples/megatron_bridge/prune_minitron.py

@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 — 4 findings (CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 4)

Claude review passed — no blocking issues found. LGTM

Scope: full review (trigger comment carried no scoping instructions). All 10 changed files reviewed — modelopt/ (2) first, then examples/ (6), then tests/ (2). Nothing deprioritized.

Prior-round findings: both resolved

  • mtp_hybrid_override_pattern as an unconditional provider override (previously IMPORTANT) — load_mbridge_model_from_hf hard-asserts hasattr(provider, key) (modelopt/torch/utils/plugins/mbridge.py:95), so a provider missing this key would break prune_minitron.py for every architecture on the container CI actually runs. The test table now reports test_prune_minitron[qwen3] and [nemotron_h] passing on nemo:26.06.01 as well as 26.08.rc6; both exercise this override on the dense and the hybrid provider respectively, so the attribute is present on both. Considered settled empirically.
  • No weights-vs-config verification on the config-only export path (previously IMPORTANT) — the nemotron_h case now ends in AutoModelForCausalLM.from_pretrained(pruned_path), a default-strict load that fails on exactly the mismatch class the PR description documents (pruned weights vs. stale config.json). That puts the shipped path under CI. Residual, non-blocking: _HYBRID_PROVIDER_TYPES admits every hybrid provider on 26.08+, so a hybrid other than NemotronH (e.g. a non-VLM Qwen3.5-MoE — only the VL variant is in CI, and VLMs are excluded from this branch) would still surface a mismatch at user load time rather than at export. Worth an export-time config assert whenever the allowlist widens, but the allowlist plus the new load test is a legitimate mitigation for what ships here.
  • tests/_test_utils/examples/megatron_bridge.py no longer appears in the diff — the import-fatal shared-helper concern is gone with it.

This round (all non-blocking)

  1. _is_deepseek_style_moe keys on attribute presence, not value (prune_minitron.py:121) — n_shared_experts=None is a valid no-shared-expert setting in DeepSeek-V2/V3-style configs, and it currently routes into both the divisibility filter (line 573) and the new export guard (line 653), each of which then raises TypeError: ... %: 'NoneType' and 'int' instead of taking the no-shared-expert path. One-line fix suggested inline.
  2. base_config can hand candidate_filter a None (mcore_minitron.py:560-564) — this searcher's own export path documents that moe_ffn_hidden_size, kv_channels and num_query_groups may be unset, but it normalizes them only after the search. The new docstring promises non-searched hparams are 'filled in from the model config' without noting the fill-in may be None, so an arithmetic filter dies inside the tqdm loop with no hint the value was a fallback. Suggested either reusing the existing normalization or dropping Nones so the failure names the field.
  3. cleanup()'s docstring overstates what skipping the barrier buys (distributed.py:218-221) — abort()'s own docstring correctly notes that destroy_process_group stalls for the same reason as the barrier, and cleanup() still calls it unconditionally on the error path. Two entrypoints have the same try/finally: dist.cleanup() shape as the five that were fixed but did not get the abort() line: modelopt/torch/puzzletron/scoring.py:85-88 and examples/hf_ptq/example_utils.py:96-99. Either extend abort() to those two or tighten the docstring.
  4. pruned_bridge.trust_remote_code = ... is a silent no-op if the field moves (prune_minitron.py:723) — the only affected models are remote-code hybrids, which is exactly the set neither CI (locally-built tiny checkpoint) nor the manual validation (native NemotronH, no remote code) covers. mbridge.py:95 already uses assert hasattr(...) for this same hazard; mirroring it makes a future rename loud.

Verified as correct

  • candidate_filter plumbing — default None leaves existing searches byte-identical; the filter runs before _compute_candidate_metrics, which is analytically pure, so short-circuiting has no side effects and makes the search strictly cheaper; base_config is computed once outside the loop from global (PP-invariant) config fields, so all ranks filter identically and selected cannot diverge. The SUPPORTED_HPARAMS fallback is confirmed by the new GPU test, where moe_shared_expert_intermediate_size is in the skip list and is supplied from the model config. No lambda reaches state_dict() — confirmed empirically by the new test passing while writing a search checkpoint with a nested-function filter. Narrowing-only resume semantics are documented on the config key.
  • dist.abort() design — not tearing the PG down at all is the right call given peers parked in recv_from_prev_pipeline_rank_; flushing both streams before os._exit is necessary since os._exit skips buffer flushing; re-raising SystemExit preserves the --score_lower_bound exit code, and since that sys.exit(1) (line 756) is not rank-guarded, cleanup()'s 'every rank reaches it' premise holds and the barrier is correctly still taken. Keeping the process exit out of cleanup() is the right library boundary.
  • n_shared_experts / _SHARED_EXPERT_SIZE_FIELDS ordering — the _SHARED_EXPERT_SIZE_FIELDS write-back loop runs before _is_deepseek_style_moe(text_cfg), but by construction the predicate is only true when neither field exists, so the earlier setattr cannot flip it. DeepSeek semantics are right: moe_intermediate_size takes the pruned routed size and n_shared_experts = shared // routed against that same pruned size.
  • hybrid_override_pattern write-back gate — skipping it when layer_types is present avoids the read-only property on native configs and matches the pre-existing layer_types slicing that already carries the pruned cadence.
  • Export-branch CI coveragefrom_hf_config exists on 26.06+, so qwen3 covers the dummy-model branch and nemotron_h the config-only branch on both containers; the fallback warn_rank_0 is correctly unreachable for VLMs.
  • Test changescontextlib/io/re are all already imported in test_mcore_mamba_minitron_pruning.py, so the new capture block is sound; the 256-of-512 assertion is exact and consistent with moe_ffn_hidden_size in [12, 16] against an unpruned shared size of 16.
  • No modelopt_state schema change, no mode registration change, no public API signature change (candidate_filter is additive with a None default) — restore and backward compatibility are unaffected.

Risk assessment

Low. The two library-side changes are additive and default-off (candidate_filter) or strictly reduce a stall (cleanup/abort). Both of the previous round's IMPORTANT findings are addressed — one by cross-container verification, one by a strict-reload assertion in CI. Everything remaining is defensive hardening against configs and future dependency versions this PR does not target: None-valued MoE fields (findings 1-2), an incomplete rollout of the fail-fast fix to two non-Megatron-Bridge entrypoints (finding 3), and a silent-drop risk on a duck-typed bridge attribute (finding 4).

Nothing here overlaps CodeRabbit's style/security surface.

Pruning any MLA model with q_lora_rank set died during calibration with
`AttributeError: 'tuple' object has no attribute 'view'`.

Root cause: hidden_size importance estimation blanket-patches every
TELayerNormColumnParallelLinear with return_layernorm_output=True to capture
post-layernorm activations. When q_lora_rank is set, MCore builds
linear_q_up_proj as a TELayerNormColumnParallelLinear (the Q-LoRA layernorm is
fused into it, which is why q_layernorm is IdentityOp), so it was patched too --
even though its layernorm is over the latent rank, not hidden_size. TE then
returns ((out, ln_out), bias) and MCore's `q, _ = self.linear_q_up_proj(...)`
leaves q a tuple.

Isolated by probing the module before/after conversion: conversion alone keeps
the (Tensor, None) return and the forward passes; registering the importance
hooks is what nests it. Fix: exclude MLA's Q/KV up-projections from both the
patch and unpatch loops.

test_mcore_mla_pruning did not catch this because it builds MLA without
q_lora_rank, where MCore uses a plain linear_q_proj and nothing is patched.

Adds the deepseek_v3 case to test_prune_minitron, which covers this path plus
candidate_filter end-to-end (n_group=1 keeps num_moe_experts divisible after
expert pruning). Verified on 2 GPUs: prune + HF export + reload, 52s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97 kevalmorabia97 changed the title Fix Minitron prune + HF export for native NemotronH (Nemotron-3.5-Lightning); add candidate_filter to mcore_minitron Minitron pruning fixes for Nemotron-3-Lightning and Deepseek Aug 12, 2026
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

Folded in: MLA Q-LoRA fix — DeepSeek-V3 now prunes end-to-end (3bbc373)

The known limitation this PR previously documented is resolved, and the cause turned out to be in ModelOpt rather than Megatron-Bridge or MCore.

Root cause. hidden_size importance estimation blanket-patches every TELayerNormColumnParallelLinear with return_layernorm_output=True. With q_lora_rank set, MCore builds linear_q_up_proj as one of those — the Q-LoRA layernorm is fused into it, which is why q_layernorm is IdentityOp — so it was patched despite its layernorm being over the latent rank, not hidden_size. TE then returns ((out, ln_out), bias), so MCore's q, _ = self.linear_q_up_proj(...) leaves q a tuple and q.view() raises.

Isolated by probing the module rather than by inspection — two plausible hypotheses (the dynamic linear wrapper overriding forward; conversion touching the up-projection) were both wrong:

Setup linear_q_up_proj returns Forward
Before conversion tuple(Tensor, NoneType)
After conversion, no hooks tuple(Tensor, NoneType) OK
After conversion + importance hooks tuple(tuple(Tensor, Tensor), NoneType) AttributeError

Fix: exclude MLA's Q/KV up-projections from the patch and unpatch loops (~20 lines). test_mcore_mla_pruning never caught this because it builds MLA without q_lora_rank, where MCore uses a plain linear_q_proj and nothing is patched.

Verified on 2 GPUs (nemo:26.08.rc6):

Check Result
test_prune_minitron[deepseek_v3] (restored) PASSED, 52s
test_prune_minitron[qwen3] / [nemotron_h] PASSED
tests/gpu_megatron/torch/prune/plugins/ 18 passed, incl. test_mcore_mla_pruning
Pruned DeepSeek checkpoint reload 246,032 params; hidden_size=112, moe_intermediate_size=56, n_shared_experts=1, q_lora_rank=32 preserved

The restored deepseek_v3 case now covers both the MLA Q-LoRA path and candidate_filter end-to-end: without the filter the search picks a shared size that is not a multiple of the routed one, and the reload fails on the shape mismatch.

One aside worth recording: an early DeepSeek run printed Rejected 546 candidates via candidate_filter immediately followed by No subnets found fitting the constraints!, and I spent time chasing the params target before realising the filter had emptied the grid — concrete support for the suggestion to name the rejected count in that assertion.

The pruned checkpoint intermittently reloaded with the *source* config
against pruned weights. Every rank ran bridge.hf_pretrained.save_artifacts()
(writing the source config.json), while save_hf_pretrained writes the pruned
config from rank 0 alone -- so a late write from another rank could land last.
This only bites the config-only path; the dummy-model path writes the pruned
config from every rank, so per-rank ordering makes it self-correcting. That
also explains the earlier Qwen3 failure, which had the same symptom and was on
the config-only path at the time -- so narrowing the export gate moved Qwen3
off the racy path rather than fixing a config-only export defect. The gate stays
narrow (hybrids are still the only models that need it), but the comment no
longer claims config-only export drops pruned fields for dense models.

Guard the artifact write to rank 0 and barrier before reading it back.
Verified: nemotron_h failed intermittently in a 3-test session before, 3/3
clean sessions after.

Review follow-ups:
- _is_deepseek_style_moe keyed on the value, not hasattr: n_shared_experts=None
  is a valid "no shared expert" setting, and it made both the filter lambda and
  the export guard raise TypeError on `%` instead of taking the no-shared path.
- base_config omits hparams MCore leaves unset, so a filter touching one raises
  KeyError naming the field rather than TypeError from inside the search loop.
- The "no subnets found" assert names the rejected count, so an emptied grid is
  not mistaken for too-tight constraints (hit while debugging DeepSeek).
- cleanup() docstring: skipping the barrier is not sufficient on its own, since
  destroy_process_group blocks for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

Race fix on HF export (9907656) — and a correction to an earlier claim

While verifying the last review round, nemotron_h failed with pruned weights against a source config.json — a test that had passed twice earlier on the same container, and that passes 3/3 in isolation. It is a race, not a flake in the usual sense:

  • Our bridge.hf_pretrained.save_artifacts() ran on every rank, writing the source config.json.
  • save_hf_pretrained writes the pruned config from rank 0 alone (auto_bridge.py:1042).

So a late write from another rank lands last and the checkpoint ships with a config that does not match its weights. Fixed by guarding the artifact write to rank 0 with a barrier before reading it back. Before: intermittent failure in a 3-test session. After: 3/3 clean sessions.

Correction. I previously reported that routing dense models through the config-only bridge "regressed Qwen3 — hidden_size=24 weights with a config.json saying 32", and narrowed the export gate on that basis. That symptom is identical to this race, and Qwen3 was on the config-only path at the time, so the narrowing most likely moved Qwen3 onto the dummy path (which writes the pruned config from every rank and is therefore self-correcting) rather than fixing a config-only export defect. The gate stays narrow — hybrids are still the only models that need config-only export — but the claim that it "drops pruned config fields" for dense models was unsupported and has been removed from both the code comment and this description.

Also in this commit (review follow-ups)

  • _is_deepseek_style_moe keyed on the value, not hasattrn_shared_experts=None is a valid no-shared-expert setting; it previously took the DeepSeek path and raised TypeError on % in both the filter lambda and the export guard. Real crash.
  • base_config omits unset hparams — a filter touching one now raises KeyError naming the field instead of TypeError from inside the search loop.
  • The "no subnets found" assert names the rejected count — my own DeepSeek debugging hit exactly this: Rejected 546 candidates followed by No subnets found fitting the constraints!, which sent me chasing the params target.
  • cleanup() docstring — states that skipping the barrier is not sufficient, since destroy_process_group blocks for the same reason.

Declined the trust_remote_code hasattr assertion (defensive code for a rename that has not happened, on a path no test covers either way) and did not extend abort() to puzzletron/scoring.py / hf_ptq/example_utils.py — worth a follow-up, but outside this PR.

@kevalmorabia97 kevalmorabia97 changed the title Minitron pruning fixes for Nemotron-3-Lightning and Deepseek Minitron pruning fixes for Nemotron-3.5-Lightning-30B-A3B and Deepseek Aug 12, 2026
@kevalmorabia97
kevalmorabia97 merged commit f995232 into main Aug 12, 2026
64 checks passed
@kevalmorabia97
kevalmorabia97 deleted the kmorabia/prune-nemotron-lightning-hf-export branch August 12, 2026 15:35
@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.

2 participants