-
Notifications
You must be signed in to change notification settings - Fork 609
Harden DASC policy identity and restore #2380
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 |
|---|---|---|
|
|
@@ -16,23 +16,42 @@ | |
| """Decay analysis and policy selection for GDN state sparsity.""" | ||
|
|
||
| import hashlib | ||
| import importlib | ||
| import json | ||
| import math | ||
| from collections.abc import Iterable | ||
| from functools import lru_cache | ||
|
|
||
| import torch | ||
| import torch.nn.functional as F | ||
| from torch import nn | ||
|
|
||
| from modelopt.torch.opt.conversion import ApplyModeError | ||
| from modelopt.torch.opt.dynamic import DynamicModule | ||
| from modelopt.torch.utils import unwrap_model | ||
|
|
||
| from .config import DASCCalibrationMeasurement, DASCConfig, DASCLayerPolicy, DASCPolicy | ||
|
|
||
| __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 | ||
| _SUPPORTED_GDN_CLASS_PATHS = ( | ||
| ("megatron.core.ssm.gated_delta_net", "GatedDeltaNet"), | ||
| ("transformers.models.qwen3_next.modeling_qwen3_next", "Qwen3NextGatedDeltaNet"), | ||
| ) | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _supported_gdn_classes() -> tuple[type[nn.Module], ...]: | ||
| """Resolve installed GDN implementations without making either framework mandatory.""" | ||
| classes = [] | ||
| for module_name, class_name in _SUPPORTED_GDN_CLASS_PATHS: | ||
| try: | ||
| candidate = getattr(importlib.import_module(module_name), class_name) | ||
| except (AttributeError, ImportError): | ||
| continue | ||
| if isinstance(candidate, type) and issubclass(candidate, nn.Module): | ||
| classes.append(candidate) | ||
| return tuple(classes) | ||
|
|
||
|
|
||
| def compute_gdn_decay_horizons( | ||
|
|
@@ -64,8 +83,14 @@ def compute_gdn_decay_horizons( | |
|
|
||
| def _is_gdn_module(module: nn.Module) -> bool: | ||
| """Accept supported GDN implementations and their ModelOpt dynamic subclasses.""" | ||
| supported_classes = _supported_gdn_classes() | ||
| module_class = type(module) | ||
| is_supported_class = module_class in supported_classes or ( | ||
| isinstance(module, DynamicModule) | ||
| and any(base in supported_classes for base in module_class.__mro__) | ||
| ) | ||
| return ( | ||
| any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in type(module).__mro__) | ||
| is_supported_class | ||
| and isinstance(getattr(module, "A_log", None), torch.Tensor) | ||
| and isinstance(getattr(module, "dt_bias", None), torch.Tensor) | ||
| ) | ||
|
Comment on lines
84
to
96
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] Requiring Real cases that hit this: a Consider distinguishing the two cases in the error — e.g. have
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 #2382. If an ordinary subclass has a supported GDN identity in its MRO but is not a ModelOpt DynamicModule, the failure now identifies its module path and explains that an exact supported class is required. |
||
|
|
@@ -76,7 +101,7 @@ def _get_gdn_modules(model: nn.Module) -> dict[str, nn.Module]: | |
| model = unwrap_model(model, force_unwrap=True) | ||
| modules = {name: module for name, module in model.named_modules() if _is_gdn_module(module)} | ||
| if not modules: | ||
| supported = ", ".join(sorted(_SUPPORTED_GDN_CLASS_NAMES)) | ||
| supported = ", ".join(class_name for _, class_name in _SUPPORTED_GDN_CLASS_PATHS) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| raise ApplyModeError(f"DASC found no supported GDN modules; expected one of: {supported}") | ||
| return dict(sorted(modules.items())) | ||
|
|
||
|
|
@@ -140,6 +165,33 @@ def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]: | |
| ] | ||
|
|
||
|
|
||
| def _storage_rounding_radius(tensor: torch.Tensor) -> torch.Tensor: | ||
| """Bound one cast-to-storage rounding step around the represented tensor values.""" | ||
| values = tensor.detach().to(device="cpu", dtype=torch.float64) | ||
| dtype_info = torch.finfo(tensor.dtype) | ||
| unit_roundoff = dtype_info.eps / 2.0 | ||
| subnormal_slack = dtype_info.tiny * dtype_info.eps | ||
| return values.abs() * (unit_roundoff / (1.0 - unit_roundoff)) + subnormal_slack | ||
|
Comment on lines
+168
to
+174
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 rounding radius is derived from the tensor's current dtype, so the tolerance collapses to ~0 exactly in the round-trip this replacement was meant to survive.
Now the parameters are FP32 tensors holding BF16-rounded values. The file's own provenance digest already encodes the intended tolerance model — _STORAGE_CAST_DTYPES = (torch.float16, torch.bfloat16)
def _storage_rounding_radius(tensor: torch.Tensor) -> torch.Tensor:
"""Bound one cast-to-storage rounding step around the represented tensor values."""
values = tensor.detach().to(device="cpu", dtype=torch.float64).abs()
radius = torch.zeros_like(values)
for dtype in (tensor.dtype, *_STORAGE_CAST_DTYPES):
dtype_info = torch.finfo(dtype)
unit_roundoff = dtype_info.eps / 2.0
radius = torch.maximum(
radius,
values * (unit_roundoff / (1.0 - unit_roundoff)) + dtype_info.tiny * dtype_info.eps,
)
return radius(
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 #2382. The rounding interval now takes the elementwise maximum over the current dtype, FP16, and BF16, so a BF16 storage round trip remains valid after reload into FP32. A regression test performs exactly FP32 calibration to BF16 storage rounding to FP32 reload and confirms policy export. |
||
|
|
||
|
|
||
| def _storage_cast_horizon_bounds( | ||
| module: nn.Module, *, epsilon: float, static_gate_input: float | ||
| ) -> tuple[torch.Tensor, torch.Tensor]: | ||
| """Bound horizons compatible with the current parameters before one storage cast.""" | ||
| a_log = module.A_log.detach().to(device="cpu", dtype=torch.float64) | ||
| dt_bias = module.dt_bias.detach().to(device="cpu", dtype=torch.float64) | ||
| a_radius = _storage_rounding_radius(module.A_log) | ||
| dt_radius = _storage_rounding_radius(module.dt_bias) | ||
| scale = -math.log(epsilon) | ||
| lower = scale / ( | ||
| torch.exp(a_log + a_radius) * F.softplus(dt_bias + dt_radius + static_gate_input) | ||
| ) | ||
| upper = scale / ( | ||
| torch.exp(a_log - a_radius) * F.softplus(dt_bias - dt_radius + static_gate_input) | ||
| ) | ||
| return lower, upper | ||
|
|
||
|
|
||
| def _validate_measurement_coverage( | ||
| config: DASCConfig, measurements: list[DASCCalibrationMeasurement] | ||
| ) -> None: | ||
|
|
@@ -286,19 +338,24 @@ def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None | |
| epsilon=policy.epsilon, | ||
| static_gate_input=policy.static_gate_input, | ||
| ) | ||
| modules = _get_gdn_modules(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] Resolving once and passing the dict down (e.g. an internal
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 #2382. Module discovery is now performed once, with an internal analysis helper accepting the resolved module mapping. Policy construction and validation no longer repeat named_modules traversal or class resolution. |
||
| for name, values in current_horizons.items(): | ||
| layer = policy.layers[name] | ||
| if not torch.allclose( | ||
| torch.tensor(values, dtype=torch.float64), | ||
| torch.tensor(layer.static_horizons, dtype=torch.float64), | ||
| rtol=_HORIZON_DTYPE_CAST_RTOL, | ||
| atol=0.0, | ||
| ): | ||
| raise ApplyModeError( | ||
| f"DASC policy horizons do not match current decay parameters in layer {name!r}" | ||
| ) | ||
| retained = [head for head, horizon in enumerate(values) if horizon > policy.selected_wmax] | ||
| if retained != layer.retained_heads: | ||
| raise ApplyModeError( | ||
| f"DASC policy head mask does not match current decay parameters in layer {name!r}" | ||
| ) | ||
| lower, upper = _storage_cast_horizon_bounds( | ||
| modules[name], | ||
| epsilon=policy.epsilon, | ||
| static_gate_input=policy.static_gate_input, | ||
| ) | ||
| stored = torch.tensor(layer.static_horizons, dtype=torch.float64) | ||
| numerical_slack = 32.0 * torch.finfo(torch.float64).eps | ||
| if torch.any(stored < lower * (1.0 - numerical_slack)) or torch.any( | ||
| stored > upper * (1.0 + numerical_slack) | ||
| ): | ||
| raise ApplyModeError( | ||
| f"DASC policy horizons do not match current decay parameters in layer {name!r}" | ||
| ) | ||
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 ModeState] Resolution failures are silent and the real paths are never exercised, so a wrong/moved module path degrades into a misleading user-facing error.
Two coupled problems:
Silent skip.
except (AttributeError, ImportError): continueswallows a path that no longer exists._supported_gdn_classes()then returns()(or a partial tuple),_is_gdn_modulereturnsFalsefor every module, and the user sees"DASC found no supported GDN modules; expected one of: GatedDeltaNet, Qwen3NextGatedDeltaNet"on a model that does containGatedDeltaNet. That message actively misdirects debugging. The previous name-based check was immune to module-path drift;megatron.corein particular reorganizes submodules across releases, andtransformers' modular-model tooling relocates/duplicates modeling modules.No automated guard. The autouse
_register_test_gdn_classfixture monkeypatches_supported_gdn_classesin every test, andtest_supported_class_resolution_uses_imported_module_identitiesmonkeypatches_SUPPORTED_GDN_CLASS_PATHSto synthetic modules. So no test ever asserts that either entry in_SUPPORTED_GDN_CLASS_PATHSresolves against a real install — the only validation is the manual CPU smoke run in the PR description, which won't catch a future dependency bump.Also note
except (AttributeError, ImportError)is narrower than this repo's optional-dependency convention (modelopt.torch.utils.import_plugin, which catchesModuleNotFoundErrorand then broadException). A Megatron/TE import that raisesOSErrororRuntimeErroron a partially-configured install currently propagates out of_is_gdn_moduleand breaks DASC for transformers-only users.Suggested fix: broaden the catch and make the miss visible, e.g.
and add a test that skips rather than fakes, so the real identity is checked wherever the dependency exists:
(that test needs to opt out of the autouse fixture, or read
_SUPPORTED_GDN_CLASS_PATHSdirectly as written above).Separately,
lru_cachecaches a failed resolution for the whole process; with the warning above at least the user learns why every subsequent DASC call fails.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 #2382. Missing optional frameworks remain silent, but resolution failures for installed frameworks now warn with the fully qualified path and exception. The tests cover missing, broken, invalid, and valid resolution, and separately verify each declared real path wherever its framework is installed.