-
Notifications
You must be signed in to change notification settings - Fork 595
Address DASC policy review feedback #2377
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,13 +16,15 @@ | |
| """ModelOpt conversion and restoration for DASC policy metadata.""" | ||
|
|
||
| import copy | ||
| import warnings | ||
| from collections.abc import Iterable | ||
|
|
||
| from pydantic import ValidationError | ||
| from torch import nn | ||
|
|
||
| from modelopt.torch.opt.conversion import ApplyModeError | ||
| from modelopt.torch.opt.mode import ConvertReturnType, MetadataDict | ||
| from modelopt.torch.utils import unwrap_model | ||
|
|
||
| from .config import DASCCalibrationMeasurement, DASCConfig, DASCPolicy | ||
| from .policy import build_dasc_policy, validate_dasc_decay_parameters, validate_dasc_model_structure | ||
|
|
@@ -33,16 +35,22 @@ | |
|
|
||
|
|
||
| def _attach_policy(model: nn.Module, policy: DASCPolicy) -> None: | ||
| """Attach a JSON-safe DASC policy to an unwrapped model.""" | ||
| setattr(model, _DASC_POLICY_ATTRIBUTE, policy.model_dump(mode="json")) | ||
|
|
||
|
|
||
| def convert_dasc_model( | ||
| model: nn.Module, | ||
| config: DASCConfig, | ||
| *, | ||
| measurements: Iterable[DASCCalibrationMeasurement | dict], | ||
| measurements: Iterable[DASCCalibrationMeasurement | dict] | None = None, | ||
| ) -> ConvertReturnType: | ||
| """Analyze GDN decay and attach the selected DASC policy without changing execution.""" | ||
| if measurements is None: | ||
| raise ApplyModeError( | ||
| "DASC requires calibration measurements; use " | ||
| "modelopt.torch.sparsity.state_sparsity.calibrate(model, config, measurements)" | ||
| ) | ||
| policy = build_dasc_policy(model, config, measurements) | ||
| _attach_policy(model, policy) | ||
| return model, {"policy": policy.model_dump(mode="json")} | ||
|
|
@@ -88,19 +96,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 serialized metadata from the immutable attached DASC policy.""" | ||
| try: | ||
| policy = DASCPolicy(**getattr(model, _DASC_POLICY_ATTRIBUTE)) | ||
| except (AttributeError, TypeError, ValidationError) as error: | ||
| raise ApplyModeError("Model has no valid attached DASC policy") from error | ||
| """Refresh metadata without making unrelated ModelOpt save or compose paths unusable.""" | ||
| policy = get_attached_dasc_policy(model) | ||
| validate_dasc_model_structure(model, policy) | ||
|
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 ModeState] The docstring now promises to "refresh metadata without making unrelated ModelOpt save or compose paths unusable", and the decay-parameter check was correctly demoted to a warning. But the structure check on this line is outside the try/except and still raises Why it matters — the raise is reachable without the user doing anything wrong:
A stale policy should never be able to destroy a checkpoint containing other modes' state. Suggested fix — treat structure staleness the same way as decay staleness: warn on save, stay strict on export. 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)
try:
validate_dasc_model_structure(model, policy)
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"))
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 signed commit cfd7053 via #2379. Both structure and decay validation now run inside the non-fatal stale-policy handler used by save and composition; |
||
| validate_dasc_decay_parameters(model, policy) | ||
| try: | ||
| 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")) | ||
|
|
||
|
|
||
| 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) | ||
| try: | ||
| return DASCPolicy(**getattr(model, _DASC_POLICY_ATTRIBUTE)) | ||
| except (AttributeError, TypeError, ValidationError) as error: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,11 +48,6 @@ def config_class(self) -> type[ModeloptBaseConfig]: | |
| """Return the validated DASC configuration class.""" | ||
| return DASCConfig | ||
|
|
||
| @property | ||
| def next_prohibited_modes(self) -> set[str]: | ||
|
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 ModeState] Dropping
Why it matters:
The test only recalibrates once with an identical config, so none of this shows up. Suggested fix — supersede in place rather than append. Keep the guard and 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 signed commit cfd7053 via #2379. The repeat-mode prohibition is restored. Public |
||
| """Prevent applying DASC twice to the same model state.""" | ||
| return {"dasc"} | ||
|
|
||
| @property | ||
| def convert(self) -> ConvertEntrypoint: | ||
| """Return the DASC calibration entrypoint.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,11 +24,15 @@ | |
| from torch import nn | ||
|
|
||
| from modelopt.torch.opt.conversion import ApplyModeError | ||
| 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"}) | ||
| _HORIZON_DTYPE_CAST_RTOL = 0.05 | ||
|
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 name
That's not a correctness hole, but the constant reads as though a 5% horizon drift were tolerated, which is misleading for anyone tuning it later. Consider deriving it from the canonicalization dtype and saying so, e.g.: # Residual slack after the exact BF16-canonicalized parameter hash matches: only
# sub-roundoff differences from the caller's storage dtype can reach the comparison.
_HORIZON_DTYPE_CAST_RTOL = 8 * torch.finfo(torch.bfloat16).epsThe genuinely load-bearing check in this function is 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 by the validation change in signed commit cfd7053 via #2379. The exact BF16 digest no longer dominates export validation, so the 5 percent horizon tolerance now genuinely absorbs ordinary FP16/BF16 storage casts. The constant comment states that the exact retained-head mask is the semantic gate, and both dtype paths are covered by tests. |
||
|
|
||
|
|
||
| def compute_gdn_decay_horizons( | ||
| a_log: torch.Tensor, | ||
|
|
@@ -58,20 +62,21 @@ def compute_gdn_decay_horizons( | |
|
|
||
|
|
||
| def _is_gdn_module(module: nn.Module) -> bool: | ||
| class_name = "".join( | ||
| character for character in type(module).__name__.lower() if character.isalnum() | ||
| ) | ||
| """Return whether a module has one of the explicitly supported GDN implementations.""" | ||
| return ( | ||
|
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. [CRITICAL ModeState] Exact
@DMRegistry.register({GatedDeltaNet: "megatron.core.ssm.gated_delta_net.GatedDeltaNet"})
class _DynamicGatedDeltaNet(_DynamicAttention):
Why it matters — the previous normalized-substring match (
Nothing in Suggested fix — keep fail-closed semantics but resolve through the MRO so dynamic subclasses of a supported base still match, e.g.: def _is_gdn_module(module: nn.Module) -> bool:
"""Return whether a module has one of the explicitly supported GDN implementations."""
return (
any(base.__name__ in _SUPPORTED_GDN_CLASS_NAMES for base in type(module).__mro__)
and isinstance(getattr(module, "A_log", None), torch.Tensor)
and isinstance(getattr(module, "dt_bias", None), torch.Tensor)
)This still rejects unrelated implementations that merely expose similarly named decay tensors (the documented goal), while accepting
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 signed commit cfd7053 via #2379. Supported implementations are now recognized through the class MRO, which accepts ModelOpt synthesized |
||
| "gateddeltanet" in class_name | ||
| type(module).__name__ in _SUPPORTED_GDN_CLASS_NAMES | ||
| and isinstance(getattr(module, "A_log", None), torch.Tensor) | ||
| and isinstance(getattr(module, "dt_bias", None), torch.Tensor) | ||
| ) | ||
|
|
||
|
|
||
| def _get_gdn_modules(model: nn.Module) -> dict[str, nn.Module]: | ||
| """Find supported GDN layers after removing a recognized model wrapper.""" | ||
| 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: | ||
| raise ApplyModeError("DASC found no GatedDeltaNet modules; only GDN is supported") | ||
| supported = ", ".join(sorted(_SUPPORTED_GDN_CLASS_NAMES)) | ||
| raise ApplyModeError(f"DASC found no supported GDN modules; expected one of: {supported}") | ||
| return dict(sorted(modules.items())) | ||
|
|
||
|
|
||
|
|
@@ -100,11 +105,13 @@ def analyze_gdn_decay( | |
|
|
||
|
|
||
| def _canonical_sha256(value: object) -> str: | ||
| """Hash a JSON value with deterministic ordering and no non-finite numbers.""" | ||
| payload = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) | ||
| return hashlib.sha256(payload.encode()).hexdigest() | ||
|
|
||
|
|
||
| def _model_structure(modules: dict[str, nn.Module]) -> list[dict[str, object]]: | ||
| """Describe the layer names and head counts that define policy geometry.""" | ||
| return [ | ||
| { | ||
| "name": name, | ||
|
|
@@ -115,11 +122,18 @@ 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.""" | ||
| return [ | ||
| { | ||
| "name": name, | ||
| "A_log": module.A_log.detach().to(device="cpu", dtype=torch.float64).tolist(), | ||
| "dt_bias": module.dt_bias.detach().to(device="cpu", dtype=torch.float64).tolist(), | ||
| "A_log": module.A_log.detach() | ||
| .to(device="cpu", dtype=torch.bfloat16) | ||
| .to(dtype=torch.float32) | ||
| .tolist(), | ||
| "dt_bias": module.dt_bias.detach() | ||
| .to(device="cpu", dtype=torch.bfloat16) | ||
| .to(dtype=torch.float32) | ||
| .tolist(), | ||
| } | ||
|
Comment on lines
124
to
137
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] BF16 canonicalization only stabilizes the hash for BF16/FP32 — an FP16 model still gets a false "does not match" rejection. The canonicalization works because Why it matters — FP16 is a first-class inference dtype ( Suggested fix — canonicalize onto a grid that is independent of the storage dtype rather than onto one specific float format. The cheapest version, given _DECAY_HASH_DECIMALS = 3 # coarser than bf16 unit roundoff (2**-9 ~= 2e-3)
def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]:
"""Serialize decay parameters on a dtype-independent decimal grid for stable hashing."""
return [
{
"name": name,
"A_log": [
round(value, _DECAY_HASH_DECIMALS)
for value in module.A_log.detach().to(device="cpu", dtype=torch.float64).tolist()
],
"dt_bias": [
round(value, _DECAY_HASH_DECIMALS)
for value in module.dt_bias.detach().to(device="cpu", dtype=torch.float64).tolist()
],
}
for name, module in modules.items()
](Rounding on a decimal grid has its own tie-boundary caveat, but it is dtype-agnostic, so BF16, FP16 and FP32 all land on the same value.) Either way, the safety here really comes from the new horizon-equivalence and retained-mask checks 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. Addressed in signed commit cfd7053 via #2379. The exact BF16 digest is now explicitly calibration provenance, not the deployment-validity gate. Export re-derives numerical horizons with cast tolerance and exact-compares the selected retained-head mask, which is the runtime-relevant contract. The dtype regression is parameterized over both BF16 and FP16. |
||
| for name, module in modules.items() | ||
| ] | ||
|
|
@@ -128,6 +142,7 @@ def _decay_parameters(modules: dict[str, nn.Module]) -> list[dict[str, object]]: | |
| def _validate_measurement_coverage( | ||
| config: DASCConfig, measurements: list[DASCCalibrationMeasurement] | ||
| ) -> None: | ||
| """Require exactly one matching measurement for every configured window.""" | ||
| measured = [measurement.wmax for measurement in measurements] | ||
| unexpected = sorted(set(measured) - set(config.wmax_candidates)) | ||
| missing = sorted(set(config.wmax_candidates) - set(measured)) | ||
|
|
@@ -145,6 +160,7 @@ def _validate_measurement_coverage( | |
| def _validate_measurement_geometry( | ||
| horizons: dict[str, list[float]], measurements: list[DASCCalibrationMeasurement] | ||
| ) -> None: | ||
| """Bind caller-reported retained and total head counts to the analyzed model.""" | ||
| total_heads = sum(len(layer_horizons) for layer_horizons in horizons.values()) | ||
| for measurement in measurements: | ||
| retained_heads = sum( | ||
|
|
@@ -161,6 +177,7 @@ def _validate_measurement_geometry( | |
|
|
||
|
|
||
| def _candidate_passes(config: DASCConfig, measurement: DASCCalibrationMeasurement) -> bool: | ||
| """Return whether one candidate passes every configured evidence gate.""" | ||
| return measurement.checkpoint_savings >= config.min_checkpoint_savings and all( | ||
| result.perplexity_retention >= config.min_perplexity_retention | ||
| and result.top1_agreement >= config.min_top1_agreement | ||
|
|
@@ -245,14 +262,43 @@ def build_dasc_policy( | |
| def validate_dasc_model_structure(model: nn.Module, policy: DASCPolicy) -> None: | ||
| """Reject restoring a policy onto a different GDN module structure.""" | ||
| modules = _get_gdn_modules(model) | ||
| actual = _canonical_sha256(_model_structure(modules)) | ||
| if actual != policy.model_structure_sha256: | ||
| actual_structure = _model_structure(modules) | ||
| policy_structure = [ | ||
| {"name": name, "num_heads": layer.num_heads} | ||
| for name, layer in sorted(policy.layers.items()) | ||
| ] | ||
| if ( | ||
| actual_structure != policy_structure | ||
| or _canonical_sha256(actual_structure) != policy.model_structure_sha256 | ||
| ): | ||
| raise ApplyModeError("DASC policy does not match the model's GDN module structure") | ||
|
|
||
|
|
||
| def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None: | ||
| """Reject exporting a policy for different GDN decay parameters.""" | ||
| """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") | ||
|
|
||
| current_horizons = analyze_gdn_decay( | ||
| model, | ||
| epsilon=policy.epsilon, | ||
| static_gate_input=policy.static_gate_input, | ||
| ) | ||
| 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}" | ||
| ) | ||
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.
[SUGGESTION]
extra="forbid"andvalidate_assignment=Trueare already inherited — onlyprotected_namespaces=()is new.ModeloptBaseConfigsets exactly those two (modelopt/torch/opt/config.py:74):Pydantic v2 merges a subclass's
model_configinto the parent's, so re-stating them here is a no-op today but silently pins DASC to the current values — if the base class ever changes one,DASCConfig/DASCPolicywon't follow, and the divergence is invisible at the call site. Narrowing it to just the new setting keeps the inheritance intact and makes the Pydantic-compat intent obvious:Same on line 198 for
DASCPolicy.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 signed commit cfd7053 via #2379. Both subclasses now set only
ConfigDict(protected_namespaces=()), soextraandvalidate_assignmentcontinue to inherit fromModeloptBaseConfig.