Repository navigation
[OMNIML-6081] Fold fused experts' quantizers into their weights shard-locally - #2750
shengliangxu wants to merge 38 commits into
Conversation
|
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. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review: 3
Please check the settings in the CodeRabbit UI or the
|
| 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:
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
Merge Risk: 
Moderate · previous review 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: 
5
-
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.
65d7878 to
e832c67
Compare
660d339 to
784e815
Compare
e832c67 to
52b4592
Compare
|
|
/ok to test 52b4592 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## shengliangx/hfep-9-ep-model-specs #2750 +/- ##
=====================================================================
+ Coverage 61.91% 62.11% +0.19%
=====================================================================
Files 655 655
Lines 73164 73416 +252
=====================================================================
+ Hits 45301 45601 +300
+ Misses 27863 27815 -48
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
52b4592 to
d314e75
Compare
|
/ok to test d314e75 |
d314e75 to
636aea0
Compare
57494b6 to
25176cb
Compare
636aea0 to
dbb570a
Compare
|
/ok to test dbb570a |
dbb570a to
e85bc68
Compare
53b85aa to
ba6939d
Compare
ba6939d to
dec8f8c
Compare
e85bc68 to
be5d536
Compare
dec8f8c to
954d8bf
Compare
be5d536 to
4e1039c
Compare
… with no experts split_fused_experts_state_dict cut gate's and up's weight scales at the weight's row split, which holds only when a scale has one row per weight row. An INT4 weight packs two rows a byte while its scales stay per row, and an FP8 2-D block scale covers 128 rows, so for INT4 blockwise, W4A8 AWQ and FP8 2-D blockwise experts gate and up got the wrong scales. Each now takes the scale rows in proportion to the weight rows it takes; the per-expert export comparison covers the three formats. The keep-fused fold refuses, on every rank, a layout that leaves a rank no experts: that rank would stack nothing and register no input scale, so the ranks' state dicts would differ. It reads the shard's expert offset through _dtensor_dim0_offset, as the split does. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Under expert parallelism a fused-experts module keeps only its own experts' weight quantizers, in shard order, while its fused weight's DTensor spans every expert. The keep-fused fold indexed them by global expert, so every rank but the first would raise IndexError. It now pairs each local expert with the quantizer at its position in the shard when the list is shorter than the global extent, and refuses on every rank a quantizer count that is neither every expert's nor the shard's. The fold also refuses native FP8 expert weights, which only the scale_inv it drops dequantizes, and the split refuses a block scale whose rows cannot be cut at the gate|up boundary (a 128-row block across gpt-oss's 2880-row gate, say) instead of flooring the proportion. Every refusal of the fold runs before either projection is folded. The FSDP2 weight window found its unit by scanning the model's modules once per exported module and rebuilt the model's name maps each time. It now walks up the module's name through maps built once per export (ExportContext.module_names) and opens on the innermost enclosing unit. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The keep-fused fold refused native FP8 expert weights only in a projection whose experts are all quantized, but it deletes <proj>_scale_inv and <proj>_activation_scale for both projections. A native FP8 projection whose experts a recipe leaves unquantized was therefore kept as raw FP8 bytes with its scale_inv dropped, and split_fused_experts_state_dict then cut them into per-expert weights nothing can dequantize, where the per-expert export writes them dequantized. The check now runs for every projection, before either is folded, so that case is refused with the module untouched. Also: _fsdp2_weight_window's docstring says why an unwrapped root hands the window the unit itself (fsdp2_aware_weight_update opens it on the innermost enclosing unit, but only under a wrapped root), and _split_local_fused_module's comment no longer says the fold emits plain EP-group scales, which it emits as DTensors. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Under expert parallelism each rank keeps only its own experts' weight quantizers, so the keep-fused fold decided on a rank-local mask: one rank could fold a projection that another left as it was (all of its experts disabled) or refused (some of them), and the ranks' state dicts and the writer's collectives then diverged. The fold now gathers the projection's quantizer states over the mesh dim that shards the experts, joined by global offset, and folds, leaves or refuses it on the gathered mask, so every rank decides alike and a refusal names the global experts. The shard checks now run first for every sharded projection, so the ranks also agree on whether to gather. The shard checks also refuse a fused weight sharded on another dim or over more than one mesh dim: the fold shards the scales on the expert axis alone and gathers over one mesh dim. An unsharded weight is refused when num_experts, its experts and its quantizers disagree, instead of folding only the first experts. split_fused_experts_state_dict's docstring now asks for one module's shard at a time, since its input keeps the fused tensors while the per-expert copies are made. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The per-expert export reads each expert through get_expert_weight, which casts native FP8 experts' unpacked weights, held at the default dtype, to the export dtype. The fold read the raw slices, so it derived NVFP4 block scales from fp32 and left a projection with no enabled quantizer in fp32. It now reads each expert through that accessor, from this rank's rows, and writes a projection it leaves as it is in the same domain. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The fold claims a fused-experts module by its structure, but the split into per-expert keys, and the writer, find its fused weights by their experts.<proj> key. A container named otherwise was folded and then written fused, to fail only at load. Refuse it before anything is folded. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fsdp2_aware_weight_update unshards a module's whole FSDP2 unit and reshards it when it closes, so a window per module gathered a decoder layer once per quantized linear in it. The handlers now hold the unit's window open from one module to the next, each joining the modules it re-registers with FSDP2, and the export driver closes the last. The window also resolves the unit's names once rather than walking the unit per parameter. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A held window leaves its unit unsharded until something closes it, and only the _process_quantized_modules loop did. The layerwise and streaming exports dispatch the same handlers from their own loops, and would have left a unit gathered on every rank. The handlers now hold a unit's window only inside held_fsdp2_weight_windows, which that loop enters and which closes the last window on the way out; any other loop packs each module in a window of its own, closed by its handler. Closing a window is no longer a caller's job. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The BMM experts handler packs gate_up_proj and then down_proj of the same module, which joined the unit's held window once per weight. Closing the window then re-registered the module's weights with FSDP2 twice: the second pass built an FSDPParam from the DTensor shard the first pass had left, which FSDP2 takes for a tensor-parallel param and refuses. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Per-tensor FP8 makes each expert's weight scale from a Python float, so on the CPU wherever the weight lives, and a sharded fold rewrapped the stacked CPU scales as a DTensor on the weight's mesh: a CUDA mesh over a CPU shard, which no collective can serve. The fold now moves the stacked scales to the weight's device first. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…swapped The keep-fused fold reads each expert through the module's own get_expert_weight, and handed it this rank's rows of the fused weight on a bare namespace. The fused-experts PR (#2745) has that accessor pick this rank's experts with the module's _local_experts, which the namespace lacks: with both in, every fold raised AttributeError, failing 26 keep-fused and fused-experts unit tests. The accessor now reads a view of the module whose only difference is the projection. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The fold kept each expert's packed weight and scale in a list and stacked them at the end, so for a moment it held the packed projection twice, beside the high-precision fused weight. It now writes each expert into one stack made from the first expert's packed shape, so the packed projection is held once. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…l this tree lacks The comment cited a helper that comes with another PR of the series. It now states the invariant itself: transformers sets num_experts to the local count, so the quantized module makes one quantizer per expert of its local weight, in the shard's order. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The window unshards FSDP2's params before the fold reads them, so under FSDP2 alone the fused experts arrive as the unit's whole unsharded weight and each rank folds every expert of the unit, one unit at a time; expert weights FSDP2 does not shard, as expert parallelism keeps them, stay sharded over the experts and each rank folds its own. The fold's comment no longer says an FSDP2 fused weight is always a sharded DTensor there. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
torch.where takes the 1.0 as a Python scalar, which DTensor dispatches as it does the tensor form (checked on torch 2.8 and 2.14), so the zero-scale replacement no longer makes a full-size tensor of ones for every packed weight. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A module no FSDP2 unit encloses opens its window on the model itself, whose names the export context already holds; the window no longer builds the model-wide map again for it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The export's held windows append each module of an FSDP2 unit to the list they passed to fsdp2_aware_weight_update after entering it, and rely on that list being read again when the window closes. A copy taken at entry would leave every appended module's packed weights unregistered with FSDP2, to be overwritten by its original shards at the next reshard. The docstring now states the contract. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A GGML format packs each expert's weight into its blocks and registers no weight_scale, which the fold stacks per expert: the fold failed with an AttributeError midway, possibly after replacing the first projection. It is refused with the fold's other unsupported layouts now, before any projection is folded and on every rank alike; the per-expert export still writes it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The rows replace self.<proj> where get_expert_weight reads it itself. A method it calls is bound to the module and would read the whole fused DTensor, so an accessor written that way would gather it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The ungated stand-in relied on the generic plugin detecting any up_proj-only container. The real class converts through whichever wrapper quantizes Nemotron-H's experts: that detection today, a registration of their own once Nemotron-H's experts get one. The fixture leaves a class the registry covers through its base class alone, since unregistering it would fail. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…em wrong The writer splits a keep-fused export's experts.<proj> weights into per-expert keys as (num_experts, out_dim, in_dim). GPT-OSS's and Llama-4's BMM experts are named experts too but store theirs transposed (GPT-OSS also interleaves gate and up and keeps biases), so the split would write every expert wrong, quantized or not. Their handler now refuses the keep-fused export before packing anything; the gather export writes them fused, as their checkpoints are. Nothing else packs several weights of one module into the held window, so the window's once-per-module check goes too. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…t is legacy Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The format comes from a recipe that sets it by module name, so it is the same for every expert of a projection on every rank, and the ranks refuse alike without gathering it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It is the one value the fold writes replicated rather than sharded; calibration syncs the shared input quantizer's amax over the data-parallel ranks and, under expert parallelism, over the expert-parallel group, so every rank registers the same value. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fw comes from a direct subscript of the state dict, which raises on a missing key, so it is always a tensor and the None arms are dead. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Their module-local copy shadowed the one this PR adds to the torch unit conftest. Deleting it is the same change #2741 makes, so whichever lands first, the other merges cleanly. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test d097fd2 |
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.
Base: #2736. The diff below is this PR's alone, and it merges after #2736.
Re-cut: the
gloo_world_of_onefixture is added totests/unit/torch/conftest.pyhere (#2741 brought it before), in the same text #2751 adds, so either order merges cleanly. Review fixes since are listed at the end of Testing.Series layout
mainWhat 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.
keep_fused_expertsset on the export context, the handler folds each expert's quantizer into the fused weight in place:split_fused_experts_state_dictlater 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.ExportContext.module_names), instead of scanning the model per module. The window hands FSDP2 the packed weights and reshards on the way out. Insideheld_fsdp2_weight_windows(ctx), which the quantized-module export enters, a unit's window stays open across its modules and closes when the next unit's opens or the block exits; elsewhere each module's handler opens and closes its own.Without it, the next reshard puts FSDP2's own unquantized shards back.
Opening the window on the unit rather than the root matters for expert parallelism, which wraps only the decoder layers.
Time to open the window around each of 10 linears per FSDP2 unit, in a synthetic model of about 1,000 modules per unit (CPU, one gloo rank, torch 2.14):
index_put_, which DTensor cannot dispatch._cast_fp4packs the local shard and keeps the global shape.torch.bitwise_left_shift:x << 4on a DTensor returnsxunchanged (torch 2.11), which packed unrelated codes next to correct scales.Nothing sets
keep_fused_expertsyet. 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 whosenum_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'sscale_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:_export_fused_experts, byte for byte, for gated and ungated experts in FP8, FP8 weight-only and NVFP4;num_expertsshort of the weight's experts refused with the module untouched, a native-FP8 projection whose experts are all unquantized included;(Shard(2),)) or over two mesh dims ((Shard(0), Shard(0))) refused, on one gloo rank;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/uniton 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.
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
expertsis 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] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745's accessor needs (with [OMNIML-6073] 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_expertspass 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.
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' realNemotronHExperts, 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 aquantization_config, loaded with FSDP2 and exported without gathering, would have had its fusedgate_up_projsplit 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_scaleis synchronized; two unreachableNoneguards are removed.Review fix, 2026-10-27, last round: the
gloo_world_of_onefixture destroys its process group in afinally, also when its mesh fails to build.Review suggestion, 2026-10-28:
test_meta_dcp_load.py's module-localgloo_world_of_oneis deleted in favor of the shared conftest fixture, the same deletion [OMNIML-6060] Load an MoE expert-parallel: shard the experts at load time #2741 makes.Before your PR is "Ready for review"
CONTRIBUTING.md: N/AAdditional Information
Part 14 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2746.
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