Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e1a06b3
fix(hf_ptq): detect layerwise recipes whose algorithm parses as a dict
Fridah-nv Aug 10, 2026
b7e1622
feat(export): export each decoder layer as layerwise calibration fini…
Fridah-nv Aug 10, 2026
053b1df
feat(export): support accelerate offload in per-layer export
Fridah-nv Aug 13, 2026
f33a399
fix(export): close review findings in per-layer export
Fridah-nv Aug 13, 2026
88ae24e
refactor(export): make ExportContext the single state owner in layerw…
Fridah-nv Aug 19, 2026
d3b97dc
docs(export): compact the comments in the layerwise export path
Fridah-nv Aug 19, 2026
21ecccd
fix(hf_ptq): keep the layerwise resume manifest alongside its shards
Fridah-nv Aug 13, 2026
e887c5a
fix(export): adopt the name-based tied-weight map in per-layer export
Fridah-nv Aug 19, 2026
8c1673f
fix(export): refuse to overwrite layer shards when the resume record …
Fridah-nv Aug 19, 2026
5b45897
fix(export): repair the doc build and mypy on the layerwise export path
Fridah-nv Aug 20, 2026
24ecfbb
fix(export): address PR review on the layerwise export path
Fridah-nv Aug 20, 2026
39c80d7
fix(export): bind shards to their run, and keep resume state out of t…
Fridah-nv Aug 20, 2026
90aa931
fix(export): derive the layerwise resume dir instead of overriding it
Fridah-nv Aug 20, 2026
a06e61e
docs(export): trim the layerwise export comments and docstrings
Fridah-nv Aug 20, 2026
c5f9e38
fix(hf_ptq): derive the resume dir for list-form algorithms too
Fridah-nv Aug 20, 2026
4074508
fix(export): let the fusion probe run after a qdq-from-prev capture
Fridah-nv Aug 20, 2026
0d2e78b
fix(hf_ptq): scope layerwise export and resume to the one exporting pass
Fridah-nv Aug 20, 2026
2540775
fix(export): re-check the format gate once the first layer is calibrated
Fridah-nv Aug 20, 2026
ee8111f
test(export): fold the near-duplicate layerwise export cases into tables
Fridah-nv Aug 20, 2026
333d2dc
fix(export): stop the resume dir growing per layer, and close four re…
Fridah-nv Aug 21, 2026
b98c24d
refactor(export): one format gate, and cross resume with every export…
Fridah-nv Aug 21, 2026
eed0019
fix(hf_ptq): scope the qdq-from-prev layer reset to the exporting run
Fridah-nv Aug 21, 2026
2c85487
test(export): pin that the index routes every key to the shard holdin…
Fridah-nv Aug 21, 2026
bb8364b
revert(export): drop the sibling-expert replay, which cannot fire
Fridah-nv Aug 21, 2026
30c91eb
fix(hf_ptq): announce the forced --skip_generate, and state its real …
Fridah-nv Aug 21, 2026
640c011
refactor(export): convert each layer in place, dropping transient_mod…
Fridah-nv Aug 27, 2026
2674269
refactor(export): drop the run-identity file, and build the module ma…
Fridah-nv Aug 27, 2026
42e1ea8
docs(export): name the layer pipeline, and cut the commentary to its …
Fridah-nv Aug 27, 2026
68f4095
refactor(export): drop the storage-alias copy and the tied-weight dedup
Fridah-nv Aug 27, 2026
851e613
refactor(export): hand the exporter its inputs instead of a probe clo…
Fridah-nv Aug 27, 2026
589be3a
fix(export): treat a manifest with no resume point as no resume record
Fridah-nv Aug 27, 2026
b78b331
fix(export): give the fusion probe no cache, since Cache.reset() keep…
Fridah-nv Aug 27, 2026
0676ed4
refactor(export): replay the captured kwargs as they are, cache included
Fridah-nv Aug 27, 2026
a5e39fb
docs(export): keep only the comments that record a hazard
Fridah-nv Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Changelog
*Quantization*

- Add the ``nvfp4_act_headroom`` calibration algorithm for NVFP4 **activation** global scales. Instead of setting the global scale from the largest per-block amax seen during calibration (plain ``max``, which leaves no room above it so any larger activation saturates), it anchors the scale to a low percentile of the per-block amax distribution, leaving the rest of the FP8 block-scale range as headroom: ``amax = max(rho * anchor, upper)``, where ``anchor`` and ``upper`` are the per-block amaxes at ``anchor_percentile`` (default 1) and ``upper_percentile`` (default 99.99; set to 100 to never clip calibration data), and ``rho`` (default 16384) is the headroom factor. Applies only to NVFP4 dynamic-block input quantizers; ``SequentialQuantizer`` activation quantizers raise. Weight scales are an orthogonal axis selected by a nested ``weight_scale_algorithm`` (``max`` by default, or ``mse`` / ``local_hessian``), so one recipe can combine a weight calibration with this activation policy in a single pass. Ships ``modelopt_recipes/general/ptq/nvfp4_act_headroom-kv_fp8_cast.yaml``, which mirrors ``nvfp4_default-kv_fp8_cast`` with only the calibration algorithm swapped and exports a standard NVFP4 checkpoint.
- Add ``layerwise.export_dir``: layerwise calibration writes each decoder layer to its own quantized checkpoint shard as it finishes, so no separate ``export_hf_checkpoint()`` pass is needed and, with ``layerwise.checkpoint_dir``, an interrupted run resumes without redoing finished layers. Supports FP8 and NVFP4 on single-process models, resident or offloaded; other formats and placements raise ``NotImplementedError`` before calibration starts.

*Megatron Framework (M-LM / M-Bridge)*

Expand Down
123 changes: 115 additions & 8 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,12 +1134,90 @@ def copy_custom_model_files(
print("No checkpoint sidecar files found to copy")


def _layerwise_blocks(algorithm) -> list[dict]:
"""Every ``layerwise`` block in the algorithm, which may be one entry or a list."""
entries = algorithm if isinstance(algorithm, list) else [algorithm]
return [
e["layerwise"]
for e in entries
if isinstance(e, dict) and isinstance(e.get("layerwise"), dict)
]


def recipe_layerwise_blocks(recipe) -> list[dict]:
"""Every ``layerwise`` block in a recipe's algorithm(s), in order, normalized to dicts.

Reads the parsed *recipe*, where YAML gives plain dicts and the deprecated
``--auto_quantize_*`` path gives config objects; :func:`_layerwise_blocks` reads the
resolved ``quant_cfg``, which is always dicts.
"""
quantize = getattr(recipe, "quantize", None)
algorithm = getattr(quantize, "algorithm", None)
entries = algorithm if isinstance(algorithm, list) else [algorithm]
blocks = []
for entry in entries:
block = (
entry.get("layerwise") if isinstance(entry, dict) else getattr(entry, "layerwise", None)
)
if block is not None:
blocks.append(block if isinstance(block, dict) else block.model_dump())
return blocks


def _layerwise_checkpoint_dir(algorithm) -> str | None:
"""Return the nested ``layerwise.checkpoint_dir``, or None."""
if not isinstance(algorithm, dict):
"""First ``layerwise.checkpoint_dir`` across the algorithm entries, or None."""
return next(
(b["checkpoint_dir"] for b in _layerwise_blocks(algorithm) if b.get("checkpoint_dir")),
None,
)


def layerwise_export_block(algorithm) -> dict | None:
"""The one ``layerwise`` block that owns per-layer export, or None.

Export finalizes each layer's shard during calibration, so a later pass would change
the model after its checkpoint was written: exactly one entry may set ``export_dir``,
and it must be the last.
"""
entries = algorithm if isinstance(algorithm, list) else [algorithm]
exporting = [
(i, e["layerwise"])
for i, e in enumerate(entries)
if isinstance(e, dict)
and isinstance(e.get("layerwise"), dict)
and e["layerwise"].get("export_dir") is not None
]
if not exporting:
return None
nested = algorithm.get("layerwise") or {}
return nested.get("checkpoint_dir") if isinstance(nested, dict) else None
if len(exporting) > 1:
raise ValueError(
f"{len(exporting)} algorithm entries set layerwise.export_dir; only one "
"calibration pass can own the exported checkpoint."
)
index, block = exporting[0]
if index != len(entries) - 1:
raise ValueError(
f"layerwise.export_dir is set on algorithm entry {index} of {len(entries)}; it "
"must be the last, since a later pass would change the model after its shards "
"were written."
)
return block


def default_layerwise_resume_dir(quant_cfg: dict, export_path: str) -> tuple[dict, bool]:
"""Derive ``layerwise.checkpoint_dir`` from ``export_path`` when unset.

A sibling, not a child: nothing deletes the resume state, so inside ``export_path`` it
would ship in the checkpoint. An explicit path is left alone.
"""
quant_cfg = copy.deepcopy(quant_cfg)
# The exporting block specifically: another pass's explicit checkpoint_dir says nothing
# about where this one resumes from.
block = layerwise_export_block(quant_cfg.get("algorithm"))
if block is None or block.get("checkpoint_dir") is not None:
return quant_cfg, False
block["checkpoint_dir"] = export_path.rstrip("/") + ".layerwise_resume"
Comment thread
Fridah-nv marked this conversation as resolved.
return quant_cfg, True


def needs_checkpoint_path_update(quant_cfg: dict) -> bool:
Expand All @@ -1156,8 +1234,7 @@ def resolve_checkpoint_dir(quant_cfg: dict, model_path: str) -> tuple[dict, str]
Returns ``(updated_quant_cfg, resolved_path)`` so the caller can log or
reference the resolved path without re-deriving the dict shape.
"""
base_dir = _layerwise_checkpoint_dir(quant_cfg["algorithm"])
assert base_dir is not None # guaranteed by needs_checkpoint_path_update
assert needs_checkpoint_path_update(quant_cfg), "no layerwise.checkpoint_dir to resolve"

name = model_path.rstrip("/")
if "/" in name and not os.path.isabs(name):
Expand All @@ -1166,13 +1243,43 @@ def resolve_checkpoint_dir(quant_cfg: dict, model_path: str) -> tuple[dict, str]
name = Path(name).name

config_hash = hashlib.sha256(json.dumps(quant_cfg, default=str).encode()).hexdigest()[:8]
resolved = os.path.join(base_dir, f"{name}_{config_hash}")
suffix = f"{name}_{config_hash}"

quant_cfg = copy.deepcopy(quant_cfg)
quant_cfg["algorithm"]["layerwise"]["checkpoint_dir"] = resolved
# Each pass keeps its own base, so two layerwise passes cannot resolve onto one manifest.
exporting = layerwise_export_block(quant_cfg.get("algorithm"))
resolved = None
for block in _layerwise_blocks(quant_cfg.get("algorithm")):
if block.get("checkpoint_dir") is None:
continue
block["checkpoint_dir"] = os.path.join(block["checkpoint_dir"], suffix)
if resolved is None or block is exporting:
resolved = block["checkpoint_dir"]
assert resolved is not None # needs_checkpoint_path_update found one above
return quant_cfg, resolved


def set_layerwise_export_dir(quant_cfg: dict, export_path: str) -> dict:
"""Retarget layerwise per-layer export at ``export_path``.

The recipe opts in via ``layerwise.export_dir``; its value is a placeholder, since the
destination is per-run. Raises when nothing was retargeted: the caller decides to skip
the real export from a separately parsed recipe, so a silent no-op would leave
``--export_path`` empty on a run reporting success.
"""
quant_cfg = copy.deepcopy(quant_cfg)
algorithm = quant_cfg.get("algorithm")
block = layerwise_export_block(algorithm)
if block is None:
raise ValueError(
"layerwise export is enabled but no layerwise.export_dir was found to retarget "
f"in algorithm={algorithm!r}. The exported shards would go to the recipe's "
"placeholder path instead of --export_path."
)
block["export_dir"] = export_path
return quant_cfg
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def add_mlflow_args(parser: argparse.ArgumentParser) -> None:
"""Add the MLflow tracking flags."""
parser.add_argument(
Comment thread
Fridah-nv marked this conversation as resolved.
Expand Down
130 changes: 114 additions & 16 deletions examples/hf_ptq/hf_ptq.py
Comment thread
Fridah-nv marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
cleanup_distributed,
copy_custom_model_files,
create_vlm_calibration_loop,
default_layerwise_resume_dir,
get_model,
get_processor,
get_tokenizer,
Expand All @@ -43,9 +44,11 @@
mlflow_run,
mtp_layer_prefixes_from_checkpoint,
needs_checkpoint_path_update,
recipe_layerwise_blocks,
resolve_checkpoint_dir,
resolve_mlflow_args,
run_nemotron_vl_preview,
set_layerwise_export_dir,
setup_distributed_args,
validate_fsdp2_supported,
)
Expand Down Expand Up @@ -768,6 +771,65 @@ def mono_quantize(
warnings.warn("Skipping quantization: model is already quantized.")


def assert_layerwise_export_compatible(args, full_model, mtp_layer_prefixes) -> None:
"""Refuse layerwise export before calibration starts, not after it writes a checkpoint.

Layerwise export writes the finished checkpoint during calibration, so anything that
would rewrite or contradict that checkpoint afterwards has to be caught here -- once
calibration begins, the user has already paid for the whole run.
"""
if is_multimodal_model(full_model):
raise NotImplementedError(
"layerwise.export_dir does not support multimodal models: calibration runs on the "
"extracted language model, so the shards and config.json would describe that "
"submodel rather than the full VLM, and the VLM export path would then "
"overwrite config.json with the unquantized source config."
)

if mtp_layer_prefixes:
Comment thread
Fridah-nv marked this conversation as resolved.
raise NotImplementedError(
f"layerwise.export_dir does not support models with MTP layers {mtp_layer_prefixes}: "
"their exclusions and any orphaned MTP weights are applied after calibration, by "
"which point every shard and the quant config are already written."
)

if has_spec_opt(full_model):
raise NotImplementedError(
"layerwise.export_dir does not support speculative-decoding models: "
"export_speculative_decoding() would write a second checkpoint over the same "
"--export_path."
)

if args.cast_mxfp4_to_nvfp4:
raise NotImplementedError(
"layerwise.export_dir is not compatible with --cast_mxfp4_to_nvfp4: the cast "
"rewrites weights after calibration, by which point every shard is written."
)

# Mirrors export_quantized's branches: a second exporter would overwrite --export_path.
for flag, value, exporter in (
("--vllm_fakequant_export", args.vllm_fakequant_export, "export_hf_vllm_fq_checkpoint()"),
("--sparsity_fmt", args.sparsity_fmt != "dense", "export_tensorrt_llm_checkpoint()"),
(
# int8_sq is the export-format constant, int8_smoothquant the qformat preset.
"--qformat int8_smoothquant",
any(t in args.qformat for t in ("int8_sq", "int8_smoothquant")),
"export_tensorrt_llm_checkpoint()",
),
(
"an encoder-decoder model_type (t5/bart/whisper)",
getattr(full_model.config, "model_type", None) in ("t5", "bart", "whisper"),
"export_tensorrt_llm_checkpoint()",
),
):
if value:
raise NotImplementedError(
f"layerwise.export_dir is not compatible with {flag}: {exporter} would write a "
"second checkpoint over the same --export_path that layerwise calibration "
"already populated."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def export_quantized(
args: argparse.Namespace,
full_model: torch.nn.Module,
Expand Down Expand Up @@ -870,11 +932,22 @@ def export_quantized(
if mtp_layer_prefixes:
full_model._mtp_layer_prefixes = mtp_layer_prefixes

export_hf_checkpoint(
full_model,
export_dir=export_path,
extra_state_dict=mtp_state_dict,
)
if args.layerwise_export:
if mtp_state_dict:
raise NotImplementedError(
"layerwise.export_dir does not support models with MTP weights: "
"they are loaded after calibration has already written every "
"shard, so they would be missing from the checkpoint. Export "
"without layerwise.export_dir."
)
# Calibration already wrote every shard, the index and the configs.
print(f"Layerwise export already wrote the checkpoint to {export_path}")
else:
export_hf_checkpoint(
full_model,
export_dir=export_path,
extra_state_dict=mtp_state_dict,
)

if args.qformat == "w4a16_nvfp4":
warnings.warn(
Expand Down Expand Up @@ -1128,17 +1201,22 @@ def quantize_main(
aq_config = None
fixed_quantize_config = None

def _is_layerwise(obj):
if isinstance(obj, ModelOptPTQRecipe):
return _is_layerwise(obj.quantize.algorithm)
if isinstance(obj, ModelOptAutoQuantizeRecipe):
return obj.quantize is not None and _is_layerwise(obj.quantize.algorithm)
if isinstance(obj, list):
return any(_is_layerwise(a) for a in obj)
layerwise = getattr(obj, "layerwise", None)
return bool(getattr(layerwise, "enable", False))

is_layerwise = _is_layerwise(recipe)
layerwise_cfgs = recipe_layerwise_blocks(recipe)
is_layerwise = any(cfg.get("enable", False) for cfg in layerwise_cfgs)

# The value is a placeholder, replaced with --export_path below; presence is the switch.
args.layerwise_export = any(cfg.get("export_dir") is not None for cfg in layerwise_cfgs)
if args.layerwise_export:
if isinstance(recipe, ModelOptAutoQuantizeRecipe):
# Only the mono-quantize path retargets export_dir and runs the refusals;
# auto_quantize would export to the placeholder and skip the real export.
raise NotImplementedError(
"layerwise.export_dir is not supported with an AutoQuantize recipe; "
"use a PTQ recipe, or drop export_dir and export afterwards."
)
if not args.skip_generate:
print("Layerwise export: forcing --skip_generate, the model is left in export form.")
args.skip_generate = True

if args.batch_size == 0:
# For VL models with image-text calibration, skip automatic batch size detection
Expand Down Expand Up @@ -1263,13 +1341,33 @@ def _is_layerwise(obj):
# Complementary to recipe `*mtp*` wildcards (name-match); this catches MTP layers
# identified by index.
mtp_layer_prefixes = getattr(full_model, "_mtp_layer_prefixes", None)
if args.layerwise_export and not mtp_layer_prefixes:
# Only the FSDP2 loader flags these before quantization. Per-layer export has
# to refuse *before* calibration, or the run writes a complete-looking
# checkpoint and only then discovers it is missing the MTP weights.
mtp_layer_prefixes = mtp_layer_prefixes_from_checkpoint(args.pyt_ckpt_path)
if mtp_layer_prefixes:
quant_cfg = copy.deepcopy(quant_cfg)
for prefix in mtp_layer_prefixes:
pattern = f"*{prefix}*"
quant_cfg["quant_cfg"].append({"quantizer_name": pattern, "enable": False})
print(f"Excluding MTP layer from quantization: {pattern}")

# Before resolve_checkpoint_dir, which hashes the config: with the placeholder
# still in it, two --export_path values would share one checkpoint dir.
if args.layerwise_export:
assert_layerwise_export_compatible(args, full_model, mtp_layer_prefixes)
quant_cfg = set_layerwise_export_dir(quant_cfg, args.export_path)
print(f"Layerwise export enabled: writing quantized shards to {args.export_path}")
# The shards are only a resume artifact if the manifest that names the resume
# point survives alongside them; see default_layerwise_resume_dir.
quant_cfg, moved = default_layerwise_resume_dir(quant_cfg, args.export_path)
if moved:
print(
"Layerwise checkpoint_dir co-located with the export path so a resumed "
"run finds its manifest next to the shards it must not overwrite."
)

if needs_checkpoint_path_update(quant_cfg):
quant_cfg, resolved_dir = resolve_checkpoint_dir(quant_cfg, args.pyt_ckpt_path)
print(f"Auto-resolved layerwise checkpoint_dir: {resolved_dir}")
Expand Down
Loading
Loading