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
93 changes: 93 additions & 0 deletions modelopt/torch/export/hf_export_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@
"""Built-in module handlers for unified Hugging Face export."""

import collections.abc
import contextlib
import warnings
from dataclasses import dataclass

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

from modelopt.torch.quantization.utils import fsdp2_aware_weight_update, module_name_maps
from modelopt.torch.quantization.utils.core_utils import ModuleNames, _get_enclosing_fsdp_module
from modelopt.torch.utils.distributed import distribution_plan

from .layer_utils import get_expert_linear_names, is_quantlinear, set_expert_quantizer_amax
from .moe_utils import _export_fused_experts
Expand All @@ -34,6 +41,86 @@ def _has_fused_experts_quantizers(module: nn.Module) -> bool:
return hasattr(module, f"{first_proj_attr}_weight_quantizers")


@dataclass
class _HeldWindow:
"""An FSDP2 unit's weight-update window, and the modules packed in it so far."""

unit: nn.Module
modules: list[nn.Module]
stack: contextlib.ExitStack


def _enclosing_unit(ctx: ExportContext, module: nn.Module) -> nn.Module:
"""The innermost FSDP2 unit enclosing ``module``, or the model if none does."""
if ctx.module_names is None:
ctx.module_names = module_name_maps(ctx.model)
return _get_enclosing_fsdp_module(module, ctx.model, ctx.module_names) or ctx.model


def _window_root(ctx: ExportContext, unit: nn.Module) -> tuple[nn.Module, ModuleNames | None]:
"""The root a weight-update window on ``unit`` is opened on, and its module names.

A window does nothing on an unwrapped root, which expert parallelism leaves (it FSDP2-shards
only the dense layers), so there the unit itself is the root, with names relative to it.
"""
if isinstance(ctx.model, FSDPModule) or unit is ctx.model:
# The model's own names, which _enclosing_unit has built.
return ctx.model, ctx.module_names
return unit, module_name_maps(unit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

[P2] Re-register parameters owned directly by this window root. This fallback passes the FSDP unit itself as root_model. For an unwrapped parent containing a directly fully_shard-ed QuantLinear, create_fsdp_param_mapping() calls get_prefixed_param_names(linear, linear): the first parameter name is weight, whose rsplit('.', 1)[0] is still weight, yielding the mapping key weight.weight. On exit, fsdp2_aware_weight_update() gets the module name '' and searches for .weight. Nothing matches, so it skips the new packed parameter and reshard restores the original floating-point shard. This defeats the new fallback's writeback guarantee without an error. Build mapping keys from the owning module's relative name and join an empty prefix as just the parameter name, consistently in both mapping creation and the exit loop. Cover model = nn.Sequential(quantized_linear) with only that linear fully sharded, keep_fused_experts=True, and assert that its sharded state dict retains the packed bytes. This finding is established by source tracing; I could not execute the reproduction.

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.

Reproduced on one gloo rank, and fixed in 54f8632.

With nn.Sequential(quant_linear) and only the linear fully_shard-ed, the window's FSDPParam mapping held weight.weight, while its exit looked up .weight. It skipped the packed weight, the reshard put the float shard back, and the exported weight stayed float32, as you traced.

  • The fix: get_prefixed_param_names now names the owner of a parameter that the root holds directly "", where it returned the parameter's own name. The mapping, the entry check and the exit loop of fsdp2_aware_weight_update now join a module name and a parameter name the same way (_param_key), so the root's own parameters are keyed by their bare names.
  • The test: the handler test gains a layout in which each linear is an FSDP2 unit of its own under an unwrapped root. Both its held and per-module cases fail without the fix.

On reachability: modelopt's own loader wraps the decoder layers and the root, never a linear on its own. That is why I called this layout unreachable on CodeRabbit's earlier comment. But a model wrapped by hand would have exported unquantized weights with no error, so it is fixed here.

Comment thread
shengliangxu marked this conversation as resolved.
Resolved


def _fsdp2_weight_window(
ctx: ExportContext, module: nn.Module
) -> contextlib.AbstractContextManager:
"""The ``fsdp2_aware_weight_update`` window to pack ``module``'s weights in.

Inside :func:`held_fsdp2_weight_windows`, the window of the module's FSDP2 unit, held open
across the unit's modules. Anywhere else, a window of the module's own, closed when the handler
is done, so a dispatch loop that knows nothing of held windows cannot leave a unit unsharded.
"""
unit = _enclosing_unit(ctx, module)
if not ctx.hold_fsdp2_windows:
root, names = _window_root(ctx, unit)
return fsdp2_aware_weight_update(root, module, names=names)
held = ctx.fsdp2_window
if held is not None and held.unit is unit:
# Once per module, though a handler packing several weights asks once per weight: the
# window re-registers each listed module's weights with FSDP2 when it closes.
if not any(m is module for m in held.modules):
held.modules.append(module)
Comment on lines +89 to +90

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] The dedupe here is the right fix (it was the CRITICAL from the previous round, and _export_bmm_experts is the live caller that asks twice for gate_up_proj then down_proj). My concern is only with the evidence behind it.

test_a_module_joins_its_units_held_window_once runs on gloo_world_of_one, and at world_size=1 the re-chunk in FSDPParam._init_sharded_param is the identity: without this if, pass 2 would re-read module.named_parameters(), get back the param pass 1 already assigned, and chunk it to the same bytes. Its final assertion (sd["0.0.weight"].full_tensor() == ref.weight) then still holds, so the test looks like it would pass against the unfixed code — which is exactly how the bug survived the previous round (CONTRIBUTING's "each fix has a test that fails without it").

Could you revert these two lines locally and confirm the test actually fails? If it passes, the test is asserting something other than what it documents, and the real check needs either a world_size >= 2 fixture (tests/gpu* or the session-scoped cpu_dist_workers) or a direct assertion on the held list — e.g. assert len(ctx.fsdp2_window.modules) == 1 after the second _fsdp2_weight_window call, which is rank-count independent and fails immediately without the dedupe.

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.

I checked by reverting the two lines: the test fails without the dedupe.

At world size one the re-chunk is the identity, as you say, but pass 2 never reaches it. It reads back the param that pass 1 installed, which is now a DTensor. FSDPParam then takes its tensor-parallel path (_init_sharding_spec_tp), and that path raises in DeviceMesh._concatenate with TypeError: Invariant encountered: value was None when it should not be. The test is checking for that failure: the window closes cleanly, and the packed weight survives the reshard.

return contextlib.nullcontext()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
_close_fsdp2_weight_window(ctx)
root, names = _window_root(ctx, unit)
held = _HeldWindow(unit, [module], contextlib.ExitStack())
held.stack.enter_context(fsdp2_aware_weight_update(root, held.modules, names=names))
ctx.fsdp2_window = held
return contextlib.nullcontext()


@contextlib.contextmanager
def held_fsdp2_weight_windows(ctx: ExportContext):
"""Within the block, pack each FSDP2 unit's modules in one weight-update window.

The window unshards the whole unit and reshards it when it closes, so a window per module
gathers a decoder layer once per quantized linear in it. Here the unit's window stays open from
one handler to the next until the export reaches another unit, each module joining the list
whose weights it re-registers with FSDP2, and leaving the block closes the last.
"""
ctx.hold_fsdp2_windows = True
try:
yield
finally:
ctx.hold_fsdp2_windows = False
_close_fsdp2_weight_window(ctx)


def _close_fsdp2_weight_window(ctx: ExportContext) -> None:
"""Close the weight-update window the handlers hold open, if any, resharding its unit."""
held, ctx.fsdp2_window = ctx.fsdp2_window, None
if held is not None:
held.stack.close()


def _export_weight(
module: nn.Module,
ctx: ExportContext,
Expand All @@ -43,6 +130,12 @@ def _export_weight(
# install the built-in handlers while retaining this legacy helper's import path.
from .unified_export_hf import _export_quantized_weight

# The no-gather export packs a still-sharded model: an FSDP2-owned weight goes in its unit's
# weight-update window, or the unit's next reshard puts FSDP2's unquantized shard back.
if ctx.keep_fused_experts and distribution_plan(ctx.model).is_fsdp_dense:
with _fsdp2_weight_window(ctx, module):

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.

[P2] Handle sequential quantizers before opening the FSDP2 packing window

The new route also accepts W4A8, but opening the window calls enable_fake_quant(), which reads m.weight_quantizer._fake_quant. W4A8_AWQ_BETA_CFG uses a SequentialQuantizer, so this raises AttributeError: 'SequentialQuantizer' object has no attribute '_fake_quant' before packing.

Reproduced on two CPU/Gloo ranks with the unmodified recipe, including its AWQ calibration: the existing gathered handler produces reference-matching bytes, while the keep_fused_experts=True held-window route fails on both ranks. The helper limitation predates this PR, but this new handler exposes it.

Could we save/restore fake-quant state on the constituent quantizer stages, or explicitly reject unsupported recipes before entering the window? Please add a W4A8 handler test to cover the chosen behavior.

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.

Thanks, reproduced. It predates this PR: on main, export_hf_checkpoint on an FSDP2 model quantized with W4A8_AWQ_BETA_CFG raises the same AttributeError in the window that the AWQ resmooth opens. Fixed in #3128 (f06c347): enable_fake_quant now sets and restores the flag on each stage of a sequential weight quantizer. The PR adds a one-rank CPU test that fails without the fix and re-enables W4A8 in the FSDP2 GPU export tests.

_export_quantized_weight(module, ctx.dtype, weight_name)
return
_export_quantized_weight(module, ctx.dtype, weight_name)


Expand Down
6 changes: 4 additions & 2 deletions modelopt/torch/export/quant_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
NVFP4QTensor,
QTensorWrapper,
)
from modelopt.torch.quantization.qtensor.base_qtensor import _refuse_dtensor
from modelopt.torch.quantization.utils import (
quantizer_attr_names,
representative_weight_quantizer,
Expand Down Expand Up @@ -828,6 +829,7 @@ def _validate_int4_block_size(in_dim, block_size):

def pack_int4_in_uint8(weight, weights_scaling_factor, block_size):
"""Packs the INT4 weights into uint8 tensor."""
_refuse_dtensor(weight, "INT4 packing")
out_dim = weight.shape[-2]
assert out_dim % 2 == 0, f"Cannot pack weight. Out dimension {out_dim} is not an even number."
in_dim = weight.shape[-1]
Expand All @@ -851,7 +853,7 @@ def pack_int4_in_uint8(weight, weights_scaling_factor, block_size):
# Pack two 4-bit values (val0,val1) into a single byte:
val0 = transpose[..., 0] & 0x0F
val1 = transpose[..., 1] & 0x0F
packed_byte = val0 | (val1 << 4)
packed_byte = val0 | torch.bitwise_left_shift(val1, 4)

# Transpose back to the shape (experts, out_dim // 2, in_dim)
return packed_byte.permute(0, 2, 1).contiguous().view(torch.uint8)
Expand All @@ -864,7 +866,7 @@ def pack_int4_in_uint8(weight, weights_scaling_factor, block_size):
# Pack two 4-bit values into one byte
val0 = reshaped[..., 0] & 0x0F
val1 = reshaped[..., 1] & 0x0F
packed_byte = val0 | (val1 << 4)
packed_byte = val0 | torch.bitwise_left_shift(val1, 4)

# Return shape (out_dim // 2, in_dim)
return packed_byte.T.contiguous().view(torch.uint8)
Expand Down
21 changes: 21 additions & 0 deletions modelopt/torch/export/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@

from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING

import torch
import torch.nn as nn

if TYPE_CHECKING:
from modelopt.torch.export.hf_export_handlers import _HeldWindow
from modelopt.torch.quantization.utils.core_utils import ModuleNames

__all__ = [
"ExportContext",
"ExportHandler",
Expand Down Expand Up @@ -59,6 +64,22 @@ class ExportContext:
model's spec in ``modelopt.torch.models``. ``None`` means unknown: spec lookups then
fail loudly instead of guessing."""

keep_fused_experts: bool = False
"""Marks the distributed no-gather export, whose handlers pack while the model is still sharded:
an FSDP2-owned weight is packed inside its unit's weight-update window."""

module_names: "ModuleNames | None" = None
"""``model``'s name maps, built once per export by the first handler that looks a module up
(:func:`~modelopt.torch.quantization.utils.module_name_maps`)."""

hold_fsdp2_windows: bool = False
"""Whether the handlers hold an FSDP2 unit's weight-update window open across its modules: set
only inside :func:`~modelopt.torch.export.hf_export_handlers.held_fsdp2_weight_windows`, which
closes the last window. Elsewhere each module is packed in a window of its own."""

fsdp2_window: "_HeldWindow | None" = None
"""The FSDP2 unit's weight-update window the handlers hold open across its modules."""


ExportHandler = Callable[[str, nn.Module, ExportContext], None]

Expand Down
16 changes: 13 additions & 3 deletions modelopt/torch/export/unified_export_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
export_sparse_attention_config = None

# Importing the built-in handlers installs their entries in the two registries.
from . import hf_export_handlers as _hf_export_handlers # noqa: F401
from . import hf_export_handlers as _hf_export_handlers
from .convert_hf_config import convert_hf_quant_config_format
from .layer_utils import get_experts_list, is_layernorm, is_quantlinear, sync_moe_gate_up_amax
from .model_utils import TiedWeightMap, get_language_model_from_vl, is_multimodal_model
Expand Down Expand Up @@ -636,6 +636,12 @@ def _export_quantized_weight(
The export includes converting weight tensor to correct quantized values and quantized dtype,
and registering scaling factors.

An export handler packs through ``hf_export_handlers._export_weight`` rather than calling this
directly: on the keep-fused export an FSDP2-owned weight must be packed inside its unit's
weight-update window, which that helper opens.
Packed outside it, the unit's next reshard puts FSDP2's unquantized shard back, and the
checkpoint is written unquantized.

Tied-weight dedup is not handled here: both sides of a tie are packed independently
(identically, once ``sync_tied_input_amax`` has equalized their scales), and the
duplicate is dropped by name in :func:`postprocess_state_dict`. Deduping per-module at
Expand Down Expand Up @@ -996,8 +1002,12 @@ def _process_quantized_modules(
model_type=hf_model_type(model),
)

for name, sub_module in model.named_modules():
_dispatch_export_handler(name, sub_module, ctx)
# For the no-gather export, which is to set keep_fused_experts on this context and let a sharded
# model past the assert above: each FSDP2 unit's modules are then packed in one weight window,
# held across them. Until then no window opens here.
with _hf_export_handlers.held_fsdp2_weight_windows(ctx):
Comment thread
shengliangxu marked this conversation as resolved.
Resolved
for name, sub_module in model.named_modules():
_dispatch_export_handler(name, sub_module, ctx)


def _prepare_model_for_export(model, dtype, is_modelopt_qlora):
Expand Down
12 changes: 12 additions & 0 deletions modelopt/torch/quantization/qtensor/base_qtensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ class QTensorType(enum.Enum):
__all__ = ["BaseQuantizedTensor", "QTensorWrapper", "pack_real_quantize_weight"]


def _refuse_dtensor(tensor: torch.Tensor, packer: str) -> None:
"""Refuse a DTensor shard in a packer that only packs a whole tensor.

DTensor refuses the plain tensors such a packer combines with its input, or the packer gathers
the input whole on every rank; of the 4-bit packers, only NVFP4's packs a shard.
"""
if isinstance(tensor, DTensor):
raise NotImplementedError(
f"{packer} packs a whole tensor, not a DTensor shard; pack the unsharded tensor."
)


class BaseQuantizedTensor:
"""Base class for quantized tensors, providing methods for quantization and dequantization.

Expand Down
5 changes: 3 additions & 2 deletions modelopt/torch/quantization/qtensor/int4_tensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import torch

from ..extensions import get_cuda_ext
from ..qtensor.base_qtensor import BaseQuantizedTensor
from ..qtensor.base_qtensor import BaseQuantizedTensor, _refuse_dtensor
from ..utils import reduce_amax, reduce_block_padding

__all__ = ["INT4QTensor"]
Expand Down Expand Up @@ -47,6 +47,7 @@ def quantize(cls, input: torch.Tensor, block_size: int) -> tuple:
Returns:
tuple: Contains quantized data, input quantization config, and scale quantization config.
"""
_refuse_dtensor(input, cls.__name__)
cuda_ext = get_cuda_ext()

scale_quant_maxbound = cls._get_quant_maxbound(num_bits=4)
Expand Down Expand Up @@ -81,7 +82,7 @@ def quantize(cls, input: torch.Tensor, block_size: int) -> tuple:
# pack the int4 weights into a uint8 tensor
# packing format: w0, w1, w2, w3, w4, w5, ...
# | byte | byte | byte |
packed_output_uint8 = flattened[::2] << 4 | flattened[1::2]
packed_output_uint8 = torch.bitwise_left_shift(flattened[::2], 4) | flattened[1::2]

# reshape the packed_output_uint8 to the original dimensions for sharding
packed_output_uint8 = packed_output_uint8.reshape(*original_input.shape[:-1], -1)
Expand Down
6 changes: 4 additions & 2 deletions modelopt/torch/quantization/qtensor/mxfp4_tensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import torch

from ..qtensor.base_qtensor import BaseQuantizedTensor
from ..qtensor.base_qtensor import BaseQuantizedTensor, _refuse_dtensor

__all__ = ["MXFP4QTensor"]

Expand Down Expand Up @@ -57,10 +57,12 @@ def fuse_uint4_to_uint8(x):
# If this behavior is not desired, please modify the code accordingly
left_side = x[..., 0::2] # Even indices (0, 2, 4...)
right_side = x[..., 1::2] # Odd indices (1, 3, 5...)
new_data = right_side.clone() << 4 # Put odd indices (higher addresses) in high bits
# Put odd indices (higher addresses) in high bits
new_data = torch.bitwise_left_shift(right_side, 4)
new_data[..., : left_side.shape[-1]] += left_side # Put even indices in low bits
return new_data

_refuse_dtensor(input, cls.__name__)
if block_size is None:
block_size = 32

Expand Down
8 changes: 6 additions & 2 deletions modelopt/torch/quantization/qtensor/nf4_tensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import torch

from ..extensions import get_cuda_ext
from ..qtensor.base_qtensor import BaseQuantizedTensor
from ..qtensor.base_qtensor import BaseQuantizedTensor, _refuse_dtensor
from ..utils import reduce_amax, reduce_block_padding

nf4_table = torch.tensor(
Expand Down Expand Up @@ -87,6 +87,7 @@ def quantize(
Returns:
tuple: Contains quantized data, input quantization config, and scale quantization config.
"""
_refuse_dtensor(input, cls.__name__)
cuda_ext = get_cuda_ext()

# pad the input if needed
Expand Down Expand Up @@ -116,7 +117,10 @@ def quantize(
# pack the int4 weights into a uint8 tensor
# packing format: w0, w1, w2, w3, w4, w5, ...
# | byte | byte | byte |
packed_output_uint8 = quantized_output_uint8[::2] << 4 | quantized_output_uint8[1::2]
packed_output_uint8 = (
torch.bitwise_left_shift(quantized_output_uint8[::2], 4)
| quantized_output_uint8[1::2]
)

# pad the scales if needed
scales = reduce_block_padding(scales.view(-1), block_sizes={-1: scale_block_size})
Expand Down
Loading
Loading