Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4087cfe
Write a sharded model's HF checkpoint without gathering it
shengliangxu Oct 11, 2026
9c371c7
Pick the HF export's route by the model's distribution plan
shengliangxu Oct 14, 2026
832c8d0
Record the plan the no-gather export tests wrap their models by
shengliangxu Oct 15, 2026
213c626
Note in the changelog that a distributed model must carry its plan
shengliangxu Oct 15, 2026
0da6f61
File the no-gather export entries under 0.49.0 in the changelog
shengliangxu Oct 15, 2026
9160fd0
Run the no-gather writer's two-rank test on the shared gloo workers
shengliangxu Oct 15, 2026
5bd7016
Expect the cross-rank name collision on every rank in the two-rank wr…
shengliangxu Oct 15, 2026
54102c4
Guard the fused-expert fold's FSDP2 window with a real export
shengliangxu Oct 16, 2026
3ad0fd3
Check on CPU what the GPU-only no-gather export tests checked
shengliangxu Oct 16, 2026
88a5f41
Trim the no-gather export's tests: fold its end-to-end cases, drop co…
shengliangxu Oct 16, 2026
f0e9d39
Run the writer's input-scale reduce on the CPU under a gloo-only group
shengliangxu Oct 16, 2026
3bb0005
Check the extra tensors' overlap inside a collective every rank joins
shengliangxu Oct 16, 2026
6dee682
Drop the export config helper's second generation-config write
shengliangxu Oct 16, 2026
2a6482e
Join the extra tensors' overlap check on every rank, holding extras o…
shengliangxu Oct 17, 2026
cb142a9
Say what picks the no-gather writer in the distributed export test
shengliangxu Oct 22, 2026
0bf5e0a
Resolve a mixed-precision KV cache on the no-gather export, and take …
shengliangxu Oct 24, 2026
9d59059
Keep the no-gather export's quant config names in step with its weights
shengliangxu Oct 24, 2026
ddb5892
Copy only inference tensors before the no-gather export's transfer
shengliangxu Oct 25, 2026
20ed23c
Drop get_max_batch_size from the changelog's plan-required entry
shengliangxu Oct 25, 2026
28a6957
Save the ModelOpt state from the no-gather export, as the streaming e…
shengliangxu Oct 27, 2026
1b22c83
Agree on the expert input-scale keys before the writer reduces them
shengliangxu Oct 27, 2026
e227a03
Keep the blank line ruff format wants before the carried-tensors comment
shengliangxu Oct 27, 2026
c942185
Keep each merge group on one rank in the no-gather export
shengliangxu Oct 27, 2026
f932fb2
Keep the export handlers imported for the keep-fused window test
shengliangxu Oct 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
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Changelog
- GPTQ no longer runs a max-calibration forward when no activation quantizer needs one, and ``hf_ptq.py`` now sizes the default layerwise calibration batch at 8,192 tokens (16 samples at ``--calib_seq 512``) instead of 1. Layerwise GPTQ on HF fused-MoE models runs over 10x faster; pass ``--batch_size 1`` for the old batching, which pads no sample.
- Add a checkpoint-specific NVIDIA Nemotron 3.5 Super VL AutoQuantize recipe with a 4.9-effective-bit NVFP4-MSE/FP8/BF16 search and fixed NVFP4 MTP experts. Select it under ``models/nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-BF16/auto_quantize/``.
- Add ``examples/hf_ptq/convert_ggml_to_int8.py`` to expand a checkpoint exported with a GGML recipe (IQ formats, ``q8_0``) into an INT8 weight-only compressed-tensors checkpoint. Stock vLLM serves it with its integer kernels at about half the BF16 weight memory, reproducing the GGML weights up to rounding their scales to the model dtype.
- Add an opt-in no-gather HuggingFace export for FSDP2-sharded models: with ``MODELOPT_FSDP2_EXPORT=nogather``, ``export_hf_checkpoint`` sends each tensor to a single owner rank and every rank writes its own shards in parallel, instead of gathering the full state dict on one host. MoE experts stay sharded through the export, so each rank only materializes its own experts.

*Speculative Decoding*

Expand All @@ -41,6 +42,7 @@ Changelog
- ``export_mcore_gpt_to_hf`` given a Hugging Face Hub model ID no longer writes the tokenizer, processor, generation config or remote-code files. Pass a local directory instead, e.g. from ``modelopt.torch.export.ensure_local_checkpoint``.
- ``modelopt.torch.utils.distributed.fsdp2_wrap`` moved to ``modelopt.torch.utils.plugins.model_load_utils``, with the other loaders. The old import path still works, but calling it now needs the ``hf`` extra.
- Diffusion HF exports now omit attention quantizer buffers by default, except for FLUX.1, which retains enabled Q/K/V ``_amax`` keys for runtime compatibility; other consumers of those keys can opt in with ``export_attention_quantizers=True`` (CLI: ``--extra-param export_attention_quantizers=true``). Disabled attention quantizers remain excluded, and diffusion components, including LTX-2, no longer emit KV-cache quantization metadata.
- ``export_hf_checkpoint`` now reads how a model is distributed from the plan ``load_pretrained`` records on it, and raises for an FSDP2-sharded model, or one in a multi-rank run, that carries none. If you distribute a model yourself, for example with ``fsdp2_wrap``, which records no plan, record one with ``modelopt.torch.utils.distributed.set_distribution_plan`` before calibrating or exporting it.

**Deprecations**

Expand Down
2 changes: 2 additions & 0 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,8 @@ See [Recipe-based Quantization](#recipe-based-quantization) for the recipe forma

> *Performance Note: FSDP2 is designed for training workloads and may result in longer calibration and export times. For faster calibration, maximize the batch size based on available GPU memory and choose the right number of GPUs to avoid unnecessary communication.*

> *Set `MODELOPT_FSDP2_EXPORT=nogather` to have every rank write its own shards of the checkpoint in parallel instead of gathering the full state dict on one host.*

## Evaluate Accuracy

### TensorRT-LLM Validation
Expand Down
266 changes: 264 additions & 2 deletions modelopt/torch/export/distributed_writer.py

Large diffs are not rendered by default.

112 changes: 93 additions & 19 deletions modelopt/torch/export/unified_export_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import copy
import importlib
import json
import os
import re
import tempfile
import warnings
Expand Down Expand Up @@ -91,7 +92,12 @@
from modelopt.torch.quantization.utils.core_utils import has_accelerate_offload
from modelopt.torch.utils import print_rank_0
from modelopt.torch.utils.dataset_utils import _disable_use_cache
from modelopt.torch.utils.distributed import is_fsdp2_model
from modelopt.torch.utils.distributed import (
DistributionPlan,
broadcast_from,
distribution_plan,
is_fsdp2_model,
)
from modelopt.torch.utils.perf import maybe_clear_cuda_cache

try:
Expand Down Expand Up @@ -949,6 +955,7 @@ def _process_quantized_modules(
model: nn.Module,
dtype: torch.dtype,
is_modelopt_qlora: bool = False,
keep_fused_experts: bool = False,
) -> None:
"""Process all quantized modules in model, export weights in-place.

Expand All @@ -964,14 +971,17 @@ def _process_quantized_modules(
# No per-module dedup cache: tied duplicates are dropped by name in postprocess_state_dict.
# The handlers pack whatever weight they are handed, and nothing here materializes a shard --
# FSDP2 models go through collect_export_tensors instead, which packs inside a gather window.
assert not is_fsdp2_model(model), (
# The no-gather writer is the deliberate exception: it packs each rank's LOCAL shard and never
# materializes a full tensor, which is signalled by keep_fused_experts.
assert not is_fsdp2_model(model) or keep_fused_experts, (
"_process_quantized_modules cannot pack a sharded model; use collect_export_tensors"
)
ctx = ExportContext(
model=model,
dtype=dtype,
is_modelopt_qlora=is_modelopt_qlora,
model_type=hf_model_type(model),
keep_fused_experts=keep_fused_experts,
)

try:
Expand Down Expand Up @@ -1031,13 +1041,22 @@ def _prepare_model_for_export(model, dtype, is_modelopt_qlora):
return dtype, tied_map, quant_config


def pack_quantized_weights(model, dtype, is_modelopt_qlora: bool = False) -> None:
"""Quantize every module's weight in place, then rebuild the fused MoE linears."""
def pack_quantized_weights(
model, dtype, is_modelopt_qlora: bool = False, keep_fused_experts: bool = False
) -> None:
"""Quantize every module's weight in place, then rebuild the fused MoE linears.

``keep_fused_experts`` is for the no-gather distributed FSDP2 export: it leaves MoE experts
fused and sharded through the fold, so each rank materializes only its LOCAL experts instead
of all N on every rank. The per-expert split happens later, in the writer.
"""
# Deferred: modelopt.torch.quantization imports modelopt.torch.export, so importing the
# per-model PTQ module at module scope here would risk an import cycle.
from modelopt.torch.models.step3p5.modeling_ptq import _reconstruct_fused_moe_linear

_process_quantized_modules(model, dtype, is_modelopt_qlora)
_process_quantized_modules(
model, dtype, is_modelopt_qlora, keep_fused_experts=keep_fused_experts
)
_reconstruct_fused_moe_linear(model)


Expand Down Expand Up @@ -1887,6 +1906,27 @@ def read_unplaced_weights(model: nn.Module, *, keys_only: bool = False) -> dict[
return out


def _write_base_config(model: nn.Module, export_dir: "Path | str") -> None:
"""Write config.json + generation_config for the no-gather distributed export path.

``distributed_save_hf_checkpoint`` writes only the weight shards, so rank 0 emits here what
``save_pretrained`` would have. The weight half of ``save_pretrained`` must NOT run: the
weights are already on disk and the model may still be sharded.
"""
# A config's save_pretrained writes only config.json: transformers reverts weight conversions
# in the model's, so the patch save_non_weight_artifacts takes is not needed here.
model.config.save_pretrained(export_dir)
if model.can_generate() and getattr(model, "generation_config", None) is not None:
try:
# Same strict-validate trip as every other save site: a config carrying top_p/top_k
# with do_sample unset (e.g. GLM-5.3-BF16) makes save_pretrained raise, and the
# export would then carry the source's generation_config.json instead of this one.
_sanitize_generation_config_for_save(model)
model.generation_config.save_pretrained(export_dir)
except Exception as gen_err:
Comment thread
shengliangxu marked this conversation as resolved.
Comment on lines +1909 to +1926

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] _write_base_config is save_non_weight_artifacts (line 1602) minus its last line. CLAUDE.md asks to prefer extending an existing mechanism over adding a parallel one, and two near-identical config writers will drift — e.g. save_non_weight_artifacts calls _sanitize_generation_config_for_save unconditionally and wraps the generation-config save in contextlib.suppress, while this one calls it inside the try and warns; whichever gains the next fix, the other won't.

Since the no-gather branch does want the source-file copy (just later, from the shared rank == 0 tail), the cleanest shape is a keyword on the existing helper:

def save_non_weight_artifacts(
    model: nn.Module, export_dir: Path, *, copy_source_files: bool = True
) -> None:
    ...
    if copy_source_files:
        _copy_non_model_files_from_source(model, export_dir)

called as save_non_weight_artifacts(model, export_dir, copy_source_files=False). That also drops the if not (_offloaded or (is_fsdp2_sharded and not use_no_gather_writer)) special case in the tail back to its simpler form.

Separately: if the _patch_revert_weight_conversion wrapper that save_non_weight_artifacts puts around model.config.save_pretrained really is unnecessary (as this function's comment argues), that's worth removing there rather than forking a copy without it.

warnings.warn(f"Could not save generation_config: {gen_err}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
shengliangxu marked this conversation as resolved.
Comment on lines +1909 to +1928

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] _write_base_config is a near-copy of the existing save_non_weight_artifacts (same file, L1607), which the streaming and layerwise exporters already use: config → generation_config → copy the source's non-model files. The only differences are the can_generate() guard, a warning instead of a silent suppress, and that it stops short of the copy — which this path then performs separately via the widened guard at L2182.

Reusing it would collapse both the helper and that guard change:

elif use_no_gather_writer:
    ...
    if rank == 0:
        save_non_weight_artifacts(model, export_dir)
        ...

and leave L2182 as the original if not (_offloaded or is_fsdp2_sharded):.

Ordering is safe: copy_non_model_files documents that it skips any file the export already wrote, so the source's config.json / generation_config.json are not copied over the ones just emitted, and _write_hf_export_config still rewrites config.json in place afterwards.

If the can_generate() guard and the visible warning are the point, the cheaper move is to add them to save_non_weight_artifacts so all four export paths get them, rather than keeping a second helper that drifts from it. Per CONTRIBUTING/CLAUDE.md's "extend an existing mechanism over adding a parallel one". Non-blocking.


def export_hf_checkpoint(
model: Any,
dtype: torch.dtype | None = None,
Expand Down Expand Up @@ -1935,14 +1975,10 @@ def export_hf_checkpoint(
# emits it from rank 0 alone, so on the other ranks a full read is host memory spent and thrown
# away -- and the carried set is the large stuff. The KEY list is still resolved everywhere,
# because get_quant_config runs per rank and the configs have to agree.
_writes_extra = (
not (
torch.distributed.is_available()
and torch.distributed.is_initialized()
and is_fsdp2_model(model)
)
or torch.distributed.get_rank() == 0
)
# How the model is distributed decides how it is exported. A diffusers pipeline is not a module,
# and nothing here distributes one.
plan = distribution_plan(model) if isinstance(model, nn.Module) else DistributionPlan()

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.

[CRITICAL ModeState] Nothing in modelopt/ or examples/ records a DistributionPlan, so this line makes every FSDP2 export — including the existing default (gather/streaming) path — raise.

distribution_plan() raises when the model has no _modelopt_distribution_plan and either is_fsdp2_model(model) or size() > 1. In this checkout the only producers of a plan are tests:

$ grep -rn "set_distribution_plan" --include=*.py .
tests/gpu/torch/export/test_distributed_hf_export.py:162
tests/unit/torch/export/test_distributed_writer.py:171
tests/unit/torch/export/test_fused_experts_keep_fused.py:689
tests/unit/torch/utils/test_distributed_run.py:94,112,116,150,153
modelopt/torch/utils/distributed.py:350   # the definition

Neither load_pretrained (modelopt/torch/utils/plugins/model_load_utils.py:376) nor parallel_load_and_prepare_fsdp2 (:452, which just calls fsdp2_wrap at :491) records one. Consequences:

  1. The usage this PR documents — MODELOPT_FSDP2_EXPORT=nogather torchrun … hf_ptq.py --use_fsdp2 — cannot work: hf_ptq.py:330 builds the model with parallel_load_and_prepare_fsdp2, so export_hf_checkpoint raises RuntimeError: … is FSDP2-sharded, but has no distribution plan recorded before reaching either writer.
  2. The pre-existing FSDP2 streaming export regresses the same way — this is not limited to the new opt-in path.
  3. Existing GPU tests break: tests/gpu/torch/export/test_export_carry_over.py::_ptq_and_export does fsdp2_wrap(model) then export_hf_checkpoint(...) with no plan (so do test_fsdp2_distributed_export_carries_unplaced_weights_from_provenance and test_fsdp2_distributed_export_of_vlm_keeps_vision_tower). Per the Testing notes these run in CI only, which is likely why it wasn't seen locally.
  4. Any replicated multi-rank export (size() > 1, no FSDP2) now raises too, where it previously wrote from every rank — a wider break than the FSDP2 one the CHANGELOG describes.

The PR description asserts "A model FSDP2-sharded with fsdp2_wrap or loaded through load_pretrained carries its plan", which contradicts both the code above and the PR's own backward-compat note ("fsdp2_wrap included, must record its plan"). The plan-recording appears to live in the sibling branch #2737 (plan decision), which is not an ancestor of this PR, so per the repo's series rule ("every sub-PR stands on its own… CI passes on it without the later PRs") this slice is not independently mergeable.

Fix options: (a) record the plan in fsdp2_wrap/parallel_load_and_prepare_fsdp2/load_pretrained in this PR, or (b) keep is_fsdp2_model(model) as the fallback here when no plan is recorded — reading the plan only to pick the route, not to decide whether the model is sharded — and defer the hard requirement to the PR that adds the recording.

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.

Right: the re-cut put this PR on a base without the PRs that record the plan. In the original stack, two of them sat below it:

The base is now shengliangx/hfep-base-12c-nogather-export, which merges #2779, #2740, #2750 and #2751, so this PR merges only after both plan-recording PRs. Its own diff is unchanged. #2754, which runs this export from hf_ptq, now also builds on this track.

_writes_extra = not plan.is_fsdp_dense or torch.distributed.get_rank() == 0
Comment on lines +1978 to +1981

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 Compatibility] distribution_plan() is now read for every export, before the layerwise-exporter and diffusers dispatches below, and it raises when size() > 1 with no plan recorded. The PR body accounts for pipelines ("A diffusers pipeline is not a module"), but a diffusers module — UNet2DConditionModel, FluxTransformer2DModel, which this function's own docstring lists as supported ("Supports both transformers models ... and diffusers models/pipelines") — is an nn.Module, so a replicated multi-rank export of one now raises instead of each rank writing the whole checkpoint, which is what the rank = ... if is_fsdp2_sharded else 0 comment at line 2014 still says that case does.

No in-repo example hits it (examples/diffusers/* pass pipe), so this is a library-user-facing edge rather than a broken example, and the CHANGELOG does state the multi-rank rule. Still, it costs little to keep the dispatch honest: hoist the diffusers detection above the carried-weights block and give a pipeline/diffusers object DistributionPlan() the same way, or read the plan only on the paths that consume it. The second line of defence is the _writes_extra computation itself — torch.distributed.get_rank() is now called with no is_initialized() guard whenever plan.is_fsdp_dense, so a hand-recorded DistributionPlan(1, dense="fsdp") outside a process group raises from get_rank() rather than from something diagnosable.

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.

Postponed. No in-repo caller exports a diffusers module from a run of several ranks (examples/diffusers pass the pipeline, which is exempt), and such a caller can record DistributionPlan(world_size) with set_distribution_plan, as the CHANGELOG entry says. [17/21] replaces this line with _hf_shard_flags, and the exemption belongs there, so it is a follow-up for after the stack lands rather than a change to [17/21] in this round. I checked it: a two-rank export of a tiny UNet2DConditionModel raises the missing-plan error at this commit and exports on both ranks once a diffusers model reads as unsharded. On get_rank(): a plan whose dense part is FSDP2 means fully_shard ran, which needs a process group; outside one, get_rank() raises torch's own error that the default process group is not initialized.

Comment thread
shengliangxu marked this conversation as resolved.
_carried = read_unplaced_weights(model, keys_only=not _writes_extra)
if _writes_extra and _carried:
extra_state_dict = {**_carried, **(extra_state_dict or {})}
Expand Down Expand Up @@ -1983,10 +2019,11 @@ def export_hf_checkpoint(
)
return

is_fsdp2_sharded = (
torch.distributed.is_available()
and torch.distributed.is_initialized()
and is_fsdp2_model(model)
is_fsdp2_sharded = plan.is_fsdp_dense
# Rank 0's choice: the two writers issue different collectives, so ranks that read their
# environments differently would wait in collectives the others never join.
use_no_gather_writer = is_fsdp2_sharded and broadcast_from(
"MODELOPT_FSDP2_EXPORT", lambda: os.environ.get("MODELOPT_FSDP2_EXPORT") == "nogather"
)
Comment thread
shengliangxu marked this conversation as resolved.
Comment thread
shengliangxu marked this conversation as resolved.
Comment on lines +2025 to 2027

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] This is the only MODELOPT_* knob read with a bare os.environ.get rather than through modelopt/torch/utils/env.py, whose module docstring is exactly "The MODELOPT_* environment-variable knobs of distributed HuggingFace model loading" and which already holds ep_degree() / fsdp_size() as call-time accessors for the same reason given here (a launcher sets them before forking; tests override them).

Two concrete costs of the ad-hoc read:

  1. Any unrecognized value silently falls back to the gather/streaming writer. MODELOPT_FSDP2_EXPORT=no-gather, =NOGATHER, =1, or =true all compare unequal to "nogather" and the export quietly takes the path the user set the variable to avoid — which is the path that may OOM on the model they chose this writer for. That is the "fail loudly rather than silently ignore an option or route" case from CLAUDE.md's review checklist, and the knob is not self-validating the way fsdp_size()'s "world"/number/"auto" parsing is. Note the tests themselves use a second value (knob="streaming" in test_distributed_writer.py:74), so the knob already reads as a two-valued route selector rather than a boolean.

  2. Discoverability: the knob is documented in CHANGELOG.rst and examples/hf_ptq/README.md but is invisible from env.py, where a reader looking for the distributed knobs will look.

Suggested shape — an accessor in env.py that rejects what it does not know, broadcast as the resolved route rather than a bool:

# modelopt/torch/utils/env.py
def fsdp2_export() -> str:
    """FSDP2 export route knob: ``"nogather"`` or ``"streaming"``; ``""``/unset -> ``"streaming"``."""
    value = os.environ.get("MODELOPT_FSDP2_EXPORT") or "streaming"
    if value not in ("nogather", "streaming"):
        raise ValueError(
            f"MODELOPT_FSDP2_EXPORT={value!r}: expected 'nogather' or 'streaming'."
        )
    return value

and here:

use_no_gather_writer = is_fsdp2_sharded and broadcast_from(
    "MODELOPT_FSDP2_EXPORT", lambda: env.fsdp2_export() == "nogather"
)

Raising inside the broadcast_from callback keeps the refusal collective in the same way the rest of this file does — rank 0 checks before it sends, so a bad value raises on every rank instead of leaving the others in the broadcast.

# Not the global rank: a non-FSDP2 export writes the whole checkpoint from every process.
rank = torch.distributed.get_rank() if is_fsdp2_sharded else 0
Expand Down Expand Up @@ -2017,6 +2054,42 @@ def export_hf_checkpoint(
if getattr(model, "hf_quantizer", None) is not None:
model.hf_quantizer = None
hf_quant_config = _revert_quant_config_names_best_effort(model, hf_quant_config)
elif use_no_gather_writer:
# Opt-in alternative to the streaming path below: never replicate a unit across ranks.
# Each tensor is sent to exactly one owner over batched point-to-point sends, instead of
# every rank entering a gather window and all but one discarding the result. Same
# outputs; the win grows with world size, where the replicated gather costs ~world x the
# bytes. Left opt-in so the default path is unchanged.
from .distributed_writer import distributed_save_hf_checkpoint

_qlora = kwargs.get("is_modelopt_qlora", False)
_dtype, _tied, hf_quant_config = _prepare_model_for_export(model, dtype, _qlora)
# main split preparation from packing, so pack explicitly. keep_fused_experts leaves
# MoE experts fused + sharded through the fold, so each rank folds only its LOCAL
# experts; the writer splits them after consolidation.
pack_quantized_weights(model, _dtype, _qlora, keep_fused_experts=True)
if getattr(model, "hf_quantizer", None) is not None:
model.hf_quantizer = None
reverted = distributed_save_hf_checkpoint(
model,
export_dir,
maxbound=448, # the KV cache scale is amax / 448 for FP8 and NVFP4, as above
kv_cache_format=_get_kv_cache_postprocess_config(
(hf_quant_config or {}).get("quantization", {})
),
max_shard_size=max_shard_size,
is_modelopt_qlora=_qlora,
extra_state_dict=extra_state_dict,
)
if rank == 0:
_write_base_config(model, export_dir)
# As the streaming path below: every FSDP2 rank holds the same ModelOpt state.
if save_modelopt_state and ModeloptStateManager.is_converted(model):
torch.save(modelopt_state(model), export_dir / _MODELOPT_STATE_SAVE_NAME)
# The weights keep their in-memory names when any rank could not revert them, and
# the quant config has to name the modules as the weights do.
if reverted:
hf_quant_config = _revert_quant_config_names_best_effort(model, hf_quant_config)
elif is_fsdp2_sharded:
# FSDP2 multi-rank: stream each rank's owned units straight to its own shard files, so
# a rank buffers its own share of the model rather than the whole checkpoint, and the
Expand Down Expand Up @@ -2106,8 +2179,9 @@ def export_hf_checkpoint(
if rank == 0:
_write_hf_export_config(model, hf_quant_config, export_dir)
copy_off_index_safetensors(_source_checkpoint(model), export_dir)
if not (_offloaded or is_fsdp2_sharded):
# The streaming paths already did, from save_non_weight_artifacts.
if not (_offloaded or (is_fsdp2_sharded and not use_no_gather_writer)):
# The streaming paths already did, from save_non_weight_artifacts; the no-gather
# writer does not.
_copy_non_model_files_from_source(model, export_dir)

except Exception as e:
Expand Down
Loading
Loading