Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ Changelog
- Fix QAT of Hugging Face fused MoE experts with non-reentrant activation checkpointing, as in the ``examples/llm_qat`` FSDP2 config: the experts' ``F.linear`` interception stayed installed after the recompute, and training crashed with ``aten.mm.default got mixed torch.Tensor and DTensor``.
- Fix quantization-aware training of Diffusers models failing in the backward pass with ``NotImplementedError`` once their attention modules are quantized, with or without MHA quantization.
- Fix weight calibration, GPTQ and weight folding of Hugging Face fused MoE experts sharded by the expert parallelism of Transformers 5.16 and later, which used another rank's expert weights on every rank but the first.
- Fix ``export_hf_checkpoint`` of an FSDP2 model quantized with W4A8 AWQ (``W4A8_AWQ_BETA_CFG``), which failed with an ``AttributeError`` on every rank.
- AWQ calibration (``awq_lite``, ``awq_full``) of an FSDP2 model sharded over more than one rank is now rejected with ``NotImplementedError`` by ``mtq.quantize``, ``mtq.calibrate`` and ``mtq.auto_quantize`` (before its search starts, its default formats included), and by ``examples/hf_ptq --use_fsdp2`` before loading the model: each rank would fold a different smoothing scale into its weight shard. Calibrate in a single process, or use a non-AWQ algorithm.
- ``mtq.compress`` now rejects W4A8 weights with ``NotImplementedError``: a compressed W4A8 model could neither run nor be reloaded.

0.48.0 (2026-10-xx)
^^^^^^^^^^^^^^^^^^^
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 @@ -614,6 +614,8 @@ ModelOpt enables quantization of LLMs across multiple GPU nodes using FSDP2 for

> *Nemotron-H checkpoints containing MTP weights are not supported with `--use_fsdp2` and are rejected before distributed model loading. Remove `--use_fsdp2` to load and calibrate the MTP modules; the distributed loader does not construct them.*

> *AWQ calibration (`awq_lite`, `awq_full`), as the AWQ formats and recipes use, AutoQuantize candidates included, is not supported with `--use_fsdp2` on more than one rank and is rejected before distributed model loading: each rank would fold its own smoothing scale into its weight shard. Drop `--use_fsdp2` (the single-process `device_map` still spreads the model over all the GPUs), run on one rank, or use a non-AWQ format.*

### Usage

#### Slurm (recommended)
Expand Down
30 changes: 30 additions & 0 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
ProcessorMixin,
)

from modelopt.recipe import load_recipe
from modelopt.recipe.presets import QUANT_CFG_CHOICES
from modelopt.torch.export import has_spec_opt
from modelopt.torch.export.model_utils import is_multimodal_model
from modelopt.torch.models import hf_model_type
Expand All @@ -52,6 +54,7 @@
prefers_bundled_code,
prepare_model_for_loading,
)
from modelopt.torch.quantization.mode import get_modelike_from_algo_cfg
from modelopt.torch.utils import distributed as dist_utils
from modelopt.torch.utils.mlflow import Tool, resolved_recipe_texts, tracked_run
from modelopt.torch.utils.plugins.model_load_utils import is_fsdp2_text_only_vlm, load_pretrained
Expand Down Expand Up @@ -94,6 +97,27 @@ def cleanup_distributed(args):
dist_utils.cleanup()


def _awq_calibrations(args) -> set[str]:
"""The AWQ calibrations (``awq_lite``, ``awq_full``) the run's --recipe or --qformat uses."""
if getattr(args, "recipe", None) is not None:
recipe = load_recipe(args.recipe)
configs = [getattr(recipe, "quantize", None)]
search = getattr(recipe, "auto_quantize", None) # the KV-cache search allows only max
if search is not None:
configs += search.candidate_formats
configs += [c for space in search.module_search_spaces for c in space.candidate_formats]
algorithms = [config.algorithm for config in configs if config is not None]
else:
preset = QUANT_CFG_CHOICES.get(getattr(args, "qformat", None) or "")
algorithms = [preset.get("algorithm")] if preset is not None else []
methods = {
mode.removesuffix("_calibrate")
for algorithm in algorithms
for mode, _ in get_modelike_from_algo_cfg(algorithm)
}
return methods & {"awq_lite", "awq_full"}


def validate_fsdp2_supported(args, config):
"""Raise ``NotImplementedError`` for model/CLI combos the FSDP2 path doesn't support yet."""
issues = []
Expand All @@ -113,6 +137,12 @@ def validate_fsdp2_supported(args, config):
issues.append("speculative decoding (--specdec_offline_dataset)")
if getattr(args, "low_memory_mode", False):
issues.append("--low_memory_mode (redundant with FSDP2)")
if dist_utils.size() > 1 and (awq := _awq_calibrations(args)):
issues.append(
f"AWQ calibration ({', '.join(sorted(awq))}) on more than one rank: each rank would fold "
"its own smoothing scale into its weight shard (drop --use_fsdp2, run on one rank, or "
"use a non-AWQ format)"
)
if not issues and checkpoint_has_mtp(config.model_type, args.local_checkpoint_path):
issues.append("Nemotron-H MTP (auxiliary modules are not constructed by the FSDP2 loader)")

Expand Down
11 changes: 11 additions & 0 deletions modelopt/torch/quantization/compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .backends.gemm_registry import disable_real_quant_gemm, enable_real_quant_gemm
from .config import CompressCfgType, CompressConfig
from .conversion import _replace_quant_module, set_quantizer_attributes_partial
from .nn import SequentialQuantizer
from .nn.modules.quant_linear import RealQuantLinear
from .qtensor import QTensorWrapper, pack_real_quantize_weight
from .utils import is_quantized_linear
Expand Down Expand Up @@ -69,6 +70,16 @@ def compress_convert(
Returns:
The compressed model.
"""
# Compressed, a SequentialQuantizer weight cannot run forward; compressed or excluded, its
# RealQuantLinear can neither be restored nor take a new weight.
for name, module in model.named_modules():
if is_quantized_linear(module) and any(
isinstance(q, SequentialQuantizer) for q in module.weight_quantizer.modules()
):
raise NotImplementedError(
f"{name}: compressing a sequential weight quantizer (as in W4A8) is not supported, "
"even when the compress config excludes the layer."
)
for _, module in model.named_modules():
if is_quantized_linear(module) and type(module) not in RealQuantModuleRegistry:
class_to_register = RealQuantLinear
Expand Down
31 changes: 31 additions & 0 deletions modelopt/torch/quantization/model_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import torch
import torch.nn as nn
from torch.distributed.fsdp import FSDPModule

import modelopt.torch.quantization as mtq
import modelopt.torch.utils.distributed as dist
Expand Down Expand Up @@ -54,6 +55,7 @@
from .mode import QuantizeModeRegistry, get_modelike_from_algo_cfg
from .nn import AnyQuantizer, QuantModule, SequentialQuantizer, TensorQuantizer
from .utils import is_quantized
from .utils.core_utils import _get_fsdp2_mesh

__all__ = [
"auto_quantize",
Expand All @@ -70,6 +72,31 @@
]


def _refuse_awq_on_a_sharded_fsdp2_model(
model: nn.Module, algorithm: QuantizeAlgoCfgType, candidate: str | None = None
) -> None:
"""Refuse AWQ calibration of an FSDP2 model sharded over several ranks, before it starts.

Each rank calibrates on its own data and folds the AWQ scale it picks into its own weight
shard, so the shards would not match. Every rank decides alike, from the run and the model.
"""
if dist.size() == 1 or not dist.is_fsdp2_model(model):
return
methods = [mode.removesuffix("_calibrate") for mode, _ in get_modelike_from_algo_cfg(algorithm)]
awq = [method for method in methods if method in ("awq_lite", "awq_full")]
if awq:
# For the message only: the FSDP2 shard mesh can be narrower than the run (HSDP, 2D).
mesh = _get_fsdp2_mesh(next(m for m in model.modules() if isinstance(m, FSDPModule)))
ranks = mesh.size(-1) if mesh is not None else dist.size()
what = f"AutoQuantize candidate {candidate!r} calibrates" if candidate else "Calibrating"
raise NotImplementedError(

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.

[IMPORTANT Compatibility] The guard itself is the right call — but the user-facing surface that steers people into exactly this combination is not updated, and the refusal lands late in an expensive job.

What. On more than one rank this now rejects every AWQ format on an FSDP2 model. examples/hf_ptq --use_fsdp2 is the documented multi-node PTQ path, and AWQ formats are first-class there: int4_awq, nvfp4_awq_lite and w4a8_awq_beta are all in _AUTO_QUANTIZE_QFORMATS (examples/hf_ptq/autoquant_utils.py:84-102), and the shipped recipes modelopt_recipes/general/auto_quantize/w4a8_awq_beta_fp8_at_6p0bits.yaml plus the modelopt_recipes/model_type/*/ptq/w4a8_awq-kv_fp8_cast.yaml set are AWQ. The README's FSDP2 section (examples/hf_ptq/README.md:609-650) tells users AutoQuantize recipes "use the same torchrun launch" and carries a note block listing the --use_fsdp2 restrictions (Nemotron-H MTP, --calib_with_images) — this new one is missing from it.

Why it matters. A user following that README with one of those shipped recipes on 2+ ranks now gets NotImplementedError where they previously got a checkpoint. Refusing is better than the silently mismatched shards, so the behaviour change is fine — the problem is that nothing in the examples or recipe docs says so, and the refusal fires after the distributed model load and calibration-data setup, i.e. minutes into a multi-node job on a large model rather than at argument parsing.

Suggested fix (docs + fail-fast, no change to this function):

  1. Add the restriction to the --use_fsdp2 note block in examples/hf_ptq/README.md, next to the existing MTP / --calib_with_images notes.
  2. Wire it into the existing fail-fast list in examples/hf_ptq/example_utils.py:95-124, which already raises --use_fsdp2 does not support: ... before distributed loading:
if world_size() > 1 and any("awq" in fmt for fmt in _requested_qformats(args)):
    issues.append("AWQ qformats on more than one rank (each rank folds its own smoothing scale)")

Alternatively, for auto_quantize specifically, dropping AWQ candidates from the search with a warning (rather than refusing the whole call) would keep the shipped AQ recipe runnable on multi-rank FSDP2 — your call, but then w4a8_awq_beta_fp8_at_6p0bits.yaml should say what it degrades to.

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.

Done in 060836a, both parts:

  • examples/hf_ptq/example_utils.py::validate_fsdp2_supported now lists "AWQ calibration (awq_lite) on more than one rank" when the process group has more than one rank and the run would calibrate with AWQ. That includes --qformat presets, a PTQ recipe's quantize, and an AutoQuantize recipe's candidate_formats and module_search_spaces. It mirrors the library's criterion (awq_lite / awq_full) and runs before the distributed load, so a multi-node job fails at startup. The library guard stays.
  • examples/hf_ptq/README.md: a note next to the MTP note in the --use_fsdp2 section says AWQ formats and recipes, AutoQuantize candidates included, are rejected on more than one rank before loading, and what to use instead.
  • AutoQuantize candidates are still refused rather than dropped, so w4a8_awq_beta_fp8_at_6p0bits fails loudly on several ranks instead of running a narrower search than the one asked for.
  • tests/examples/hf_ptq/test_example_utils.py::test_fsdp2_refuses_awq_on_several_ranks_before_loading covers a W4A8 qformat and the W4A8 AutoQuantize recipe on 2 ranks (refused), FP8 on 2 ranks and W4A8 on 1 rank (allowed). Both refused cases fail without the check.

f"{what} with {', '.join(awq)}, which is not supported yet on an FSDP2 model sharded "
f"over {ranks} ranks: each rank calibrates on its own data and folds the AWQ scale it "
"picks into its own weight shard, so the shards would not match. Calibrate in a single "
"process, or with an algorithm other than AWQ."
)


# TODO: Descriptors for the supported algorithms
def calibrate(
model: nn.Module,
Expand Down Expand Up @@ -99,6 +126,7 @@ def calibrate(

Returns: The calibrated pytorch model.
"""
_refuse_awq_on_a_sharded_fsdp2_model(model, algorithm)
if forward_loop is not None:
# get the number of arguments of forward_loop
num_args = len(inspect.signature(forward_loop).parameters)
Expand Down Expand Up @@ -439,6 +467,7 @@ def forward_loop(model) -> None:
Returns: A pytorch model which has been quantized and calibrated.
"""
quantize_config = QuantizeConfig(**dict(config))
_refuse_awq_on_a_sharded_fsdp2_model(model, config.get("algorithm"))
if not is_quantized(model):
model = apply_mode(model, mode=[("quantize", dict(config))], registry=QuantizeModeRegistry)
else:
Expand Down Expand Up @@ -996,6 +1025,8 @@ def forward_backward_step(model, batch) -> None:
"Please run auto_quantize with 'max' or 'mse' calibration and use "
"get_auto_quantize_config() to obtain a config for mtq.quantize()."
)
# Every candidate, before the search calibrates any: calibrate() would refuse mid-search.
_refuse_awq_on_a_sharded_fsdp2_model(model, algo, candidate=name)

# Select the appropriate searcher based on method
if method_name not in AUTO_QUANTIZE_SEARCHERS:
Expand Down
29 changes: 19 additions & 10 deletions modelopt/torch/quantization/utils/core_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1005,17 +1005,26 @@ def patched_new(cls, data=None, requires_grad=True):
def enable_fake_quant(module):
"""Temporarily set the fake_quant attribute of a module to True.

This is used to prevent weight compression from being triggered during an unshard() call.
This is used to prevent weight compression from being triggered during an unshard() call. A
sequential weight quantizer (W4A8) holds the flag on each of its stages.
"""
original_fake_quant = []
for m in module.modules():
if hasattr(m, "weight_quantizer"):
original_fake_quant.append(m.weight_quantizer._fake_quant)
m.weight_quantizer._fake_quant = True
yield
for m in module.modules():
if hasattr(m, "weight_quantizer"):
m.weight_quantizer._fake_quant = original_fake_quant.pop(0)
from ..nn import TensorQuantizer # local: ..nn imports this utils package at module scope

quantizers = [
q
for m in module.modules()
if hasattr(m, "weight_quantizer")
for q in m.weight_quantizer.modules()
if isinstance(q, TensorQuantizer)
]
Comment on lines +1013 to +1019

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 rewrite is the right shape for the W4A8 bug — compress.py:91 broadcasts {"fake_quant": False} through SequentialQuantizer.set_from_attribute_config, so the flag genuinely lives per stage, and _QuantizerContainerBase._delegated_properties only exposes the read-only fake_quant property (tensor_quantizer.py:533-535), so the container cannot carry a writable copy. Two notes on the comprehension while you are here:

  1. The one non-FSDP caller still matches nothing. model_calib._mse_quant_func calls enable_fake_quant(quantizer) with a bare TensorQuantizer (model_calib.py:775), alongside enable_quant(quantizer) and disable_calib(quantizer) which both act on it directly. A TensorQuantizer has no weight_quantizer attribute, so the outer filter at 1014 rejects it and the window is a no-op — the MSE sweep's intent that quantizer(x) fake-quantizes is not actually enforced. It is benign today (fake_quant defaults to True and mtq.compress flips it only after calibration), and it predates this PR, but the new hasattr(q, "_fake_quant") filter removes the only way it would ever announce itself. Accepting a quantizer directly would cover both callers, e.g. iterate module.modules() and take m.weight_quantizer.modules() when present, else [m].

  2. hasattr(q, "_fake_quant") is broader than the intent. The attribute you want to exclude is the container itself; isinstance(q, TensorQuantizer) says that directly and keeps a future quantizer type that legitimately lacks the flag from being silently dropped from the window instead of raising.

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.

  1. Taken in ffa435a: the stages are picked with isinstance(q, TensorQuantizer), as core_utils does elsewhere.

  2. Not changed. _mse_quant_func's enable_fake_quant(quantizer) is pre-existing and, as you say, benign: MSE calibration runs before mtq.compress, while _fake_quant still holds its default True. Letting enable_fake_quant take a bare quantizer would change the MSE calibration path, which this bug fix does not touch, so it is better as a change of its own.

On the flag's consumers: besides pack_real_quantize_weight, RealQuantParameterDict.__setitem__ in quant_linear.py reads it too, on the SequentialQuantizer it captured at conversion. A compressed W4A8 model also cannot run (its FP8 stage real-quantizes the dequantized weight) or be restored (SequentialQuantizer has no set_from_modelopt_state). So 7354499 makes mtq.compress refuse W4A8 weights up front.

original_fake_quant = [q._fake_quant for q in quantizers]
for q in quantizers:
q._fake_quant = True
try:
yield
finally:
for q, fake_quant in zip(quantizers, original_fake_quant):
q._fake_quant = fake_quant


@contextmanager
Expand Down
23 changes: 23 additions & 0 deletions tests/examples/hf_ptq/test_example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ def test_fsdp2_qwen_vlm_requires_text_only_calibration(
example_utils.validate_fsdp2_supported(args, config)


@pytest.mark.parametrize(
("ranks", "qformat", "recipe", "refused"),
[
(2, "w4a8_awq_beta", None, True),
(2, "fp8", "general/auto_quantize/w4a8_awq_beta_fp8_at_6p0bits", True),
(2, "fp8", None, False),
(1, "w4a8_awq_beta", None, False),
],
)
def test_fsdp2_refuses_awq_on_several_ranks_before_loading(
monkeypatch, ranks, qformat, recipe, refused
):
"""Each rank would fold its own AWQ scale into its weight shard, which the library refuses
only once the model is loaded: the example refuses at argument validation."""
monkeypatch.setattr(example_utils.dist_utils, "size", lambda: ranks)
args = SimpleNamespace(local_checkpoint_path="local-model", qformat=qformat, recipe=recipe)
expected = (
pytest.raises(NotImplementedError, match="AWQ calibration") if refused else nullcontext()
)
with expected:
example_utils.validate_fsdp2_supported(args, SimpleNamespace(model_type="llama"))


def _write_safetensors(path, tensors):
save_file(tensors, str(path), metadata={"format": "pt"})

Expand Down
26 changes: 16 additions & 10 deletions tests/gpu/torch/export/test_fsdp2_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,16 @@ def _fuse_layers(rank, size, quant_config, bias):
def calib_fn(x):
return x(calib_data)

# Shard model
fully_shard(model)
torch.distributed.barrier()
# AWQ calibration of a model sharded over several ranks is refused: there, shard after it.
awq_on_ranks = size > 1 and "awq" in str(quant_config.get("algorithm"))
if not awq_on_ranks:
fully_shard(model)
torch.distributed.barrier()

# Quantize model
mtq.quantize(model, quant_config, calib_fn)
mtq.quantize(non_fsdp_model, quant_config, calib_fn)
if awq_on_ranks:
fully_shard(model)

torch.distributed.barrier()

Expand Down Expand Up @@ -217,13 +220,16 @@ def _export_quantized_weight_test(rank, size, quant_config, bias):
def calib_fn(x):
return x(calib_data)

# Shard model
fully_shard(model)
torch.distributed.barrier()
# AWQ calibration of a model sharded over several ranks is refused: there, shard after it.
awq_on_ranks = size > 1 and "awq" in str(quant_config.get("algorithm"))
if not awq_on_ranks:
fully_shard(model)
torch.distributed.barrier()

# Quantize model
mtq.quantize(model, quant_config, calib_fn)
mtq.quantize(non_fsdp_model, quant_config, calib_fn)
if awq_on_ranks:
fully_shard(model)

torch.distributed.barrier()

Expand Down Expand Up @@ -305,7 +311,7 @@ def test_fsdp2_weight_update_context_error_propagates(dist_workers):
mtq.FP8_DEFAULT_CFG,
mtq.NVFP4_DEFAULT_CFG,
mtq.NVFP4_AWQ_LITE_CFG,
# mtq.W4A8_AWQ_BETA_CFG, #TODO: Fix unit test for this case
mtq.W4A8_AWQ_BETA_CFG,
# mtq.FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG, #TODO: Fix unit test for this case
mtq.W4A8_MXFP4_FP8_CFG,
# NVFP4_MLP_ONLY_CFG is omitted: SmallQKVModel has no MLP, so that config matched
Expand All @@ -327,7 +333,7 @@ def test_fsdp2_weight_update_context_for_fuse_layers(dist_workers, quant_config,
mtq.FP8_DEFAULT_CFG,
mtq.NVFP4_DEFAULT_CFG,
mtq.NVFP4_AWQ_LITE_CFG,
# mtq.W4A8_AWQ_BETA_CFG, #TODO: Fix unit test for this case
mtq.W4A8_AWQ_BETA_CFG,
# mtq.FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG, #TODO: Fix unit test for this case
mtq.W4A8_MXFP4_FP8_CFG,
# NVFP4_MLP_ONLY_CFG is omitted: SmallQKVModel has no MLP, so that config matched
Expand Down
29 changes: 17 additions & 12 deletions tests/gpu/torch/quantization/test_fsdp2.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,36 @@ def forward_loop(model):
out_test = fsdp_model(inputs)
out_test.sum().backward()

fully_shard(fsdp_model_quant_after[0])
fully_shard(fsdp_model_quant_after[1])
fsdp_model_quant_after = fully_shard(fsdp_model_quant_after)
fsdp_model_quant_after = mtq.quantize(fsdp_model_quant_after, quant_cfg, forward_loop)
optimizer_quant_after = torch.optim.SGD(fsdp_model_quant_after.parameters(), lr=0.1)
out_quant_after = fsdp_model_quant_after(inputs)
out_quant_after.sum().backward()
# AWQ calibration of a model sharded over several ranks is refused: there, quantize first only.
quant_after = size == 1 or "awq" not in str(quant_cfg.get("algorithm"))
if quant_after:
fully_shard(fsdp_model_quant_after[0])
fully_shard(fsdp_model_quant_after[1])
fsdp_model_quant_after = fully_shard(fsdp_model_quant_after)
fsdp_model_quant_after = mtq.quantize(fsdp_model_quant_after, quant_cfg, forward_loop)
optimizer_quant_after = torch.optim.SGD(fsdp_model_quant_after.parameters(), lr=0.1)
out_quant_after = fsdp_model_quant_after(inputs)
out_quant_after.sum().backward()

assert torch.allclose(out_ref, out_test)
assert torch.allclose(out_ref, out_quant_after)
if quant_after:
assert torch.allclose(out_ref, out_quant_after)

optimizer_ref.step()
optimizer_ref.zero_grad()

optimizer_test.step()
optimizer_test.zero_grad()

optimizer_quant_after.step()
optimizer_quant_after.zero_grad()
if quant_after:
optimizer_quant_after.step()
optimizer_quant_after.zero_grad()

out_ref_1 = model(inputss)
out_test_1 = fsdp_model(inputss)
out_quant_after_1 = fsdp_model_quant_after(inputss)
assert torch.allclose(out_ref_1, out_test_1, rtol=1e-4)
assert torch.allclose(out_ref_1, out_quant_after_1, rtol=1e-4)
if quant_after:
assert torch.allclose(out_ref_1, fsdp_model_quant_after(inputss), rtol=1e-4)


def test_fsdp_simple_linear(dist_workers):
Expand Down
27 changes: 24 additions & 3 deletions tests/unit/torch/export/test_fsdp2_parallel_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@
from transformers import AutoModelForCausalLM, LlamaConfig


def _tiny_quantized_llama(quant_cfg=None, tie=True, mesh=None):
def _tiny_quantized_llama(quant_cfg=None, tie=True, mesh=None, hidden_size=64):
mto.enable_huggingface_checkpointing()
torch.manual_seed(0)
cfg = LlamaConfig(
vocab_size=128,
hidden_size=64,
intermediate_size=128,
hidden_size=hidden_size,
intermediate_size=2 * hidden_size,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=4,
Expand Down Expand Up @@ -94,6 +94,27 @@ def test_streaming_export_matches_resident_path(tmp_path):
assert torch.equal(streamed[key].float(), ref[key].cpu().float()), key


def test_streaming_export_of_a_sharded_w4a8_model(tmp_path, gloo_world_of_one):
"""An FSDP2 model calibrated with W4A8 AWQ on one rank exports what the unsharded model does.

W4A8's weight quantizer is a ``SequentialQuantizer``, which the weight-update window the AWQ
resmooth opens on each sharded layer must handle. AWQ calibration of an FSDP2 model is refused
only over several ranks.
"""
# 128 wide, so that W4A8's 128-wide INT4 blocks tile every weight.
ref_model = _tiny_quantized_llama(mtq.W4A8_AWQ_BETA_CFG, hidden_size=128)
ref, _ = _export_transformers_checkpoint(ref_model, torch.bfloat16)
# Sharded before it is calibrated, as hf_ptq --use_fsdp2 does on one GPU.
model = _tiny_quantized_llama(mtq.W4A8_AWQ_BETA_CFG, hidden_size=128, mesh=gloo_world_of_one)

_export_fsdp2_checkpoint_streaming(model, torch.bfloat16, export_dir=tmp_path)

streamed = _load_all(tmp_path)
assert set(streamed) == set(ref)
for key in ref:
assert torch.equal(streamed[key], ref[key].cpu()), key


def test_streaming_export_drops_tied_alias_and_writes_config(tmp_path):
model = _tiny_quantized_llama()
d = tmp_path
Expand Down
Loading
Loading