Skip to content

[OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally - #2750

Merged
shengliangxu merged 76 commits into
mainfrom
shengliangx/hfep-12a-keep-fused-fold
Oct 30, 2026
Merged

shengliangxu merged 76 commits into
mainfrom
shengliangx/hfep-12a-keep-fused-fold

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [14/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night.

[2/2] of this PR's split (2026-10-28), made at its review's request because it was over the 500-line budget. [1/2], #3099, with the NVFP4 DTensor packing and the held FSDP2 weight-update windows, merged on 2026-10-29. This PR keeps the fused-experts fold.

Base: main. The diff below is this PR's alone (499 source lines). Review fixes are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature

The fused-experts export splits each 3-D expert weight into full per-expert tensors before it quantizes them. Under FSDP2 that first all-gathers the weight, so every rank ends up holding every expert: a 235B MoE OOMed at about 157 GB per rank, however many nodes it ran on.

  • Folding in place. With keep_fused_experts set on the export context, the handler folds each expert's quantizer into the fused weight in place:
    • On a sharded weight it quantizes only this rank's experts, finding each one's quantizer by its global expert number when the module keeps every expert's quantizers (FSDP2), or by its position in the shard when it keeps only its own (expert parallelism). Any other count is refused on every rank before anything is folded.
    • Where a rank keeps only its own experts' quantizers (expert parallelism), the fold first gathers each projection's quantizer states over the mesh dim that shards the experts, so every rank folds, leaves or refuses the projection alike, and a refusal names the global experts. The gather runs on that path only: two small object gathers per fused-experts module.
    • It keeps the result sharded with the original expert count. Uneven splits, such as 3 experts over 2 ranks, keep their numbering.
  • Unquantized experts. A projection whose experts are all unquantized stays as it is, which is what the per-expert export writes for it, unless its weights are native FP8 (below). One whose experts mix quantized and unquantized cannot be one fused tensor and is refused before anything is folded.
  • Splitting afterwards. split_fused_experts_state_dict later cuts the folded weight and its stacked scales into exactly the per-expert keys the single-process export writes. It takes one module's shard at a time, not a consolidated state dict: its input keeps the fused tensors while the per-expert copies are made.
  • FSDP2 windows. When the model's distribution plan ([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736) FSDP2-shards its dense part, FSDP2 owns the fused weights, so the fold runs inside the weight window of the unit that encloses them ([OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's _fsdp2_weight_window). The window hands FSDP2 the folded weights and reshards on the way out; folded outside it, the next reshard would put FSDP2's own unquantized shards back.

The held FSDP2 windows and the NVFP4 packing of DTensor shards are #3099's, [1/2] of this split.

Nothing sets keep_fused_experts yet. The writer that consumes the fold arrives in #2751 and #2752.

The fold drops the source checkpoint's expert scales (*_scale_inv, *_activation_scale) as the per-expert export does, and refuses on every rank a layout that leaves a rank no experts, a fused weight sharded on a dim other than the expert axis or over more than one mesh dim, and an unsharded weight whose num_experts, expert rows and weight quantizers disagree. The shard checks run for every sharded projection, quantized or not. It also refuses native FP8 expert weights in every projection, quantized or not, since it drops each projection's scale_inv (dequantize them first, unpack_weight()): a native-FP8 projection whose experts a recipe leaves unquantized would otherwise be kept as raw FP8 bytes without it. The split cuts a packed or block scale in proportion to the weight rows it cuts, so INT4 blockwise, W4A8 AWQ and FP8 2-D blockwise gate and up get their own scales, and refuses a block scale a gate|up cut would split (128-row blocks over gpt-oss's 2880-row gate, say) instead of rounding.

Usage

N/A: internal to the distributed export (#2752).

Testing

  • New tests/unit/torch/export/test_fused_experts_keep_fused.py, on CPU under gloo, one and two ranks:

    • fold-then-split against _export_fused_experts, byte for byte, for gated and ungated experts in FP8, FP8 weight-only and NVFP4;
    • the uncalibrated-expert fallback, unquantized projections exported as is, and a mixed projection, native FP8 weights or a num_experts short of the weight's experts refused with the module untouched, a native-FP8 projection whose experts are all unquantized included;
    • two ranks folding their own Shard(0) experts, each with its own quantizer at its global offset (_dtensor_dim0_offset), against the unsharded fold, an uneven 3-over-2 shard included; gathered whole with full_tensor(), the two ranks' folds split into exactly the per-expert export;
    • expert-parallel quantizer lists (each rank keeps only its own) against the unsharded fold; a mismatched count refused on both ranks; rank 1 disabling some or all of its experts' quantizers refused on both ranks after they gather their quantizer states, naming global indices, and every expert disabled left unquantized on both; a layout that leaves a rank no expert refused on both;
    • a fused weight sharded off the expert axis ((Shard(2),)) or over two mesh dims ((Shard(0), Shard(0))) refused, on one gloo rank;
    • the split refusing a block across the gate|up cut, and uneven blocks;
    • the export handler's switch, and the fused experts folded inside a weight window exactly when the plan shards the dense part.
  • The NVFP4 DTensor packing and held-window tests are [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's (test_sharded_weight_packing.py); _split_local_fused_module's tests went to [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752 with it.

  • Mutation-checked: each test fails under at least one deliberate break of the code, the handlers' two plan reads, the shard-local quantizer pairing, the quantizer-count, native-FP8 (an all-unquantized native-FP8 projection's too), num_experts, placement and split refusals, the ranks' joint decision on a projection, and the window's unit included.

  • The export comparisons are one table, the one-rank and faked uneven shards fold into the real two-rank job, and the NVFP4 zero-block tests move beside main's scale-clamping tests. Each drop is checked by mutation.

  • The per-expert comparison covers source scales, and INT4 blockwise, W4A8 AWQ and FP8 2-D blockwise experts at 128-wide dims; one expert over two ranks is refused on both.

  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.

  • GPU tests were not re-run on this revision: the fold's gather has run on two gloo ranks only, not yet over NCCL in an expert-parallel export on GPUs. (It has since 2026-10-29, in the GPU test at the end of this list.)

  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.

  • Review fixes, 2026-10-27: the fold reads each expert through the module's own accessor, in the weight domain the per-expert export packs; one FSDP2 weight window per unit rather than per linear; a fused-experts module not named experts is refused before anything is folded. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.

  • Review fixes, 2026-10-27, round 2: a unit's FSDP2 window is held only inside held_fsdp2_weight_windows, which the quantized-module export enters, and each handler packs its module in one call (the BMM experts, which pack two weights, refuse the keep-fused export); the fold puts its stacked scales on the weight's device; the fold hands the expert accessor the module with only the projection swapped, which [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745's accessor needs (with [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745 merged in, 26 keep-fused and fused-experts unit tests fail without it and pass with it). Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.

  • Review fixes, 2026-10-27, round 3: the fold writes each packed expert into one preallocated stack, so it holds the packed projection once (table below); a zero NVFP4 block scale is replaced with a scalar rather than a tensor of ones; a window on the model itself reuses the module names the export context holds; comments say what orders an expert-parallel rank's quantizers and which experts a rank folds inside an FSDP2 weight window. The byte-for-byte comparisons against _export_fused_experts pass unchanged; pre-commit, mypy included, and this PR's own unit tests pass at this head. Peak memory the fold adds over its input, one gated module (hidden 1024, intermediate 1024), CPU, torch 2.14:

  • Rebased on 2026-10-27 onto main 7c3c868, after [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8).

  • Review suggestions, 2026-10-27 evening: the keep-fused fold refuses a projection whose weight quantizers use a GGML format, before anything is folded.

    Experts, format Packed gate_up Before After
    64, FP8 128 MiB 274 MiB 166 MiB
    64, NVFP4 64 MiB 195 MiB 149 MiB
    128, FP8 256 MiB 826 MiB 280 MiB
    128, NVFP4 128 MiB 477 MiB 217 MiB
  • Review suggestion, 2026-10-27 night: _local_expert_weight's docstring says which reads of the projection the local rows stand in for. The keep-fused tests' ungated case runs transformers' real NemotronHExperts, so it passes whichever wrapper quantizes Nemotron-H's experts ([OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 gives them one of their own).

  • Review fixes, 2026-10-27, last round: the BMM experts (GptOssExperts, Llama4TextExperts) refuse the keep-fused export: a GPT-OSS checkpoint without a quantization_config, loaded with FSDP2 and exported without gathering, would have had its fused gate_up_proj split on the wrong axis. The new test fails without the change. Docstring and wording fixes beside it.

  • Review suggestions, 2026-10-27, last round: comments name why the GGML refusal agrees across ranks and where the replicated input_scale is synchronized; two unreachable None guards are removed.

  • Review fix, 2026-10-27, last round: the gloo_world_of_one fixture destroys its process group in a finally, also when its mesh fails to build.

  • Review suggestion, 2026-10-28: test_meta_dcp_load.py's module-local gloo_world_of_one is deleted in favor of the shared conftest fixture, the same deletion [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741 makes.

  • Rebased on 2026-10-28, late night, onto [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736 at cf4597dcbeb, on main 1ca8bd1fc4d (after [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088 and [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745 merged), without conflicts; this PR's own diff is unchanged. Its tests with the fused-experts and FSDP2 helper tests pass (266), and with [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 merged in, which also changes _QuantFusedExperts, the keep-fused, fused-experts and all-to-all tests pass (244). pre-commit passes against main.

  • Split on 2026-10-28, late night, at the review's request (meenchen's review bot): the NVFP4 DTensor packing and the held FSDP2 weight-update windows moved to [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099 ([1/2]) with their tests, and this PR merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099 into its history, so its own commits stay as they were. _split_local_fused_module, whose only caller is [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752's writer, moves to [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752 with its two tests (it lands there at [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752's next rebase; until then [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752's base still carries it). The module's local imports moved to the top, the DTensor = None fallback going with them (2504934ab24). keep_fused_experts keeps its name: it switches the no-gather export, as its comment says, and the FSDP2 windows are part of that path. Rebased onto main bdc45368d10 after [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736 merged. The fold's tests, the export tests and test_fused_experts.py pass (730; 7 test_export_diffusers.py cases fail the same way without this PR in that environment), the fold with [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 merged in passes its tests (238), and pre-commit passes against main.

  • 2026-10-29: merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's first review round (ebfb6a7b050): a module joins its unit's held window once, however many weights it packs; comments cut to their constraint; module-scope imports. No change of its own. tests/unit/torch/export, test_nvfp4_tensor.py, test_meta_dcp_load.py and quantization/test_utils.py pass (620; the 7 test_export_diffusers.py cases fail as before in that environment), and pre-commit passes against main.

  • 2026-10-29, later: _export_bmm_experts's docstring no longer says a rank packs its experts without a gather. Every path that reaches it now packs whole weights, and the keep-fused export refuses these experts (d34142120fd, at [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's review). Docstring only; pre-commit passes against main.

  • Review (meenchen's review bot), 2026-10-29: the two-rank fold test is back (3a86980a421), without _split_local_fused_module, which is [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752's now. Each rank's fold is checked against the unsharded one at its offset, and the ranks' folds, gathered whole with full_tensor(), against the per-expert export (gloo cannot gather FP8, so one-byte values move as bytes). Each of four deliberate breaks fails it: quantizers paired by position for every layout, the ranks' quantizer-state gather skipped, the rewrap inferring the global shape, and the no-expert refusal dropped. The native-FP8 test imports PretrainedConfig at module scope; FP8Experts stays behind importorskip, since transformers 5.5 imports triton in its module. The keep-fused tests pass (48) on transformers 5.18 and 5.5.4, and pre-commit passes against main. The description no longer claims [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's windows and NVFP4 packing.

  • 2026-10-29, later: merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's second review round (54f8632c78b): a unit that owns a weight directly keeps its packed weight in the window, and the packing has a two-rank test. No change of its own (a322d417b4b). The keep-fused, sharded-packing, NVFP4 and param-index tests pass (79), and pre-commit passes against main.

  • 2026-10-29, later: merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's GPU test of the sharded packing (51f187fb029). No change of its own (0759477e213); pre-commit passes against main.

  • 2026-10-29, evening (meenchen's review bot, optional item): _export_fused_experts_module's docstring drops a paragraph from before the keep-fused fold, which said FSDP2 ranks pack their own experts kept fused (26e9863b87d). Docstring only; pre-commit passes against main.

  • 2026-10-29, evening: merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's third review round (6033bb8e2a8): the INT4, NF4 and MXFP4 packers refuse a DTensor shard, NVFP4 refuses one with a static quantizer's scale, and a module the weight window cannot name keeps off the root's key (fa69b309c15). Its only change of its own is keep_fused_experts' new docstring, which here also says the flag keeps fused experts fused and sharded. Pre-commit passes against main.

  • 2026-10-29, evening: a GPU test of the fold, tests/gpu/torch/export/test_fused_experts_keep_fused_gpu.py (ec2741cdef3), for meenchen's review bot's question about NCCL. On every visible GPU, and skipped below two:

    • Each rank folds its experts, every expert's quantizers held as under FSDP2, in FP8 and NVFP4, an uneven shard included. Each fold is checked against the unsharded fold at its offset. Gathered over NCCL, the folds must split into the per-expert export.
    • A layout that leaves a rank no expert is refused.
    • Under expert parallelism, each rank keeps only its own quantizers. The ranks gather their quantizer states and refuse a mixed projection on every rank, naming the global experts.
    • The ranks' state gather runs over Gloo in the cpu:gloo,cuda:nccl group the export runs on, and over NCCL in a group of NCCL alone.
    • Every collective gives up after a minute.
    • Checked on two and four gloo ranks with the GPU swapped for the CPU. Ignoring the gathered states fails it.
  • GPU run, 2026-10-29, at ec2741cdef3: test_fused_experts_keep_fused_gpu.py::test_ranks_fold_their_own_experts_over_nccl, both cases (objects-over-gloo, objects-over-nccl). GB200, torch 2.11.0a0 (NGC 26.02), CUDA 13.1, NCCL 2.29.2, transformers 5.9.0. A mutant whose ranks each decide alone, ignoring the gathered quantizer states, fails both cases: it still refuses, but names the rank's local experts ("quantizers [1] of 2") instead of the global ones ("[3] of 4" on two GPUs, "[7] of 8" on four).

    Run 1 GPU 2 GPUs 4 GPUs
    This PR (ec2741cdef3) 2 skipped 2 passed, 21.1 s 2 passed, 27.6 s
    Mutant: each rank decides alone not run 2 failed, 48.6 s 2 failed, 52.1 s
  • 2026-10-29, evening: merges [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099's fourth review round (46742ffd24e), comments only (6f1d1da4b2b). Pre-commit passes against main.

  • 2026-10-29, night: [1/2] ([OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099) merged into main as 6c475d716cf, and this PR now merges main (830d7044438). registry.py and unified_export_hf.py conflicted where this PR extends [1/2]'s text, and take this PR's side. The PR's diff against main is byte for byte its diff against [1/2]'s last head. Pre-commit passes against main.

  • Review (Fridah-nv), 2026-10-29, night: split_fused_experts_state_dict's docstring says how it numbers the experts it is given, from 0 by their row in plain tensors, which a caller splitting a rank's local shard shifts by the shard's offset (fc6d99829d8, docstring only). The suggestion to hand get_expert_weight a tensor instead of the fold's proxy is answered in its thread. Swapping the fold's accessor reads for local_w[idx].to(dtype) fails 12 of the byte-for-byte tests. Pre-commit passes against main; 497 source lines.

  • Review (Claude's review, approved), 2026-10-29, night (945271deb04):

    • The split refuses projections whose expert counts differ; test_split_refuses_projections_holding_different_expert_counts fails without the check.
    • The scale slicer's comment says that only the gate|up cut reads its row count.
    • The GPU test's expert-parallel comparison also checks the folded keys. A run of its job on two and four gloo ranks, with the GPU swapped for the CPU, passes.
    • The keep-fused tests pass (49), and pre-commit passes against main; 499 source lines.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Single-process and gather exports are unchanged; the fold runs only when the export context asks for it.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A. The no-gather export's entry lands with [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752.
  • Did you get Claude approval on this PR?: ✅ At fc6d99829d8; its two suggestions are in 945271deb04.

Additional Information

Part of expert-parallel PTQ for Hugging Face MoE models.

The Codex re-review item "Validate quantization consistency across EP ranks before folding" is handled in this PR, by the fold's gather, as well as by #2753's model-wide pre-check.

Summary by CodeRabbit

  • New Features
    • Distributed model exports can retain fused expert weights and convert them into per-expert checkpoint entries afterward, while preserving sharded layouts.
  • Bug Fixes
    • Improved export handling for sharded expert weights and scales, including uneven expert distributions.

(Edited after the split: the NVFP4 fixes this summary listed are #3099's.)

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 46e8cc98-b044-49ee-9cb5-216f53d15c90
📥 Commits

Reviewing files that changed from the base of the PR and between a3e2acd and b728e3a.

📒 Files selected for processing: 1
  • tests/unit/torch/export/test_fused_experts_keep_fused.py

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

Walkthrough

The pull request adds an opt-in path for no-gather export of fused MoE experts. It quantizes fused weights, supports DTensor shards, and adds utilities to split fused state dictionaries into per-expert entries.

Changes: Distributed fused MoE export

Layer / File(s) Summary
Wire fused export into handlers
modelopt/torch/export/registry.py, modelopt/torch/export/hf_export_handlers.py, tests/unit/torch/export/test_fused_experts_keep_fused.py
ExportContext adds the keep_fused_experts option. No-gather export uses the keep-fused exporter, with an FSDP2 weight-update window for dense FSDP. Handler tests cover opt-in behavior and update-window ordering.
Quantize fused expert weights
modelopt/torch/export/moe_utils.py, modelopt/torch/quantization/qtensor/nvfp4_tensor.py, tests/unit/torch/export/test_fused_experts_keep_fused.py, tests/unit/torch/quantization/test_nvfp4_tensor.py, tests/unit/torch/conftest.py
Fused expert quantization handles plain tensors and DTensor shards. NVFP4 scale handling and packing support DTensor inputs, and zero-amax blocks receive scale 1. Tests cover quantization configurations, uncalibrated experts, and sharded NVFP4 behavior.
Split fused state dictionaries
modelopt/torch/export/moe_utils.py, tests/unit/torch/export/test_fused_experts_keep_fused.py
The splitter converts fused gated and ungated projections into per-expert entries. Rank-local splitting uses shard offsets and expert-parallel indexing. Tests cover scale propagation, unrelated keys, and distributed expert indices.

Priority: Normal

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant ExportContext
  participant HFExportHandler
  participant FSDP2WeightUpdateWindow
  participant KeepFusedExporter
  ExportContext->>HFExportHandler: enable keep_fused_experts
  HFExportHandler->>FSDP2WeightUpdateWindow: pack weights within window when dense FSDP is active
  FSDP2WeightUpdateWindow->>KeepFusedExporter: export fused expert weights
Loading
Merge Risk: Moderate · up to b728e

Keep-fused export can select the wrong expert quantizer on an EP shard, and can fail when a rank owns no experts. Resolve these distributed-export cases before merging unless their limitations are explicitly accepted.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns Passed No listed security anti-pattern was introduced. The reviewed Python additions contain no torch.load(..., weights_only=False), numpy.load/np.load with allow_pickle=True, hardcoded trust_remote_code=Tru…
Title check Passed The title clearly and concisely describes the main change: folding fused experts' quantizers into weight shards locally.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

No listed security anti-pattern was introduced. The reviewed Python additions contain no torch.load(..., weights_only=False), numpy.load/np.load with allow_pickle=True, hardcoded trust_remote_code=True, eval/exec calls, or # nosec comments. The pull request does not change pyproject.toml or requirements.txt.

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

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

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from 65d7878 to e832c67 Compare October 12, 2026 01:11
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11c-kimi-k3 branch from 660d339 to 784e815 Compare October 12, 2026 02:44
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from e832c67 to 52b4592 Compare October 12, 2026 02:44
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-30 00:32 UTC

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 52b4592

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.17225% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.91%. Comparing base (6c475d7) to head (945271d).

Files with missing lines Patch % Lines
modelopt/torch/export/moe_utils.py 95.97% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2750      +/-   ##
==========================================
+ Coverage   78.18%   79.91%   +1.72%     
==========================================
  Files         664      664              
  Lines       76452    76659     +207     
==========================================
+ Hits        59776    61262    +1486     
+ Misses      16676    15397    -1279     
Flag Coverage Δ
examples-diffusers 19.85% <7.17%> (-0.07%) ⬇️
examples-gpt-oss 13.44% <7.17%> (-0.03%) ⬇️
examples-hf_ptq 23.43% <9.56%> (-0.06%) ⬇️
examples-llm_distill 13.51% <7.17%> (-0.03%) ⬇️
examples-llm_eval 17.23% <7.17%> (+<0.01%) ⬆️
examples-llm_qat 17.36% <7.17%> (-0.07%) ⬇️
examples-llm_sparsity 16.74% <7.17%> (+<0.01%) ⬆️
examples-megatron_bridge 26.37% <7.17%> (-0.38%) ⬇️
examples-specdec_bench 13.22% <7.17%> (-0.03%) ⬇️
examples-speculative_decoding 17.47% <7.17%> (-0.06%) ⬇️
examples-torch_onnx 21.12% <7.17%> (-0.11%) ⬇️
examples-torch_trt 15.17% <7.17%> (-0.05%) ⬇️
examples-vllm_serve 17.45% <7.17%> (-0.02%) ⬇️
gpu 58.90% <10.04%> (+5.99%) ⬆️
regression 14.99% <7.17%> (-0.03%) ⬇️
unit 62.33% <96.17%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from 52b4592 to d314e75 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test d314e75

@shengliangxu shengliangxu changed the title [15/19] Fold fused experts' quantizers into their weights shard-locally [OMNIML-6081] [15/19] Fold fused experts' quantizers into their weights shard-locally Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from d314e75 to 636aea0 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11c-kimi-k3 branch from 57494b6 to 25176cb Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from 636aea0 to dbb570a Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test dbb570a

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6081] [15/19] Fold fused experts' quantizers into their weights shard-locally [OMNIML-6081] [15/20] Fold fused experts' quantizers into their weights shard-locally Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from dbb570a to e85bc68 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11c-kimi-k3 branch from 53b85aa to ba6939d Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6081] [15/20] Fold fused experts' quantizers into their weights shard-locally [OMNIML-6081] [17/21] Fold fused experts' quantizers into their weights shard-locally Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11c-kimi-k3 branch from ba6939d to dec8f8c Compare October 13, 2026 21:12
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from e85bc68 to be5d536 Compare October 13, 2026 21:12
@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-11c-kimi-k3 branch from dec8f8c to 954d8bf Compare October 14, 2026 00:14
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12a-keep-fused-fold branch from be5d536 to 4e1039c Compare October 14, 2026 00:14
@shengliangxu
shengliangxu added this pull request to stack #2786 October 14, 2026 00:14
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Thanks. The optional item is done in 26e9863: the paragraph of _export_fused_experts_module's docstring that said FSDP2 ranks pack their own experts kept fused is gone. Without the keep-fused fold the handler splits every expert, and the docstring's last paragraph describes the fold. Docstring only; pre-commit passes against main.

`<<` is a silent no-op on a DTensor, and the INT4, NF4 and MXFP4
packers, and the export's INT4 packer, still shifted with it. They now
use torch.bitwise_left_shift, which gives the same bytes on a plain
tensor.

None of them packs a shard either way: each combines its input with
plain tensors, which DTensor refuses deep inside, or, on INT4's fallback,
gathers it whole on every rank. So each now refuses a DTensor up front,
and NVFP4 refuses one that comes with a plain per-block scale, such as a
static quantizer's, which covers the whole weight.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The window keys a parameter its root owns by its bare name, and
_param_key took any falsy module name for the root. A module the
window's name map does not know (None) then took the root's key, and
the window would have handed FSDP2 that module's weight as the root's
own. Only "" names the root now; an unknown module keeps its old None.
key, which no parameter has, so the window skips it as before.

keep_fused_experts is documented in a docstring, like the fields after
it, as the marker of the no-gather export.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…he window's root key, into the keep-fused fold

keep_fused_experts' docstring, from [1/2], also says what the field
means for fused experts here.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test fa69b30

Each rank folds the experts it holds on its GPU, at their global
offsets, and the folds, gathered over NCCL, split into the per-expert
export, an uneven shard included. Under expert parallelism the ranks
decide on each projection together: their quantizer states go over Gloo
in the cpu:gloo,cuda:nccl group the export runs on, and over NCCL in a
group of NCCL alone. Every collective runs on a group that gives up
after a minute. The test skips below two GPUs.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test ec2741c

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Following up on the second sign-off item, that the expert-parallel all_gather_object had only run over gloo: it now has GPU runs, over NCCL among them.

tests/gpu/torch/export/test_fused_experts_keep_fused_gpu.py (ec2741cdef3) runs the two-rank fold job on every visible GPU, and skips below two:

  • Each rank folds the experts it holds and is checked against the unsharded fold at its offset. Gathered over NCCL, the folds must split into the per-expert export, an uneven shard included.
  • Under expert parallelism each rank keeps only its own quantizers. The ranks gather their quantizer states and refuse a mixed projection on every rank, naming the global experts.
  • One case gathers the states in the cpu:gloo,cuda:nccl group the export runs on, where object collectives go over Gloo. The other gathers them in a group of NCCL alone.

GB200, torch 2.11.0a0 (NGC 26.02), CUDA 13.1, NCCL 2.29.2, transformers 5.9.0:

Run 1 GPU 2 GPUs 4 GPUs
This PR (ec2741cdef3) 2 skipped 2 passed, 21.1 s 2 passed, 27.6 s
Mutant: each rank decides alone not run 2 failed, 48.6 s 2 failed, 52.1 s

The mutant ignores the gathered states. It still refuses, but names the rank's local experts ("quantizers [1] of 2") instead of the global ones ("[3] of 4" on two GPUs, "[7] of 8" on four). So the test pins that the refusal comes from the states gathered across the ranks.

…PU test

_process_quantized_modules enters held_fsdp2_weight_windows around its
dispatch loop, but its context leaves keep_fused_experts unset and its
assert keeps sharded models out, so no window opens there yet. Its
comment now says so instead of describing a live property. The GPU
test's comment names what ends a rank left waiting in a window: pytest's
per-test timeout, after which the worker pool kills its ranks.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…nto the keep-fused fold

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 6f1d1da

[1/2] merged into main as one squashed commit, so this branch takes main
in a merge, as it took [1/2]'s heads before, and keeps its own commits.
registry.py and unified_export_hf.py conflicted where this PR extends
[1/2]'s text, and take this PR's side. Main holds [1/2]'s files exactly
as its last head left them, so the PR's diff against main is the diff
it had against [1/2], byte for byte.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 830d704

return sorted(prefixes)


def split_fused_experts_state_dict(state_dict: dict) -> dict:

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.

Question (non-blocking): the per-expert keys are numbered from 0 over whatever tensors come in. The docstring says "one module's shard at a time", but the tests split full_tensor() results. Is the contract whole tensors, or a local shard with the caller adding the offset? A rank-1 shard would otherwise be numbered from 0. Worth one line in the docstring, since #2751/#2752 call it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Plain tensors, numbered by their row, with the caller owning the global numbers. The docstring now says so (fc6d998):

  • split_fused_experts_state_dict takes n_experts = first_w.shape[0] and emits keys {prefix}.{expert_idx}.* for expert_idx in range(n_experts). So a module's experts are numbered from 0 by their row in whatever it is given.
  • Whole tensors get their global numbers. The tests hand it full_tensor() results, and the single-process layout is whole.
  • For a rank's local shard, the caller shifts the numbers. In [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it #2752, _split_local_fused_module passes the to_local() rows, then adds the shard's global offset (_dtensor_dim0_offset, plus the expert-parallel block base when the weight holds only its group's experts) to each index.
  • So a rank-1 shard is numbered from 0 only inside that call, and the writer never sees those numbers.
  • "One module's shard at a time" meant one module's tensors, to bound memory. It read as a rank's shard, so it now says "tensors".

return DTensor.from_local(local, mesh, placements, run_check=False, shape=shape, stride=stride)


class _LocalRows:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion (non-blocking): the proxy self works, but it depends on which attributes the accessor reads, and the docstring notes a bound method would read the whole DTensor. get_expert_weight is this repo's own method, so could it take an optional tensor instead? Also, native FP8 is now refused up front, so what does the proxy still cover that local_w[idx].to(dtype) doesn't?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

On what the proxy covers that local_w[idx].to(dtype) does not: the weight domain the per-expert export packs.

  • _export_fused_experts reads each expert through module.get_expert_weight(proj, idx, dtype). The base _QuantFusedExperts.get_expert_weight ignores dtype and returns getattr(self, name)[idx] as stored.
  • The export derives the NVFP4 per-block scales from that weight, and leaves an unquantized projection in its stored dtype.
  • .to(dtype) would round each expert to the export dtype first. I swapped both of the fold's accessor reads for it, and 12 of the byte-for-byte tests fail: NVFP4, the uncalibrated fallback, the unquantized-projection cases, W4A8 AWQ's split, and the two-rank fold.

I'd keep the proxy rather than add a tensor argument to get_expert_weight, for now:

  • Who defines it: [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 and [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 redefine both accessors, _QuantFusedExperts.get_expert_weight and _QuantFP8Experts.get_expert_weight, to index through _local_experts. A new argument has to be added and honored in all of them, and whichever PR merges second has to carry it again. That couples this PR to theirs for a call only the fold makes.
  • What it reaches: for each of those accessors the rows stand in exactly where an argument would. Each reads the projection once as getattr(self, name) and hands it to its helper as an argument (self._local_experts(getattr(self, name))). The docstring's caveat is for a helper that reads self.<proj> on its own, which none does.
  • Coverage: the keep-fused tests fold through the real accessors and compare byte for byte with the per-expert export. They use _QuantFusedExperts on the gated layout and _QuantNonGatedFusedExperts on transformers' NemotronHExperts, and the two-rank job folds rows a rank holds at a non-zero offset.

Once #3074 and #3067 land, the argument would fit as one follow-up that changes every definition at once.

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

LGTM

It numbers each module's experts from 0 by their row in the tensors it
receives, so whole tensors get their global numbers, and a caller that
splits a rank's local shard shifts each index by the shard's offset.
"One module's shard at a time" meant one module's tensors, which read as
a rank's shard.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test fc6d998

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +760 to +772
for proj_name, weight, ws, ins, ws2, row_slice in projections:
base = f"{prefix}.{expert_idx}.{proj_name}"
# .clone() every emitted tensor so no two keys share storage. gate/up share the
# per-tensor scale object (and slices alias the fused parent); without cloning,
# safetensors/save_pretrained shared-tensor dedup silently drops the duplicate
# (e.g. up_proj.weight_scale/input_scale would go missing).
out[f"{base}.weight"] = weight.detach().clone().contiguous()
sliced_ws = _slice_weight_scale(ws, expert_idx, row_slice, first_w.shape[1])
if sliced_ws is not None:
out[f"{base}.weight_scale"] = sliced_ws.detach().clone().contiguous()
# weight_scale_2 is a per-expert scalar (shared by gate/up): _slice_weight_scale's
# dim<=1 branch returns ws2[expert_idx] regardless of row_slice. NVFP4 only; None for FP8.
sliced_ws2 = _slice_weight_scale(ws2, expert_idx, row_slice, first_w.shape[1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] _slice_weight_scale's rows denominator is first_w.shape[1] for every projection in the tuple list, including down_proj — whose own fused row count is down_w.shape[1].

It is correct today only by short-circuit: down_proj (and the ungated up_proj) always pass row_slice=None, so line 718 returns before rows is read. Nothing in the signature or the comment says the argument is only meaningful for the gate|up cut, so the first caller that ever passes a row_slice for down_proj gets proportional math against the wrong denominator — and for the common HIDDEN_DIM != 2 * INTERMEDIATE_DIM case that is a silently mis-sliced block scale rather than the NotImplementedError at line 725.

Cheapest fix is to carry the projection's own fused total in the tuple instead of re-deriving it at the call site:

projections = [
    ("gate_proj", first_w[expert_idx, :inter, :], first_ws, first_in, first_ws2, slice(0, inter), first_w.shape[1]),
    ("up_proj",   first_w[expert_idx, inter:, :], first_ws, first_in, first_ws2, slice(inter, None), first_w.shape[1]),
    ("down_proj", down_w[expert_idx],             down_ws,  down_in,  down_ws2,  None,               down_w.shape[1]),
]
...
for proj_name, weight, ws, ins, ws2, row_slice, fused_rows in projections:
    ...
    sliced_ws  = _slice_weight_scale(ws,  expert_idx, row_slice, fused_rows)
    sliced_ws2 = _slice_weight_scale(ws2, expert_idx, row_slice, fused_rows)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment now says it, in 945271d: rows is the fused gate|up's row count, and only the gate and up cuts read it. That is the case today too. down_proj and an ungated up_proj pass row_slice=None, which returns before rows is read, and the only row slices are the two halves of first_w, which the loop builds itself.

I didn't add a per-projection total to the tuples. It would be a field no current entry reads, and this PR sits at 499 of its 500 source lines.

Comment thread modelopt/torch/export/moe_utils.py Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude review — keep-fused expert fold, [2/2] (round 4)

Scope: full review (the trigger comment carried no scoping instructions). 6 files changed (+1498/−9). I read the whole modelopt/ diff (4 files, 506 insertions) and both test files. The branch is a single squashed commit against main, so instead of diffing against the earlier rounds I re-traced the cross-file pieces: _fsdp2_weight_window / held_fsdp2_weight_windows / _export_weight (hf_export_handlers.py), _process_quantized_modules (unified_export_hf.py), _export_fused_experts and _delete_fused_moe_source_attrs (moe_utils.py), the two get_expert_weight implementations in quantization/plugins/huggingface.py, and the dist_workers / cpu_dist_workers / need_2_gpus fixtures.

Findings: CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 2 (both posted inline, both non-blocking)

What I verified this round

Fold/split equivalence with the reference path. I traced _export_fused_experts_keep_fused + split_fused_experts_state_dict against _export_fused_experts stage by stage. The two agree where it matters:

  • The uncalibrated fallback uses the whole fused (2I, H) expert slice, so gate and up share one amax and therefore one weight_scale_2 — the same invariant that the shared-amax step at moe_utils.py:473-491 exists to preserve for the W1/W3 fusion in vLLM. The non-gated and down_proj cases fall through to the per-projection slice, matching the fallback at line 557.
  • Quantizing the fused slice once and slicing the resulting scale is equivalent to the reference order (slice the amax, then quantize) for per-row and per-block amax, since each row scale depends only on its own amax; and trivially so for per-tensor amax, which the numel() > 1 guard leaves unsliced.
  • The proportional cut in _slice_weight_scale is exact for the layouts claimed: NVFP4 (2I, H/16), INT4 and W4A8 (2I, H/group), FP8 2-D (ceil(2I/128), ceil(H/128)). The gpt-oss-shaped case where a 128-row block straddles the gate|up cut raises rather than rounding.

Rank agreement. The decision loop runs every check and both all_gather_object calls for both projections before the fold loop touches anything, so a refusal on the second projection cannot leave the first folded — the "refused with the module untouched" claim holds. _refuse_unfoldable_shards reaches the same verdict on every rank: the global shape and the mesh are replicated, and the only rank-dependent input, an uneven EP quantizer count, is refused on all ranks by the num_quantizers * shards check. shards can accumulate at most one mesh dim, because a multi-Shard placement is refused first. The zero-expert guard matches the chunking torch does (ceil(E/size)), so 3-over-2 passes and 7-over-8 is refused.

Previously raised, now closed.

  • The round-1 hazard where _fused_experts_prefixes also matched the BMM experts: _export_bmm_experts now raises before its QUANTIZATION_NONE early return, and it is registered by class name, so it is dispatched for every GptOssExperts / Llama4TextExperts whether quantized or not. A model with transposed BMM experts can no longer reach the keep-fused export at all, so the split never meets one.
  • The round-2 held-window module-list dedup is in place at hf_export_handlers.py:89-90 (identity check).
  • The all-gather staging-device suggestion now has real coverage rather than an argument: test_fused_experts_keep_fused_gpu.py parametrizes the group over cpu:gloo,cuda:nccl and over NCCL alone, so the object gather runs on both, with torch.cuda.set_device done by the worker pool.

The new GPU test (tests/gpu/torch/export/test_fused_experts_keep_fused_gpu.py, 211 lines) is the main thing added since the last round, and it is well targeted: the sharded fold checked byte for byte against the plain fold at the rank offset, round-tripped through full_tensor() into the per-expert export, plus the uneven 2*W-1 shard, the no-expert-rank refusal, EP-local quantizer lists, and the joint decision of the ranks on a partly-disabled projection. One soft spot: the EP comparison at lines 180-183 iterates the folded state dict and indexes plain[key], so an extra key fails the test but a missing one does not — the assert set(fused) == set(plain) used at line 138 would close that.

Risk

Low to moderate. The feature is dead code until #2751/#2752 (nothing sets keep_fused_experts, nothing calls split_fused_experts_state_dict), every refusal is a loud NotImplementedError rather than a silent fallback, and the non-keep-fused path through _export_fused_experts_module and _export_bmm_experts is unchanged. The residual risk is the one the description states plainly: the EP object gather had not run over NCCL until the GPU test added today, which the linux CI job will not execute.

Both suggestions harden split_fused_experts_state_dict against inputs the fold itself cannot produce. Neither blocks.

🤖 Generated with Claude Code

…U test's keys

The split numbers each module's experts by the first projection, so a
down_proj holding more experts lost the extra ones unwritten, and one
holding fewer failed deep in the loop. It is refused up front, naming
the module. The scale slicer's comment says that only the gate|up cut
reads its row count. The GPU test's expert-parallel comparison checks
the folded keys too, as the two-rank unit test does.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 945271d

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Thanks. All three points are addressed in 945271d:

  • The GPU test's expert-parallel comparison now asserts set(_state(sharded)) == set(plain) before comparing values, so a missing key fails it, as in the two-rank unit test. A run of the job on two and four gloo ranks, with the GPU swapped for the CPU, passes.
  • Projections with different expert counts are refused up front, with a test that fails without the check. The thread has the details.
  • The scale slicer's row count: its comment now says only the gate|up cut reads it.

The PR is at 499 source lines. Pre-commit passes against main.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants