-
Notifications
You must be signed in to change notification settings - Fork 599
Align DASC analysis and lifecycle contracts #2389
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 |
|---|---|---|
|
|
@@ -28,7 +28,7 @@ | |
|
|
||
| from .config import DASCCalibrationMeasurement, DASCConfig, DASCPolicy | ||
| from .policy import ( | ||
| _DASCModelStructureMismatchError, | ||
| _DASCRecoverableStalenessError, | ||
| build_dasc_policy, | ||
| validate_dasc_decay_parameters, | ||
| validate_dasc_model_structure, | ||
|
|
@@ -98,7 +98,7 @@ def restore_dasc_model(model: nn.Module, config: DASCConfig, metadata: MetadataD | |
|
|
||
| try: | ||
| validate_dasc_model_structure(model, policy) | ||
| except _DASCModelStructureMismatchError as error: | ||
| except _DASCRecoverableStalenessError as error: | ||
| warnings.warn( | ||
| f"{error}. The restored DASC policy is stale; re-run calibrate() before deployment", | ||
| stacklevel=2, | ||
|
|
@@ -108,16 +108,23 @@ 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.""" | ||
| """Refresh metadata while allowing recoverable policy staleness to remain serializable.""" | ||
| policy = get_attached_dasc_policy(model) | ||
| try: | ||
| validate_dasc_model_structure(model, policy) | ||
| validate_dasc_decay_parameters(model, policy) | ||
| except ApplyModeError as error: | ||
| except _DASCRecoverableStalenessError as error: | ||
| warnings.warn( | ||
| f"{error}. The saved DASC policy is stale; re-run calibrate() before deployment", | ||
| stacklevel=2, | ||
| ) | ||
| else: | ||
| try: | ||
|
Comment on lines
113
to
+121
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] Narrowing the caught type here is the right call for the architecture-replacement case the new test covers, but it makes the save path fail closed on every non-structure-mismatch
I have not confirmed DASC is expected to be saved under FSDP, so this is plausible rather than demonstrated — but the fix is cheap and keeps the fail-closed guarantee the PR is after. Distinguish "architecture is gone" (fail closed, as tested) from "decay tensors are temporarily not materialized" (warn and stay serializable), e.g. by giving try:
validate_dasc_model_structure(model, policy)
except _DASCModelStructureMismatchError as error:
warnings.warn(
f"{error}. The saved DASC policy is stale; re-run calibrate() before deployment",
stacklevel=2,
)
else:
...with
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. Fixed in #2394. A supported GDN identity whose decay tensors are temporarily unavailable is now a typed recoverable-staleness condition on both save and restore. Removing/replacing the supported architecture remains a hard ApplyModeError. The lifecycle boundary is documented and covered by a save/restore/export regression. |
||
| validate_dasc_decay_parameters(model, policy) | ||
| except ApplyModeError as error: | ||
| warnings.warn( | ||
| f"{error}. The saved DASC policy is stale; re-run calibrate() before deployment", | ||
| stacklevel=2, | ||
| ) | ||
| metadata.clear() | ||
| metadata["policy"] = copy.deepcopy(policy.model_dump(mode="json")) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,25 +32,52 @@ | |
| from modelopt.torch.opt.dynamic import DynamicModule | ||
| from modelopt.torch.utils import unwrap_model | ||
|
|
||
| from .config import DASCCalibrationMeasurement, DASCConfig, DASCLayerPolicy, DASCPolicy | ||
| from .config import ( | ||
| DASCCalibrationMeasurement, | ||
| DASCConfig, | ||
| DASCLayerPolicy, | ||
| DASCPolicy, | ||
| _DecayParameterStorageDtype, | ||
| _validate_analysis_arguments, | ||
| ) | ||
|
|
||
| __all__ = ["analyze_gdn_decay", "compute_gdn_decay_horizons"] | ||
|
|
||
| _SUPPORTED_GDN_CLASS_PATHS = ( | ||
| ("megatron.core.ssm.gated_delta_net", "GatedDeltaNet"), | ||
| ("transformers.models.qwen3_next.modeling_qwen3_next", "Qwen3NextGatedDeltaNet"), | ||
| ) | ||
| _STORAGE_DTYPES = { | ||
| _STORAGE_DTYPES: dict[_DecayParameterStorageDtype, torch.dtype] = { | ||
| "float16": torch.float16, | ||
| "bfloat16": torch.bfloat16, | ||
| "float32": torch.float32, | ||
| } | ||
|
|
||
|
|
||
| class _DASCModelStructureMismatchError(ApplyModeError): | ||
| class _DASCRecoverableStalenessError(ApplyModeError): | ||
| """Identify DASC state that may become valid after model rematerialization.""" | ||
|
|
||
|
|
||
| class _DASCModelStructureMismatchError(_DASCRecoverableStalenessError): | ||
| """Identify recoverable policy-versus-GDN-geometry drift during restore.""" | ||
|
|
||
|
|
||
| class _DASCDecayParametersUnavailableError(_DASCRecoverableStalenessError): | ||
| """Identify supported GDN modules whose decay tensors are temporarily unavailable.""" | ||
|
|
||
|
|
||
| def _validate_gdn_decay_tensors(a_log: torch.Tensor, dt_bias: torch.Tensor) -> None: | ||
| """Reject decay tensors that cannot produce well-defined horizons.""" | ||
| if a_log.ndim != 1 or dt_bias.ndim != 1 or a_log.shape != dt_bias.shape or not a_log.numel(): | ||
| raise ValueError( | ||
| "GDN A_log and dt_bias must be non-empty one-dimensional tensors of equal shape" | ||
| ) | ||
| if not a_log.dtype.is_floating_point or not dt_bias.dtype.is_floating_point: | ||
| raise ValueError("GDN A_log and dt_bias must use floating-point dtypes") | ||
| if not torch.isfinite(a_log).all() or not torch.isfinite(dt_bias).all(): | ||
| raise ValueError("GDN decay parameters must be finite") | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _supported_gdn_classes() -> tuple[type[nn.Module], ...]: | ||
| """Resolve installed GDN implementations without making either framework mandatory.""" | ||
|
|
@@ -89,22 +116,13 @@ def compute_gdn_decay_horizons( | |
| static_gate_input: float = -0.3, | ||
| ) -> torch.Tensor: | ||
| """Compute one static retention horizon per GDN head in CPU float64.""" | ||
| if a_log.ndim != 1 or dt_bias.ndim != 1 or a_log.shape != dt_bias.shape or not a_log.numel(): | ||
| raise ValueError( | ||
| "GDN A_log and dt_bias must be non-empty one-dimensional tensors of equal shape" | ||
| ) | ||
| if not a_log.dtype.is_floating_point or not dt_bias.dtype.is_floating_point: | ||
| raise ValueError("GDN A_log and dt_bias must use floating-point dtypes") | ||
| if not 0.0 < epsilon < 1.0: | ||
| raise ValueError("epsilon must be in (0, 1)") | ||
|
|
||
| _validate_analysis_arguments(epsilon, static_gate_input) | ||
| _validate_gdn_decay_tensors(a_log, dt_bias) | ||
| a_log_cpu = a_log.detach().to(device="cpu", dtype=torch.float64) | ||
| dt_bias_cpu = dt_bias.detach().to(device="cpu", dtype=torch.float64) | ||
| if not torch.isfinite(a_log_cpu).all() or not torch.isfinite(dt_bias_cpu).all(): | ||
| raise ValueError("GDN decay parameters must be finite") | ||
|
|
||
| decay = -torch.exp(a_log_cpu) * F.softplus(dt_bias_cpu + static_gate_input) | ||
| horizons = torch.log(torch.tensor(epsilon, dtype=torch.float64)) / decay | ||
| horizons = math.log(epsilon) / decay | ||
| if not torch.isfinite(horizons).all() or not torch.all(horizons > 0): | ||
| raise ValueError("GDN decay parameters produced non-finite or non-positive horizons") | ||
| return horizons | ||
|
|
@@ -132,7 +150,7 @@ def _reject_incomplete_gdn_modules(identity_modules: list[tuple[str, nn.Module]] | |
| ) | ||
| ] | ||
| if missing_decay_parameters: | ||
| raise ApplyModeError( | ||
| raise _DASCDecayParametersUnavailableError( | ||
| "DASC found supported GDN modules without A_log and dt_bias tensors at: " | ||
| f"{', '.join(missing_decay_parameters)}" | ||
| ) | ||
|
|
@@ -191,9 +209,8 @@ def _analyze_gdn_modules( | |
| a_log = module.A_log | ||
| dt_bias = module.dt_bias | ||
| try: | ||
| if not a_log.dtype.is_floating_point or not dt_bias.dtype.is_floating_point: | ||
| raise ValueError("GDN A_log and dt_bias must use floating-point dtypes") | ||
| if storage_dtype is not None: | ||
| _validate_gdn_decay_tensors(a_log, dt_bias) | ||
|
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 Every path through this loop reaches Either drop the call and let the single validator in
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. Fixed in #2394. _analyze_gdn_modules() validates the live decay tensors before any checkpoint-storage cast, so casting cannot hide an invalid integer source dtype; compute_gdn_decay_horizons still validates the storage-canonical tensors after casting. |
||
| a_log = a_log.detach().to(device="cpu", dtype=storage_dtype) | ||
| dt_bias = dt_bias.detach().to(device="cpu", dtype=storage_dtype) | ||
| layer_horizons = compute_gdn_decay_horizons( | ||
|
|
@@ -215,10 +232,24 @@ def analyze_gdn_decay( | |
| *, | ||
| epsilon: float = 1e-3, | ||
| static_gate_input: float = -0.3, | ||
| decay_parameter_storage_dtype: _DecayParameterStorageDtype | None = None, | ||
| ) -> dict[str, list[float]]: | ||
| """Return deterministic per-head horizons for every GDN module in a model.""" | ||
| """Return per-head horizons, optionally canonicalized to a checkpoint storage dtype.""" | ||
| _validate_analysis_arguments(epsilon, static_gate_input) | ||
| storage_dtype = None | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if decay_parameter_storage_dtype is not None: | ||
| if ( | ||
| not isinstance(decay_parameter_storage_dtype, str) | ||
| or decay_parameter_storage_dtype not in _STORAGE_DTYPES | ||
| ): | ||
| supported = ", ".join(_STORAGE_DTYPES) | ||
| raise ValueError(f"decay_parameter_storage_dtype must be one of: {supported}") | ||
| storage_dtype = _STORAGE_DTYPES[decay_parameter_storage_dtype] | ||
| return _analyze_gdn_modules( | ||
| _get_gdn_modules(model), epsilon=epsilon, static_gate_input=static_gate_input | ||
| _get_gdn_modules(model), | ||
| epsilon=epsilon, | ||
| static_gate_input=static_gate_input, | ||
| storage_dtype=storage_dtype, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -259,11 +290,28 @@ def _decay_parameters( | |
| ] | ||
|
|
||
|
|
||
| def _dtype_exactly_contains(source: torch.dtype, target: torch.dtype) -> bool: | ||
| """Return whether every finite source value is exactly representable in the target dtype.""" | ||
| source_info = torch.finfo(source) | ||
| target_info = torch.finfo(target) | ||
| return ( | ||
| target_info.max >= source_info.max | ||
| and target_info.eps <= source_info.eps | ||
| and target_info.tiny * target_info.eps <= source_info.tiny * source_info.eps | ||
| ) | ||
|
|
||
|
|
||
| def _storage_rounding_radius(tensor: torch.Tensor, storage_dtype: torch.dtype) -> torch.Tensor: | ||
| """Compose inverse error bounds for storage and live-dtype materialization casts.""" | ||
| values = tensor.detach().to(device="cpu", dtype=torch.float64).abs() | ||
| upper = values | ||
| cast_dtypes = tuple(dict.fromkeys((storage_dtype, tensor.dtype))) | ||
| live_dtype = tensor.dtype | ||
| if _dtype_exactly_contains(live_dtype, storage_dtype): | ||
| cast_dtypes = (live_dtype,) | ||
| elif _dtype_exactly_contains(storage_dtype, live_dtype): | ||
| cast_dtypes = (storage_dtype,) | ||
| else: | ||
| cast_dtypes = (storage_dtype, live_dtype) | ||
| for dtype in reversed(cast_dtypes): | ||
| dtype_info = torch.finfo(dtype) | ||
| unit_roundoff = dtype_info.eps / 2.0 | ||
|
|
@@ -279,7 +327,7 @@ def _storage_cast_horizon_bounds( | |
| static_gate_input: float, | ||
| storage_dtype: torch.dtype, | ||
| ) -> tuple[torch.Tensor, torch.Tensor]: | ||
| """Bound horizons compatible with the current parameters before one storage cast.""" | ||
| """Bound horizons compatible with current parameters under storage and live-dtype casts.""" | ||
| 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, storage_dtype) | ||
|
|
@@ -447,8 +495,12 @@ def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None | |
| modules = _get_gdn_modules(model) | ||
| for name, module in modules.items(): | ||
| layer = policy.layers[name] | ||
| if not module.A_log.dtype.is_floating_point or not module.dt_bias.dtype.is_floating_point: | ||
| raise ApplyModeError("GDN A_log and dt_bias must use floating-point dtypes") | ||
| try: | ||
| _validate_gdn_decay_tensors(module.A_log, module.dt_bias) | ||
| except ValueError as error: | ||
| raise ApplyModeError( | ||
| f"Invalid GDN decay parameters in module {name!r}: {error}" | ||
| ) from error | ||
| lower, upper = _storage_cast_horizon_bounds( | ||
| module, | ||
| epsilon=policy.epsilon, | ||
|
|
@@ -468,7 +520,7 @@ def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None | |
| "DASC policy head mask does not match current decay parameters in layer " | ||
| f"{name!r}" | ||
| ) | ||
| stored = torch.tensor(layer.static_horizons, dtype=torch.float64) | ||
| stored = torch.tensor(layer.static_horizons, device="cpu", 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) | ||
|
|
||
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] The documented recipe only forwards one of the three knobs that determine calibration geometry, so it silently diverges from
calibrate()for any non-default config.build_dasc_policycomputes its horizons via_analyze_gdn_modules(..., epsilon=config.epsilon, static_gate_input=config.static_gate_input, storage_dtype=_STORAGE_DTYPES[config.decay_parameter_storage_dtype])(modelopt/torch/sparsity/state_sparsity/policy.py:381-385).analyze_gdn_decayre-declares its own defaultsepsilon=1e-3/static_gate_input=-0.3, which happen to matchDASCConfig's defaults — so this snippet is only correct as long as the caller never overridesepsilonorstatic_gate_input. Both are public, documentedDASCConfigfields.Why it matters: a caller who sets e.g.
"static_gate_input": 0.0and follows this snippet verbatim derives their evaluated head mask andretained_headsfrom different horizons than the policy will encode._validate_measurement_geometry(policy.py:329-345) only compares aggregate counts, not per-head identity, so the mismatch is not reliably caught — whenever the two head sets happen to have the same cardinality,calibrate()accepts the measurements and ships a policy whoseretained_headsmask was never the one the quality evidence was measured on. That is exactly the class of drift the rest of this PR is trying to close. When the counts do differ, the error message points at the caller's measurements rather than at the two omitted kwargs, which is a confusing failure for the documented path.Suggested fix — forward all three in the snippet (and say so in the prose at lines 192-193):
Restating the defaults at the call site is itself fragile. Since the stated goal is "derive exactly the geometry consumed by calibration", the more robust option is to let the public analysis entry point take the config as its single source of truth — e.g. an overload/helper
analyze_gdn_decay(model, config=config)that pullsepsilon,static_gate_input, anddecay_parameter_storage_dtypeoff the validatedDASCConfig— so a caller cannot get partway there. That would also remove the need for callers to know which subset of config fields feeds the horizon computation.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.
Valid. Fixed in #2391. The documented config now makes epsilon and static_gate_input explicit and forwards them, along with decay_parameter_storage_dtype, to analyze_gdn_decay. The prose calls out all three as the measurement-geometry contract.