Repository navigation
[OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099
Changes from all commits
8674b73
c186322
8d1caf3
9f30d03
3c7c5d9
a48ce7a
d568dac
6033bb8
46742ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| return contextlib.nullcontext() | ||
|
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, | ||
|
|
@@ -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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Reproduced on two CPU/Gloo ranks with the unmodified recipe, including its AWQ calibration: the existing gathered handler produces reference-matching bytes, while the 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, reproduced. It predates this PR: on main, |
||
| _export_quantized_weight(module, ctx.dtype, weight_name) | ||
| return | ||
| _export_quantized_weight(module, ctx.dtype, weight_name) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[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 directlyfully_shard-ed QuantLinear,create_fsdp_param_mapping()callsget_prefixed_param_names(linear, linear): the first parameter name isweight, whosersplit('.', 1)[0]is stillweight, yielding the mapping keyweight.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. Covermodel = 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.There was a problem hiding this comment.
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 linearfully_shard-ed, the window's FSDPParam mapping heldweight.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.get_prefixed_param_namesnow 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 offsdp2_aware_weight_updatenow 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.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.