Skip to content

Commit 333d2dc

Browse files
committed
fix(export): stop the resume dir growing per layer, and close four review gaps
The auto-derived resume dir was never opted into, and kept one full activation set per layer -- on a 40-layer model that dwarfs the checkpoint it sits beside. Prune every boundary but the committed one, after the manifest commits it so a crash mid-write still resumes. Scoped to the per-layer export path, leaving the explicit checkpoint_dir semantics untouched. Fuse the sibling experts the probe never routed to, mirroring the replay requantize_resmooth_fused_llm_layers does for the same reason: sync_moe_gate_up_amax covers weight_quantizer.amax but not a static quantizer's global_amax, so unrouted gate/up pairs would keep unmerged scales. Drop the format re-check latch -- it inspected only one layer, so a recipe applying a pre-quant-scale format to a subset still shipped unfused pre_quant_scale. Refuse MTP checkpoints before calibration on the normal load path: only the FSDP2 loader flags the prefixes early, so the run used to write a complete-looking checkpoint and fail afterwards. Also: no MoE gate/up warning at finalize (the sync happens inside transient_module_state, so it fired on every MoE run and reported a miss that did not happen); delete out-of-range shards from a longer previous run; null-safe manifest read plus a num_layers check; drop __all__, which advertised names the package never exported. The resume test now interrupts for real rather than rewinding a finished run's manifest -- a state a crash cannot produce. Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
1 parent ee8111f commit 333d2dc

5 files changed

Lines changed: 133 additions & 32 deletions

File tree

examples/hf_ptq/hf_ptq.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,11 @@ def _layerwise_get(cfg, key, default=None):
13631363
# Complementary to recipe `*mtp*` wildcards (name-match); this catches MTP layers
13641364
# identified by index.
13651365
mtp_layer_prefixes = getattr(full_model, "_mtp_layer_prefixes", None)
1366+
if args.layerwise_export and not mtp_layer_prefixes:
1367+
# Only the FSDP2 loader flags these before quantization. Per-layer export has
1368+
# to refuse *before* calibration, or the run writes a complete-looking
1369+
# checkpoint and only then discovers it is missing the MTP weights.
1370+
mtp_layer_prefixes = mtp_layer_prefixes_from_checkpoint(args.pyt_ckpt_path)
13661371
if mtp_layer_prefixes:
13671372
quant_cfg = copy.deepcopy(quant_cfg)
13681373
for prefix in mtp_layer_prefixes:

modelopt/torch/export/layerwise_export.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import contextlib
1919
import hashlib
2020
import json
21+
import re
2122
import warnings
2223
from collections.abc import Callable
2324
from pathlib import Path
@@ -29,13 +30,6 @@
2930
from .model_config import FUSION_FREE_FORMATS, QUANTIZATION_NVFP4
3031
from .quant_utils import get_quant_config, get_quantization_format
3132

32-
__all__ = [
33-
"LayerwiseExporter",
34-
"assert_layerwise_export_supported",
35-
"layer_shard_name",
36-
"transient_module_state",
37-
]
38-
3933
# Fusing formats this path can handle itself. The groups _fuse_shared_input_modules works
4034
# on -- q/k/v behind input_layernorm, gate/up behind post_attention_layernorm -- live
4135
# inside one decoder layer, so export_layer rediscovers them per layer instead of needing
@@ -72,6 +66,40 @@ def _is_quantized_module(module: nn.Module) -> bool:
7266
)
7367

7468

69+
def _fuse_unrouted_experts(layer_module: nn.Module, fused_linears: dict[str, list[str]]) -> None:
70+
"""Fuse the sibling experts the probe never routed a token to.
71+
72+
The probe runs one real batch, so on a 256-expert layer it activates a handful and the
73+
rest would keep unmerged gate/up scales -- ``sync_moe_gate_up_amax`` covers
74+
``weight_quantizer.amax`` but not the static quantizer's ``global_amax``. Mirrors the
75+
replay ``requantize_resmooth_fused_llm_layers`` does for the same reason, scoped to
76+
this layer because experts never span one.
77+
"""
78+
from .quant_utils import preprocess_linear_fusion
79+
80+
names = {name for name, _ in layer_module.named_modules()}
81+
for group, members in fused_linears.items():
82+
if not re.search(r"experts?\.\d+", group):
83+
continue
84+
expert_id = 0
85+
while True:
86+
sibling = re.sub(r"(experts?\.)\d+", rf"\g<1>{expert_id}", group, count=1)
87+
if sibling in fused_linears: # the probe already fused this one
88+
expert_id += 1
89+
continue
90+
if sibling not in names:
91+
break
92+
preprocess_linear_fusion(
93+
[
94+
layer_module.get_submodule(
95+
re.sub(r"(experts?\.)\d+", rf"\g<1>{expert_id}", member)
96+
)
97+
for member in members
98+
]
99+
)
100+
expert_id += 1
101+
102+
75103
def _module_formats(model: nn.Module) -> set:
76104
"""Every distinct format present. ``get_quantization_format`` stops at the first."""
77105
return {
@@ -279,7 +307,6 @@ def __init__(
279307
else raw_tied_keys
280308
)
281309

282-
self._calibrated_format_checked = False
283310
self._bind_identity(quant_config)
284311

285312
def export_layer(
@@ -335,12 +362,10 @@ def _assert_calibrated_format_supported(self, layer_module: nn.Module) -> None:
335362
336363
AWQ and SVDQuant are only distinguishable after calibration -- their discriminators
337364
(``_pre_quant_scale``, ``svdquant_lora_a``) are registered by the calibrator -- so
338-
the constructor's gate sees plain NVFP4 and passes. Checking again on the first
339-
exported layer fails loudly instead of shipping unfused pre_quant_scale.
365+
the constructor's gate sees plain NVFP4 and passes. Every layer, not just the
366+
first: a recipe may apply such a format to a subset, and a resumed run starts
367+
part-way through. One walk per layer is one whole-model walk in total.
340368
"""
341-
if self._calibrated_format_checked:
342-
return
343-
self._calibrated_format_checked = True
344369
unsupported = sorted(str(f) for f in _module_formats(layer_module) - SUPPORTED_FORMATS)
345370
if unsupported:
346371
raise NotImplementedError(
@@ -377,9 +402,10 @@ def _fuse_shared_inputs(
377402
input_to_linear, _ = collect_shared_input_modules(
378403
layer_module, lambda: probe_forward(layer_module)
379404
)
380-
_fuse_shared_input_modules(
405+
fused = _fuse_shared_input_modules(
381406
self._ctx.model, input_to_linear, quantization_format=layer_format
382407
)
408+
_fuse_unrouted_experts(layer_module, fused)
383409

384410
def finalize(self, extra_state_dict: dict[str, torch.Tensor] | None = None) -> dict:
385411
"""Export the tail, write the config artifacts, and index all shards.
@@ -395,7 +421,6 @@ def finalize(self, extra_state_dict: dict[str, torch.Tensor] | None = None) -> d
395421
from .unified_export_hf import (
396422
_add_mtp_exclusions,
397423
_dispatch_export_handler,
398-
_warn_on_unsynced_moe_gate_up,
399424
_write_hf_export_config,
400425
save_non_weight_artifacts,
401426
)
@@ -409,7 +434,9 @@ def finalize(self, extra_state_dict: dict[str, torch.Tensor] | None = None) -> d
409434
model = self._ctx.model
410435
quant_config = get_quant_config(model, is_modelopt_qlora=self._ctx.is_modelopt_qlora)
411436
_add_mtp_exclusions(model, quant_config)
412-
_warn_on_unsynced_moe_gate_up(model)
437+
# No _warn_on_unsynced_moe_gate_up: export_layer syncs each layer inside
438+
# transient_module_state, so the shards are synced but the live model is not --
439+
# the check would fire on every MoE run and report a miss that did not happen.
413440
if getattr(model, "hf_quantizer", None) is not None:
414441
model.hf_quantizer = None
415442
# Config module names must match the tensors', or a loader treats an excluded
@@ -585,6 +612,12 @@ def _write_index(self) -> None:
585612
"""
586613
from safetensors import safe_open
587614

615+
# A longer previous run's shards are already out of the index; delete them too,
616+
# so the directory *is* the checkpoint rather than the checkpoint plus leftovers.
617+
for stale in self._export_dir.glob("model-layer-*.safetensors"):
618+
if int(stale.stem.rsplit("-", 1)[1]) >= len(self._layers):
619+
stale.unlink()
620+
588621
shards = [self._export_dir / layer_shard_name(i) for i in range(len(self._layers))]
589622
shards.append(self._export_dir / _TAIL_SHARD)
590623

modelopt/torch/quantization/model_calib.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,8 +2120,15 @@ def layerwise_calibrate(
21202120
# detect_resume_point returns None once the manifest is complete, which would put
21212121
# start_layer back at 0 and recalibrate everything. The shards are already on disk,
21222122
# so a completed manifest means finalize-only.
2123-
manifest = _read_manifest(checkpoint_dir)
2124-
if manifest is not None and manifest.get("last_completed_layer", -1) + 1 >= num_layers:
2123+
manifest = _read_manifest(checkpoint_dir) or {}
2124+
last, total = manifest.get("last_completed_layer"), manifest.get("num_layers")
2125+
if total is not None and total != num_layers:
2126+
raise ValueError(
2127+
f"Layerwise checkpoint at {checkpoint_dir} was written for {total} layers "
2128+
f"but this model has {num_layers}. Use a fresh checkpoint_dir."
2129+
)
2130+
# None, not -1: a truncated or hand-edited manifest must not arithmetic-error here.
2131+
if last is not None and last + 1 >= num_layers:
21252132
exporter.assert_shards_present(num_layers)
21262133
exporter.finalize()
21272134
print_rank_0(f"Layerwise export: finalized existing shards in {export_dir}")

modelopt/torch/quantization/utils/layerwise_calib.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,15 @@ def full_restore(self, layers: nn.ModuleList, model: nn.Module) -> None:
737737

738738
print_rank_0(f"Checkpoint: restored {self.start_layer} previously calibrated layers")
739739

740+
def _prune_stale_next_inputs(self, keep: int) -> None:
741+
"""Drop every layer's cached activations but the committed boundary's."""
742+
for idx in range(self.num_layers):
743+
if idx == keep:
744+
continue
745+
stale = os.path.join(_layer_dir(self.checkpoint_dir, idx), "next_inputs.pt")
746+
if os.path.exists(stale):
747+
os.remove(stale)
748+
740749
def save(
741750
self,
742751
layer_idx: int,
@@ -803,6 +812,12 @@ def save(
803812
calib_mutates_weights=self.calib_mutates_weights,
804813
save_layer_state=self.save_layer_state,
805814
)
815+
# Per-layer export only, whose resume dir is auto-derived and so was never opted
816+
# into: without this it keeps one activation set per layer, which dwarfs the
817+
# checkpoint it sits beside. After the manifest commits the boundary, so a crash
818+
# mid-write still leaves the previous one resumable.
819+
if not self.save_layer_state:
820+
self._prune_stale_next_inputs(keep=layer_idx)
806821
window_start = self._last_saved_layer + 1
807822
self._last_saved_layer = layer_idx
808823
window_size = layer_idx - window_start + 1

tests/gpu/torch/export/test_layerwise_export.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717

1818
import copy
1919
import json
20-
import shutil
20+
from unittest.mock import patch
2121

2222
import pytest
2323
import torch
24-
from _test_utils.torch.transformers_models import get_tiny_llama
24+
from _test_utils.torch.transformers_models import get_tiny_llama, get_tiny_qwen3_moe
2525
from safetensors.torch import load_file
2626

2727
import modelopt.torch.quantization as mtq
28+
from modelopt.torch.export.layerwise_export import LayerwiseExporter, layer_shard_name
2829
from modelopt.torch.export.unified_export_hf import export_hf_checkpoint
2930

3031
NUM_LAYERS = 4
@@ -215,28 +216,41 @@ def test_layerwise_export_replaces_resume_artifacts(tmp_path):
215216

216217
assert not list(checkpoint_dir.rglob("weights.pt"))
217218
assert not list(checkpoint_dir.rglob("quantizer_buffers.pt"))
218-
# next_inputs and output_meta are not reconstructible from exported weights, so they stay.
219+
# output_meta is not reconstructible from exported weights, so it stays.
219220
assert list(checkpoint_dir.rglob("output_meta.pt"))
221+
# The cached activations are the bulk of the resume dir, and only the committed
222+
# boundary's are resumable -- keeping one per layer would dwarf the checkpoint.
223+
assert not list(checkpoint_dir.rglob("next_inputs.pt")), (
224+
"a completed run has nothing to resume from, so no activation cache should remain"
225+
)
220226

221227

222228
def test_resume_skips_exported_layers(tmp_path, baseline_checkpoint):
223229
"""A run resuming mid-model must still produce the full, correct checkpoint."""
224230
export_dir = tmp_path / "fused"
225231
checkpoint_dir = tmp_path / "ckpt"
226-
mtq.quantize(_build_model(), _layerwise_cfg(export_dir, checkpoint_dir), _calib)
232+
# Die partway, the way a lost GPU session would: only the committed boundary is
233+
# resumable, so rewinding a *finished* run's manifest would not reproduce this state.
234+
real_export_layer = LayerwiseExporter.export_layer
235+
236+
def die_at_layer_2(self, layer_idx, *args, **kwargs):
237+
if layer_idx == 2:
238+
raise RuntimeError("simulated interruption")
239+
return real_export_layer(self, layer_idx, *args, **kwargs)
240+
241+
with (
242+
patch.object(LayerwiseExporter, "export_layer", die_at_layer_2),
243+
pytest.raises(RuntimeError, match="simulated interruption"),
244+
):
245+
mtq.quantize(_build_model(), _layerwise_cfg(export_dir, checkpoint_dir), _calib)
227246

228-
# Rewind the manifest so the next run believes only layers 0..1 finished; their shards
229-
# are on disk and must be reused rather than recalculated.
230-
manifest_path = checkpoint_dir / "manifest.json"
231-
manifest = json.loads(manifest_path.read_text())
232-
manifest["last_completed_layer"] = 1
233-
manifest_path.write_text(json.dumps(manifest))
247+
assert (export_dir / layer_shard_name(1)).is_file(), "layer 1 was never committed"
248+
assert not (export_dir / layer_shard_name(2)).exists(), "layer 2 should not have landed"
234249

235-
resumed_dir = tmp_path / "resumed"
236-
shutil.copytree(export_dir, resumed_dir)
237-
mtq.quantize(_build_model(), _layerwise_cfg(resumed_dir, checkpoint_dir), _calib)
250+
# Shards 0..1 are on disk and must be reused rather than recalculated.
251+
mtq.quantize(_build_model(), _layerwise_cfg(export_dir, checkpoint_dir), _calib)
238252

239-
_assert_same_checkpoint(baseline_checkpoint, _load_checkpoint(resumed_dir))
253+
_assert_same_checkpoint(baseline_checkpoint, _load_checkpoint(export_dir))
240254

241255

242256
def test_resume_without_matching_shards_fails_fast(tmp_path):
@@ -347,6 +361,33 @@ def test_identity_without_shards_does_not_block_a_rerun(tmp_path):
347361
assert _load_checkpoint(export_dir), "rerun produced no checkpoint"
348362

349363

364+
def _build_moe_model():
365+
torch.manual_seed(0)
366+
model = get_tiny_qwen3_moe(num_experts=16, num_experts_per_tok=1).cuda().eval()
367+
model.config.architectures = ["Qwen3MoeForCausalLM"]
368+
return model
369+
370+
371+
def test_moe_export_matches(tmp_path):
372+
"""MoE layers take a different path: fused expert inputs and gate/up amax sync.
373+
374+
This fixture uses the fused expert representation (one ``mlp.experts`` module), so it
375+
does not cover the per-expert sibling replay in ``_fuse_unrouted_experts`` -- that
376+
needs a checkpoint whose experts are separate ``experts.N.gate_proj`` modules.
377+
"""
378+
baseline_dir = tmp_path / "baseline"
379+
base = _nvfp4_cfg()
380+
base["algorithm"] = {"method": "max", "layerwise": {"enable": True}}
381+
export_hf_checkpoint(mtq.quantize(_build_moe_model(), base, _calib), export_dir=baseline_dir)
382+
383+
export_dir = tmp_path / "fused"
384+
mtq.quantize(
385+
_build_moe_model(), _layerwise_cfg(export_dir, tmp_path / "ckpt", base=_nvfp4_cfg()), _calib
386+
)
387+
388+
_assert_same_checkpoint(_load_checkpoint(baseline_dir), _load_checkpoint(export_dir))
389+
390+
350391
def test_export_does_not_mutate_the_model(tmp_path):
351392
"""Exporting a layer must leave the model exactly as calibration left it.
352393

0 commit comments

Comments
 (0)