-
Notifications
You must be signed in to change notification settings - Fork 599
Harden DASC recalibration lifecycle #2379
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ | |
| from pydantic import ValidationError | ||
| from torch import nn | ||
|
|
||
| from modelopt.torch.opt.conversion import ApplyModeError | ||
| from modelopt.torch.opt.conversion import ApplyModeError, ModeloptStateManager | ||
| from modelopt.torch.opt.mode import ConvertReturnType, MetadataDict | ||
| from modelopt.torch.utils import unwrap_model | ||
|
|
||
|
|
@@ -98,8 +98,8 @@ def restore_dasc_model(model: nn.Module, config: DASCConfig, metadata: MetadataD | |
| def update_dasc_metadata(model: nn.Module, config: DASCConfig, metadata: MetadataDict) -> None: | ||
| """Refresh metadata without making unrelated ModelOpt save or compose paths unusable.""" | ||
| policy = get_attached_dasc_policy(model) | ||
| validate_dasc_model_structure(model, policy) | ||
| try: | ||
| validate_dasc_model_structure(model, policy) | ||
| validate_dasc_decay_parameters(model, policy) | ||
| except ApplyModeError as error: | ||
| warnings.warn( | ||
|
|
@@ -110,6 +110,35 @@ def update_dasc_metadata(model: nn.Module, config: DASCConfig, metadata: Metadat | |
| metadata["policy"] = copy.deepcopy(policy.model_dump(mode="json")) | ||
|
|
||
|
|
||
| def replace_dasc_mode( | ||
| model: nn.Module, | ||
| config: DASCConfig, | ||
| measurements: Iterable[DASCCalibrationMeasurement | dict], | ||
| ) -> nn.Module: | ||
| """Replace existing DASC mode state in place with a newly derived policy.""" | ||
| model = unwrap_model(model, force_unwrap=True) | ||
| policy = build_dasc_policy(model, config, measurements) | ||
| manager = ModeloptStateManager(model) | ||
| manager.update_last_state_before_new_mode(model) | ||
|
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] Two concrete consequences:
Since policy = build_dasc_policy(model, config, measurements)
manager = ModeloptStateManager(model)
state = manager.state_dict()
dasc_indices = [index for index, (mode, _) in enumerate(state) if mode == "dasc"]
if not dasc_indices:
raise ApplyModeError("Cannot replace DASC mode because the model has no DASC state")
if dasc_indices[-1] != len(state) - 1:
# a later mode owns the tail of the state; refresh it before we rewrite ours
manager.update_last_state_before_new_mode(model)Note the ordering dependency if you keep the call as-is: it must stay before the
Contributor
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. Addressed in #2380. In-place DASC recalibration skips update_last_state_before_new_mode when DASC is already the trailing mode, so missing or stale attached metadata cannot block replacement. Refresh occurs only when a non-DASC mode trails DASC; both paths are tested. |
||
| state = manager.state_dict() | ||
| dasc_indices = [index for index, (mode, _) in enumerate(state) if mode == "dasc"] | ||
| if not dasc_indices: | ||
| raise ApplyModeError("Cannot replace DASC mode because the model has no DASC state") | ||
|
|
||
| first_index = dasc_indices[0] | ||
| state[first_index] = ( | ||
| "dasc", | ||
| { | ||
| "config": config.model_dump(), | ||
| "metadata": {"policy": policy.model_dump(mode="json")}, | ||
| }, | ||
| ) | ||
| for index in reversed(dasc_indices[1:]): | ||
| del state[index] | ||
| _attach_policy(model, policy) | ||
| return model | ||
|
|
||
|
|
||
| def get_attached_dasc_policy(model: nn.Module) -> DASCPolicy: | ||
| """Return the validated policy attached by conversion or restoration.""" | ||
| model = unwrap_model(model, force_unwrap=True) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,11 @@ def config_class(self) -> type[ModeloptBaseConfig]: | |
| """Return the validated DASC configuration class.""" | ||
| return DASCConfig | ||
|
|
||
| @property | ||
| def next_prohibited_modes(self) -> set[str]: | ||
| """Route repeat calibration through the replacing public API.""" | ||
| return {"dasc"} | ||
|
|
||
|
Comment on lines
+51
to
+55
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] This routes repeat calibration away from
Also worth noting: Consider documenting the intended recovery in the property docstring, or raising the actionable error from |
||
| @property | ||
| def convert(self) -> ConvertEntrypoint: | ||
| """Return the DASC calibration entrypoint.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ | |
| __all__ = ["analyze_gdn_decay", "compute_gdn_decay_horizons"] | ||
|
|
||
| _SUPPORTED_GDN_CLASS_NAMES = frozenset({"GatedDeltaNet", "Qwen3NextGatedDeltaNet"}) | ||
| # Covers ordinary FP16/BF16 storage casts; the exact retained-head mask below is the semantic gate. | ||
| _HORIZON_DTYPE_CAST_RTOL = 0.05 | ||
|
Comment on lines
+34
to
35
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. [IMPORTANT Compatibility] The exact-digest check was removed and Two connected problems: 1. The tolerance is not derived from dtype precision. Horizon error is dominated by 2. Nothing in the suite validates the lenient path. Suggested fix: make the tolerance dtype-aware rather than a magic 5%, e.g. scale it from
Contributor
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. Addressed in #2380. The fixed 5% tolerance is replaced by an analytic interval derived from one rounding step in the current storage dtype. Tests use values that demonstrably change under both FP16 and BF16 casts. |
||
|
|
||
|
|
||
|
|
@@ -62,9 +63,9 @@ def compute_gdn_decay_horizons( | |
|
|
||
|
|
||
| def _is_gdn_module(module: nn.Module) -> bool: | ||
| """Return whether a module has one of the explicitly supported GDN implementations.""" | ||
| """Accept supported GDN implementations and their ModelOpt dynamic subclasses.""" | ||
| return ( | ||
| type(module).__name__ in _SUPPORTED_GDN_CLASS_NAMES | ||
| any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in type(module).__mro__) | ||
|
coderabbitai[bot] marked this conversation as resolved.
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 MRO walk accepts any subclass of a supported GDN class, which is broader than the stated intent and silently re-opens the case the old check fenced off. The docstring and the doc update say "ModelOpt-generated dynamic subclasses," but If the goal is specifically to survive ModelOpt dynamic conversion, gate the subclass allowance on that rather than on subclassing in general: from modelopt.torch.opt.dynamic import DynamicModule
def _is_gdn_module(module: nn.Module) -> bool:
"""Accept supported GDN implementations and their ModelOpt dynamic subclasses."""
cls = type(module)
is_supported_class = cls.__name__ in _SUPPORTED_GDN_CLASS_NAMES or (
isinstance(module, DynamicModule)
and any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in cls.__mro__)
)
return (
is_supported_class
and isinstance(getattr(module, "A_log", None), torch.Tensor)
and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
)If accepting arbitrary subclasses is a deliberate choice, the docs at
Contributor
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. Addressed in #2380. Ordinary subclasses are no longer accepted. Only exact supported class identities, or ModelOpt DynamicModule subclasses whose MRO contains one of those exact identities, pass. Arbitrary subclass and same-name lookalike tests were added. |
||
| and isinstance(getattr(module, "A_log", None), torch.Tensor) | ||
| and isinstance(getattr(module, "dt_bias", None), torch.Tensor) | ||
| ) | ||
|
|
@@ -122,7 +123,7 @@ def _model_structure(modules: dict[str, nn.Module]) -> list[dict[str, object]]: | |
|
|
||
|
|
||
| def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]: | ||
| """Serialize decay parameters at canonical BF16 precision for dtype-stable hashing.""" | ||
| """Serialize a compact BF16-canonicalized calibration snapshot for provenance.""" | ||
| return [ | ||
| { | ||
| "name": name, | ||
|
|
@@ -275,12 +276,11 @@ def validate_dasc_model_structure(model: nn.Module, policy: DASCPolicy) -> None: | |
|
|
||
|
|
||
| def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None: | ||
| """Reject deployment when current decay parameters no longer derive the stored policy.""" | ||
| modules = _get_gdn_modules(model) | ||
| actual = _canonical_sha256(_decay_parameters(modules)) | ||
| if actual != policy.decay_parameters_sha256: | ||
| raise ApplyModeError("DASC policy does not match the model's GDN decay parameters") | ||
| """Reject deployment when current decay parameters no longer derive the stored policy. | ||
|
|
||
| Numerical validation deliberately uses re-derived horizons and the exact selected mask rather | ||
| than the provenance digest because an FP16 or BF16 storage cast is lossy. | ||
| """ | ||
| current_horizons = analyze_gdn_decay( | ||
| model, | ||
| epsilon=policy.epsilon, | ||
|
|
||
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.
[IMPORTANT Compatibility] Save is now lenient about structural staleness, but
restore_dasc_modelis still strict — so this change lets ModelOpt write a checkpoint that can never be loaded again.Moving
validate_dasc_model_structureinside thetrymeansmto.save()succeeds with only a warning when the calibrated GDN geometry has changed (exactly whattest_structure_staleness_does_not_block_checkpoint_saveasserts). But the persisted metadata is the stale policy, andrestore_dasc_model(line 93) still callsvalidate_dasc_model_structureunconditionally and raisesApplyModeError. Sincerestore_from_modelopt_statereplays every mode in order and does not catch per-mode failures, that one raise aborts the whole restore — including any unrelated modes stored alongside DASC in the samemodelopt_state.Why it matters: the stated goal is that "a stale policy remains serializable so it cannot block saving or composing other ModelOpt modes." As written, DASC no longer blocks saving other modes, but it does block restoring them, and the failure now surfaces at load time (possibly a different machine, days later) instead of fail-fast at save. The new test only covers the save half — nothing asserts what happens when that checkpoint is read back.
Suggested fix — make restore consistent with save, and let
export_policyremain the single strict gate (it already is, perapi.py:86-87):If restore must stay strict instead, then the save-side warning should say the DASC entry will not restore, and a round-trip test (
mto.save→mto.restore) should pin whichever behavior you pick.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.
Addressed in #2380. Restore now attaches structurally stale policy metadata with an actionable warning, so a saved stale checkpoint remains recoverable. Export remains strict and rejects deployment until recalibration; the save/restore/export lifecycle is tested.