Skip to content
Open
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
40 changes: 40 additions & 0 deletions modelopt/torch/distill/plugins/megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,46 @@ def _set_input_tensor(self, input_tensors: list[Tensor]):

# HACK: Concatenate output tensors when PP>1 so they can be passed between ranks.
def _forward(self, *args, **kwargs):
# Static-block NVFP4: promote the student's weight quantizers once, after the
# checkpoint amax/scales have been loaded, so the training forward takes the
# StaticBlockScaleQuantizer path rather than the generic FP8 (E4M3) path. Promotion
# cannot happen at build time because the scales only exist after the load.
#
# In practice this converts exactly ONE module -- ``output_layer``; every other quantizer
# is already a StaticBlockScaleQuantizer by the time training starts. So this is a
# workaround for output_layer being the one module the restore path does not promote (the
# same asymmetry behind its weight-quantizer scales not being restored). The better fix is
# to promote it on the normal restore path, before the model is wrapped in DDP; until then,
# without this block the output projection would train through the generic FP8 path
# instead of static-block NVFP4.
if not getattr(self, "_modelopt_nvfp4_promoted", False):
Comment on lines +611 to +623

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] Two smaller points on placing this in _forward:

  1. The comment documents the workaround as a workaround. It says outright that "the better fix is to promote it on the normal path" — and the other half of this PR is touching that normal path. Since promotion after set_extra_state is what quant_module_set_extra_statemaybe_promote_nvfp4_static_quantizer already does for every other module, it would be worth a follow-up issue reference here so this doesn't become permanent.

  2. Promotion mutates module classes and can register a new _global_amax buffer at first forward, i.e. after the model has been wrapped in DDP / the distributed optimizer and after param_and_grad_buffer bucketing. Newly registered buffers aren't broadcast by DDP, so ranks rely on each computing the same value locally — which is the concern raised in the global_amax= comment above. If promotion instead happens at the end of restore (before wrapping), both problems disappear.

Also: the per-run numbers in the comment (already promoted 460, converted 1, skipped 0) will read as stale the first time someone runs a different model/recipe. Consider dropping the counts and keeping the "exactly output_layer in practice" statement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted the concrete parts — f46cc40.

  • Dropped the run-specific counts from the comment; kept the "exactly output_layer in practice" statement.
  • Reworded the follow-up note to name the destination explicitly: promotion belongs on the normal restore path, before the model is wrapped in DDP. That is the same fix that removes your second concern (a buffer registered at first forward is not broadcast), so I have kept them as one item rather than two.

I have deliberately not moved the promotion in this PR — doing it at restore time touches the shared quant_module_set_extra_state path for every module, which is a larger change than the output_layer fix this PR is scoped to. Happy to open it as a follow-up issue.

# Imported locally on purpose: this distillation plugin must stay usable without
# ``modelopt.torch.quantization`` installed/imported (a plain, non-quantized KD run
# never reaches this branch), so it must not take a module-scope dependency on it.
from modelopt.torch.quantization.utils import promote_static_block_weight_quantizers

# ``_global_amax`` restored from the checkpoint is a replicated scalar, but promotion
# recomputes it with ``reduce_amax`` over this rank's LOCAL weight shard -- which is
# rank-inconsistent for the column-parallel output_layer, whose ``_amax`` is sharded
# on dim 0. Keep whatever the checkpoint carried.
restored_global_amax = {
id(m): m._global_amax.detach().clone()
for m in self.modules()
if getattr(m, "_global_amax", None) is not None
}
# The teacher is a registered submodule; promote the student's quantizers only.
with self.hide_teacher_model():
n_promoted = promote_static_block_weight_quantizers(self)
for m in self.modules():
saved = restored_global_amax.get(id(m))
if saved is not None:
m._global_amax.copy_(saved)
if n_promoted:
logger.info(
f"Promoted {n_promoted} static-block weight quantizer(s) to "
"StaticBlockScaleQuantizer after checkpoint load."
)
self._modelopt_nvfp4_promoted = True
with torch.no_grad():
self._teacher_model.eval()
teacher_output = self._teacher_model(*args, **kwargs)
Expand Down
128 changes: 113 additions & 15 deletions modelopt/torch/quantization/plugins/megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,52 @@ def _incompatible_method(self, *args, **kwargs):
return _incompatible_method


def _resolve_output_layer_untied(model: torch.nn.Module) -> bool | None:
"""Whether ``output_layer`` weights are untied from the input embeddings, or None if unknown.

Megatron-Core models carry ``share_embeddings_and_output_weights`` (Megatron-Bridge sets it
from the HF config, Megatron-LM from ``--untie-embeddings-and-output-weights``), so reading
it off the model works under both frameworks. ``megatron.training.get_args()`` does not:
Bridge has no global args store, and defaulting to "tied" there silently drops the
``output_layer`` weight-quantizer state from the sharded checkpoint.
"""
shared = getattr(model, "share_embeddings_and_output_weights", None)
if shared is not None:
return not bool(shared)
for name, module in model.named_modules():
# Skip subtrees that do not own the language model's output_layer: the vision tower (never
# quantized here) and a distillation teacher, which may be tied differently from the
# student it is wrapped with.
if "vision_model" in name or "_teacher_model" in name:
continue
shared = getattr(module, "share_embeddings_and_output_weights", None)
if shared is not None:
return not bool(shared)
return None
Comment on lines +251 to +272

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] "First module in named_modules() that has the attribute" is fragile for multi-model graphs, and the resolved value is then broadcast onto all non-vision configs.

Two concrete cases:

  • Distillation / QAD: this hook is invoked via CUSTOM_MODEL_PLUGINS on whatever root it's given. For a DistillationModel, named_modules() walks both _teacher_model and the student; whichever appears first decides tiedness for both, and the flag is then written onto every MegatronModule.config below (line 339). If teacher and student differ in tying, one of them gets the wrong answer.
  • VLM: the flag is looked up across the whole tree including vision_model, but only written to non-vision_model configs. _resolve_output_layer_untied should be scoped to the language model it's describing.

Since output_layer is what the flag is about, resolving from the module that actually owns it is both narrower and unambiguous — walk to the enclosing model of each output_layer, or at minimum prefer the root's own attribute before falling back to a subtree scan:

def _resolve_output_layer_untied(model):
    shared = getattr(model, "share_embeddings_and_output_weights", None)
    if shared is not None:
        return not bool(shared)
    for name, module in model.named_modules():
        if "vision_model" in name:
            continue
        shared = getattr(module, "share_embeddings_and_output_weights", None)
        if shared is not None:
            return not bool(shared)
    return None

Note the existing consumers of this flag elsewhere in the codebase all read it off the specific model (unified_export_megatron.py:480, megatron_importer.py:825), not off an arbitrary first match.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted — fixed in f46cc40.

_resolve_output_layer_untied now prefers the root's own share_embeddings_and_output_weights and skips vision_model / _teacher_model subtrees in the fallback scan. The DistillationModel case was the real one: named_modules() walks both teacher and student, so whichever came first decided tiedness for both.

Added unit coverage for all four cases (no signal, root wins over subtree, subtree fallback, vision/teacher skipped).



def _output_layer_untied(config) -> bool:
"""Whether ``output_layer`` is untied, for use from ``sharded_state_dict``.

Precedence: the model-derived flag recorded by ``megatron_replace_quant_module_hook`` (the only
source available under Megatron-Bridge, which has no global args store), then Megatron-LM's
``--untie-embeddings-and-output-weights``. The answer is cached back onto ``config`` so a model
carrying neither signal warns once instead of on every save and every load.
"""
untied = getattr(config, "modelopt_output_layer_untied", None)
if untied is not None:
return untied
try:
from megatron.training import get_args as _mlm_get_args

untied = bool(getattr(_mlm_get_args(), "untie_embeddings_and_output_weights", False))
except Exception as e:
warn_rank_0(f"Failed to get Megatron arg untie_embeddings_and_output_weights: {e}")
untied = False
config.modelopt_output_layer_untied = untied
return untied


def megatron_replace_quant_module_hook(model: torch.nn.Module):
"""Configure Megatron-Core model quantization support.

Expand All @@ -260,6 +306,7 @@ def megatron_replace_quant_module_hook(model: torch.nn.Module):
typing-matching the QuantModuleRegistry.
3. For Attention modules, we configure them to use core_attention path for KV cache quantization.
"""
untied = _resolve_output_layer_untied(model)

def _configure_attention_for_kv_cache_quant(module: Attention):
"""Configure Attention module for KV cache quantization compatibility."""
Expand Down Expand Up @@ -287,11 +334,17 @@ def _configure_attention_for_kv_cache_quant(module: Attention):
def _register_extra_state_callbacks(model: torch.nn.Module):
for name, module in model.named_modules():
if type(module) in QuantModuleRegistry:
# Skip output_layer w/o enabled weight_quantizer
if name.endswith("output_layer") and not getattr(
getattr(module, "weight_quantizer", None), "is_enabled", False
):
continue
# Skip output_layer w/o enabled weight_quantizer. This hook also runs BEFORE
# QuantModule replacement (e.g. on restore), when ``weight_quantizer`` does not
# exist yet -- the old check then always skipped, so output_layer never received
# ModelOpt extra-state callbacks and its quantizer state (promotion to
# StaticBlockScaleQuantizer, ``_amax``, ``_global_amax``) was never restored.
# Fall back to the tying flag: an untied output_layer is quantizable.
if name.endswith("output_layer"):
_wq = getattr(module, "weight_quantizer", None)
_skip = not getattr(_wq, "is_enabled", False) if _wq is not None else not untied
if _skip:
continue
register_modelopt_extra_state_callbacks(
Comment on lines +337 to 348

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] The fallback registers extra-state callbacks for every untied output_layer, including the (very common) case where its quantizer is disabled — which changes the set of keys existing checkpoints advertise.

Since this hook runs before QuantModule replacement, _wq is always None in that pass, so _skip = not untied is the only thing deciding registration. modelopt_recipes/configs/ptq/units/default_disabled_quantizers.yaml disables *output_layer* and *lm_head* by default, so for the mainstream recipes on any untied model (Llama-style, most HF configs), output_layer now gets get_extra_state/set_extra_state patched where it previously did not.

_modelopt_get_extra_state returns a non-empty dict as soon as any callback fires (quant_module_get_extra_state always emits modelopt_quantizer_state, plus real-quant keys), so output_layer._extra_state appears in the sharded state dict. That is a state-dict schema change in both directions:

  • new code loading a pre-PR checkpoint: the model advertises output_layer._extra_state, the checkpoint has no such key — Megatron's dist loader errors on keys the model requires but the checkpoint lacks (the silent-skip asymmetry you documented is the other direction);
  • pre-PR code loading a new checkpoint: the extra key is silently dropped, which is benign but leaves the two formats non-identical.

Suggestion: keep the fallback narrow so it only fires when output_layer will actually be quantized. Two options — either consult the resolved quant config for the layer instead of just tiedness, or (simpler) restrict the _wq is None fallback to the restore path, e.g. register only when the module already carries ModelOpt quantizer state. Whichever you choose, please note the checkpoint-format implication in the PR description so it isn't discovered on a resume.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Half accepted: I will document this in the PR description, but I do not think the code can be narrowed the way you suggest.

You are right about the mechanism — at this point in the hook weight_quantizer does not exist yet, so untied is the only available signal, and an untied output_layer under a recipe that disables it will now get extra-state callbacks it did not get before.

The problem with both proposed narrowings is that they need information this hook cannot have:

  • "consult the resolved quant config" — the quant config is not passed to CUSTOM_MODEL_PLUGINS callbacks; the hook only receives the model.
  • "register only when the module already carries ModelOpt quantizer state" — that is false on the save side of a fresh mtq.quantize, where the state is created by the very replacement this hook runs before. Gating on it would reinstate exactly the silent-BF16 bug this PR fixes, just on a different path.

Scoping note on the blast radius: this hook only runs inside ModelOpt quantize/restore flows, so a plain Megatron checkpoint is unaffected; the change is confined to ModelOpt checkpoints where output_layer is untied.

If a maintainer prefers, I am happy to thread the resolved quant config into the hook in a follow-up so registration can key off the layer's actual config rather than tiedness — that is the only version of this that is correct in both directions. Flagging it in the PR description meanwhile.

module,
quant_module_get_extra_state,
Expand All @@ -307,6 +360,10 @@ def _register_extra_state_callbacks(model: torch.nn.Module):
if "vision_model" not in name:
# We only enable hetereogenous_dist_checkpoint for language model, vision model is not quantized
module.config.hetereogenous_dist_checkpoint = True
if untied is not None:
# Carried on the config so _MegatronParallelLinear.sharded_state_dict can read
# it without Megatron-LM global args (absent under Megatron-Bridge).
module.config.modelopt_output_layer_untied = untied
_register_extra_state_callbacks(module)


Expand Down Expand Up @@ -374,18 +431,59 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None):
# output_layer.input_quantizer._amax but TP-only does not. This lead to
# state_dict mismatch.
if prefix.endswith("output_layer."):
try:
from megatron.training import get_args as _mlm_get_args

_untied = bool(
getattr(_mlm_get_args(), "untie_embeddings_and_output_weights", False)
)
except Exception as e:
warn_rank_0(f"Failed to get Megatron arg untie_embeddings_and_output_weights: {e}")
_untied = False
if not _untied:
if not _output_layer_untied(self.config):
return super().sharded_state_dict(prefix, sharded_offsets, metadata)
Comment on lines 433 to 435

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The tiedness resolution now exists in two places with a precedence rule between them (self.config.modelopt_output_layer_untied first, get_args() second). That logic — "model flag wins, Megatron-LM global args as fallback" — would be clearer as a single helper next to _resolve_output_layer_untied, e.g. _output_layer_untied(config), so the fallback can't drift out of sync if a third source ever appears.

Minor related point: when _resolve_output_layer_untied returns None and get_args() raises, warn_rank_0 fires once per output_layer per sharded_state_dict() call. Under Megatron-Bridge with a model that carries neither signal, that's a warning on every save and every load. Caching the resolved value on the config (as the hook already does for the success case) would keep it to one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted — fixed in f46cc40.

Both points are addressed by a single _output_layer_untied(config) helper that owns the precedence rule and caches the answer back onto the config, so the get_args()-raises path warns once rather than on every save and every load. sharded_state_dict is now a one-liner:

if not _output_layer_untied(self.config):
    return super().sharded_state_dict(prefix, sharded_offsets, metadata)

Unit-tested for both precedence and the caching behaviour.


# Materialize missing weight-quantizer scale buffers so their keys appear in the load
# plan -- the dist-checkpoint loader SILENTLY SKIPS any checkpoint key the model does
# not advertise, which leaves output_layer uncalibrated and exports it as BF16.
# ``_amax`` must be allocated FLAT ``[numel // block, 1]``: that is the in-memory
# layout every other block-quantized layer uses, and ``_process_quantizer_amax`` below
# exposes it to the checkpoint as a ``[out_features, blocks]`` VIEW sharing the same
# storage, so the loader writes straight through. Allocating the viewed shape instead
# loads fine but leaves the wrong in-memory shape, which breaks the export scale math.
# Only STATIC block quant owns these buffers: a dynamic quantizer derives its scales
# per forward and deliberately never holds an ``_amax`` (its ``amax`` property asserts
# ``not self._dynamic``), so materializing one there would be actively wrong.
_wq = getattr(self, "weight_quantizer", None)
if (
_wq is not None
and getattr(_wq, "is_enabled", False)
and getattr(_wq, "is_static_block_quant", False)
):
_block_sizes = getattr(_wq, "_block_sizes", None) or {}
_block = _block_sizes.get(-1) or _block_sizes.get(1)
# `_process_quantizer_amax` later does `v.view(weight.shape[0], -1)`, which
# requires in_features (not just numel) to divide evenly by the block size.
if _block and self.weight.shape[-1] % int(_block) == 0:
if getattr(_wq, "_amax", None) is None:
# Seed from the weights rather than zeros. For weight-only quantization
# this is exactly what max calibration produces, so a checkpoint that
# turns out not to carry these keys degrades to "recalibrated from
# weights" instead of to scale=0 (or NaN) at export and forward.
_wq.amax = (
self.weight.detach()
.reshape(-1, int(_block))
.abs()
.amax(dim=1, keepdim=True)
.float()
)
# register_buffer directly: the ``global_amax`` property lives on
# StaticBlockScaleQuantizer, and on restore this is still a plain
# TensorQuantizer (promotion happens later), so the setter is unavailable.
if getattr(_wq, "_global_amax", None) is None:
_wq.register_buffer(
"_global_amax", _wq._amax.detach().max().float().clone()
)
Comment on lines +448 to +477

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL Algorithm] This materialization is not gated on the quantizer being static block quant, and it runs on the save path as well as the load path.

sharded_state_dict() is called both when writing a checkpoint and when building the load plan. The only guards here are is_enabled and block-size divisibility, so:

  1. Dynamic NVFP4 (the default) gets a bogus _amax. modelopt_recipes/configs/numerics/nvfp4.yaml — used by w4_nvfp4 (W4A16) and w4a4_nvfp4_nvfp4 — sets the weight quantizer to block_sizes: {-1: 16, type: dynamic, scale_bits: e4m3}. A dynamic quantizer intentionally never has _amax (TensorQuantizer.forward skips calibration when self._dynamic), and TensorQuantizer.amax asserts not self._dynamic. With an untied, enabled output_layer under dynamic NVFP4, this block registers a zero _amax on a dynamic quantizer, which then (a) trips that assertion on any later .amax access, and (b) is picked up by the loop below ("_quantizer" in k and "_amax" in k) and written into the checkpoint as an all-zero weight_quantizer._amax for a layer where no other rank/layer has one. The author's verified config (nvfp4_four_over_six) happens to be type: static, so this path wasn't exercised.

  2. Zeros are not a safe default even in the static case. If the checkpoint being loaded doesn't carry these keys (e.g. a checkpoint saved before this fix, or a save-path invocation), the buffers stay all-zero, and a zero amax/global_amax yields zero or NaN scales at export/forward — worse than the BF16 fallback this PR is fixing, and equally silent.

Suggested fix: gate on static block quant, and only materialize when actually building a load plan (or at minimum verify post-load that the values are non-zero):

_wq = getattr(self, "weight_quantizer", None)
if (
    _wq is not None
    and getattr(_wq, "is_enabled", False)
    and getattr(_wq, "is_static_block_quant", False)
):
    ...

and consider initializing _amax from the current weight (reduce_amax over blocks) rather than zeros, so a checkpoint that lacks the key degrades to "recalibrated from weights" instead of "scale = 0".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted, both halves — fixed in f46cc40.

  1. The materialization is now gated on is_static_block_quant, so a dynamic NVFP4 weight quantizer is left alone. You are right that it would otherwise have registered an _amax on a quantizer whose amax property asserts not self._dynamic, and written an all-zero amax into the checkpoint. Verified nvfp4.yaml uses type: dynamic while the config this PR was developed against (nvfp4_four_over_six) is type: static — so the bad path was real and simply never exercised.
  2. The buffers are now seeded from the weights rather than zeros:
    _wq.amax = self.weight.detach().reshape(-1, int(_block)).abs().amax(dim=1, keepdim=True).float()
    ...
    _wq.register_buffer("_global_amax", _wq._amax.detach().max().float().clone())
    For weight-only quantization that is exactly what max calibration produces, so a checkpoint that turns out not to carry these keys degrades to "recalibrated from weights" instead of to scale=0/NaN — which, as you note, would be worse than the BF16 fallback this PR is fixing.

else:
# Leaving the buffers unallocated is the silent-drop failure this block exists
# to prevent, so say so rather than proceeding quietly.
warn_rank_0(
f"{prefix}weight_quantizer: cannot materialize scale buffers "
f"(block_size={_block}, in_features={self.weight.shape[-1]}); its "
"calibrated scales will not be restored from the checkpoint."
)

quantizer_state_dict = {}
for k, v in self.state_dict(prefix="", keep_vars=True).items():
if "_quantizer" in k and "_amax" in k:
Expand Down
51 changes: 51 additions & 0 deletions tests/gpu_megatron/torch/quantization/plugins/test_megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -1302,3 +1302,54 @@ def test_homogeneous_sharded_state_dict_te_spec(dist_workers, tmp_path):
{"transformer_impl": "transformer_engine"},
),
)


def test_resolve_output_layer_untied():
"""The tiedness signal is read off the model, not from Megatron-LM global args."""
from modelopt.torch.quantization.plugins.megatron import _resolve_output_layer_untied

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move the plugin imports to module scope.

These imports have no circular-dependency, optional-dependency, or deferred-heavy-import justification. Import errors should fail during test collection.

  • tests/gpu_megatron/torch/quantization/plugins/test_megatron.py#L1309-L1309: move _resolve_output_layer_untied to the module import section.
  • tests/gpu_megatron/torch/quantization/plugins/test_megatron.py#L1340-L1340: move _output_layer_untied to the module import section.

As per path instructions, imports inside test methods require an explicit justification and otherwise belong at the top of the file.

📍 Affects 1 file
  • tests/gpu_megatron/torch/quantization/plugins/test_megatron.py#L1309-L1309 (this comment)
  • tests/gpu_megatron/torch/quantization/plugins/test_megatron.py#L1340-L1340
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/gpu_megatron/torch/quantization/plugins/test_megatron.py` at line 1309,
Move the plugin imports for _resolve_output_layer_untied at
tests/gpu_megatron/torch/quantization/plugins/test_megatron.py lines 1309-1309
and _output_layer_untied at lines 1340-1340 into the module-level import
section, removing the imports from the test methods; no direct changes are
required elsewhere.

Sources: Coding guidelines, Path instructions


class _Flagged(torch.nn.Module):
def __init__(self, shared):
super().__init__()
self.share_embeddings_and_output_weights = shared

# No signal anywhere -> unknown.
assert _resolve_output_layer_untied(torch.nn.Module()) is None

# The root's own flag wins over any subtree.
root = _Flagged(False)
root.inner = _Flagged(True)
assert _resolve_output_layer_untied(root) is True

# Otherwise fall back to a subtree scan.
root = torch.nn.Module()
root.language_model = _Flagged(True)
assert _resolve_output_layer_untied(root) is False

# Subtrees that do not own the language model's output_layer are skipped: the vision tower
# and a distillation teacher, either of which may be tied differently from the student.
root = torch.nn.Module()
root.vision_model = _Flagged(True)
root._teacher_model = _Flagged(True)
root.language_model = _Flagged(False)
assert _resolve_output_layer_untied(root) is True


def test_output_layer_untied_precedence_and_caching():
"""The model-derived flag wins over Megatron-LM args, and the answer is cached."""
from modelopt.torch.quantization.plugins.megatron import _output_layer_untied

class _Config:
pass

config = _Config()
config.modelopt_output_layer_untied = True
assert _output_layer_untied(config) is True

# With no model-derived flag the args fallback runs, and its answer is cached back onto the
# config so a model carrying neither signal does not warn on every save and every load.
config = _Config()
resolved = _output_layer_untied(config)
assert isinstance(resolved, bool)
assert config.modelopt_output_layer_untied is resolved
assert _output_layer_untied(config) is resolved
Comment on lines +1349 to +1355

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Test the Megatron-LM fallback result.

The test passes when megatron.training is unavailable because _output_layer_untied catches the import failure and returns False. It also passes if the fallback ignores a true untie_embeddings_and_output_weights value.

Patch megatron.training.get_args to return both True and False values. Assert the resolved value and assert that the second call uses the cached value without another get_args call. As per coding guidelines, tests must exercise the behavior they claim to validate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/gpu_megatron/torch/quantization/plugins/test_megatron.py` around lines
1349 - 1355, Update the test for _output_layer_untied to patch
megatron.training.get_args and cover both True and False fallback results.
Assert each resolved value, verify it is cached on
config.modelopt_output_layer_untied, and assert the second invocation returns
the cached value without calling get_args again.

Source: Coding guidelines

Loading