Skip to content
Closed
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
97 changes: 74 additions & 23 deletions modelopt/torch/sparsity/state_sparsity/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import importlib
import json
import math
import warnings
from collections.abc import Iterable
from functools import lru_cache

Expand All @@ -38,6 +39,7 @@
("megatron.core.ssm.gated_delta_net", "GatedDeltaNet"),
("transformers.models.qwen3_next.modeling_qwen3_next", "Qwen3NextGatedDeltaNet"),
)
_SUPPORTED_STORAGE_DTYPES = (torch.float16, torch.bfloat16)


@lru_cache(maxsize=1)
Expand All @@ -47,10 +49,26 @@ def _supported_gdn_classes() -> tuple[type[nn.Module], ...]:
for module_name, class_name in _SUPPORTED_GDN_CLASS_PATHS:
try:
candidate = getattr(importlib.import_module(module_name), class_name)
except (AttributeError, ImportError):
except ModuleNotFoundError as error:
root_module = module_name.partition(".")[0]
if importlib.util.find_spec(root_module) is None:

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] importlib.util is used here but only import importlib is at the top of the file (line 19). Accessing a submodule attribute off a package works only if something else already imported it — here it happens to work because modelopt/torch/__init__.py:19 does import importlib.util, and importing this module always executes that first. That's an invisible dependency: if that line in modelopt/torch/__init__.py is ever dropped, this raises AttributeError on the first ModuleNotFoundError path. modelopt/torch/speculative/utils.py:20 and modelopt/onnx/quantization/autotune/benchmark.py:30 use the explicit form.

Also worth hardening: find_spec itself can raise (ValueError when a name is in sys.modules with __spec__ is None, ImportError while resolving a parent). Raising from inside this except handler escapes _supported_gdn_classes() entirely, which contradicts the docstring's promise of resolving "without making either framework mandatory" — the graceful-degradation path becomes a hard failure. Wrapping the probe in try/except Exception and treating a failure as "not installed" keeps that contract.

Line 19 fix:

Suggested change
if importlib.util.find_spec(root_module) is None:
import importlib.util

(applied to the import line, not this one)

continue
warnings.warn(
f"DASC could not resolve {module_name}.{class_name}: {error!r}", stacklevel=2
)
continue
except Exception as error:
warnings.warn(
f"DASC could not resolve {module_name}.{class_name}: {error!r}", stacklevel=2
)
continue
if isinstance(candidate, type) and issubclass(candidate, nn.Module):
classes.append(candidate)
else:
warnings.warn(
f"DASC resolved {module_name}.{class_name}, but it is not an nn.Module class",
stacklevel=2,
)
return tuple(classes)


Expand Down Expand Up @@ -81,9 +99,8 @@ def compute_gdn_decay_horizons(
return horizons


def _is_gdn_module(module: nn.Module) -> bool:
def _is_gdn_module(module: nn.Module, supported_classes: tuple[type[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)
Expand All @@ -99,22 +116,37 @@ def _is_gdn_module(module: nn.Module) -> bool:
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)}
supported_classes = _supported_gdn_classes()
named_modules = list(model.named_modules())
modules = {
name: module for name, module in named_modules if _is_gdn_module(module, supported_classes)
}
if not modules:
supported = ", ".join(class_name for _, class_name in _SUPPORTED_GDN_CLASS_PATHS)
unsupported_subclasses = [
name or "<root>"
for name, module in named_modules
if not isinstance(module, DynamicModule)
and type(module) not in supported_classes
and any(base in supported_classes for base in type(module).__mro__[1:])
]
if unsupported_subclasses:
raise ApplyModeError(
"DASC found GDN subclasses that are not ModelOpt dynamic modules at: "
f"{', '.join(unsupported_subclasses)}; use an exact supported class"
)
supported = ", ".join(
f"{module_name}.{class_name}" for module_name, class_name in _SUPPORTED_GDN_CLASS_PATHS
)
raise ApplyModeError(f"DASC found no supported GDN modules; expected one of: {supported}")
Comment on lines 124 to 140

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 things about the new diagnostic split:

  1. The remediation text "use an exact supported class" is misleading — _is_gdn_module (line 105-108) also accepts any DynamicModule whose MRO contains a supported class, so an exact class is not the only valid option. Something like "convert the module with ModelOpt or use a supported class directly" matches the actual acceptance rule.

  2. There's a third failure mode that still lands in the generic "no supported GDN modules" branch: a module that is an exact supported class (or a proper dynamic subclass) but is missing the A_log/dt_bias tensors that _is_gdn_module also requires. The unsupported_subclasses filter excludes it (type(module) not in supported_classes is False), so the user is told no GDN module was found when one clearly was, just without the expected attributes. Since you're already paying for the named_modules() scan on this error path, a second bucket for "class matched but decay parameters are missing" would make that case self-explanatory.

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.

Addressed in #2383. The subclass message now says to convert with ModelOpt or use a supported class directly. A separate diagnostic detects an exact or ModelOpt-dynamic supported identity missing A_log or dt_bias tensors, with regression coverage.

return dict(sorted(modules.items()))


def analyze_gdn_decay(
model: nn.Module,
*,
epsilon: float = 1e-3,
static_gate_input: float = -0.3,
def _analyze_gdn_modules(
modules: dict[str, nn.Module], *, epsilon: float, static_gate_input: float
) -> dict[str, list[float]]:
"""Return deterministic per-head horizons for every GDN module in a model."""
"""Compute deterministic per-head horizons for already-resolved GDN modules."""
horizons = {}
for name, module in _get_gdn_modules(model).items():
for name, module in modules.items():
try:
layer_horizons = compute_gdn_decay_horizons(
module.A_log,
Expand All @@ -130,6 +162,18 @@ def analyze_gdn_decay(
return horizons


def analyze_gdn_decay(
model: nn.Module,
*,
epsilon: float = 1e-3,
static_gate_input: float = -0.3,
) -> dict[str, list[float]]:
"""Return deterministic per-head horizons for every GDN module in a model."""
return _analyze_gdn_modules(
_get_gdn_modules(model), epsilon=epsilon, static_gate_input=static_gate_input
)


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)
Expand Down Expand Up @@ -166,12 +210,19 @@ 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
"""Bound one FP16/BF16 storage cast even after values are reloaded in a wider dtype."""
if not tensor.dtype.is_floating_point:
raise ApplyModeError("DASC decay parameters must use a floating-point dtype")
Comment on lines +214 to +215

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 new floating-point guard sits too late in the validation order to be the error the user actually sees. In validate_dasc_decay_parameters, _analyze_gdn_modules runs first (line 388) and happily casts an integer A_log to float64, then the exact mask comparison at line 396 runs, and only after that does _storage_cast_horizon_bounds reach this check. For any integer-dtype parameter whose truncation shifts a horizon across selected_wmax, the user gets "DASC policy head mask does not match current decay parameters" — which points at the wrong cause.

tests/.../test_dasc.py (the new test_bf16_storage_round_trip_loaded_in_fp32_preserves_policy, pytest.raises(..., match="floating-point dtype")) only passes because that fixture's retained-head set happens to survive .to(torch.int64) truncation; it's order-dependent rather than testing the guard directly.

Suggest moving the dtype check up to where the parameters are first read — _analyze_gdn_modules or compute_gdn_decay_horizons — so it fires before any horizon math, and asserting it in the test against that entry point.

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.

Addressed in #2383. Floating-point dtype validation now occurs in compute_gdn_decay_horizons before conversion or mask derivation, so integer decay tensors produce the direct dtype error independent of the resulting mask.

values = tensor.detach().to(device="cpu", dtype=torch.float64).abs()
radius = torch.zeros_like(values)
for dtype in (tensor.dtype, *_SUPPORTED_STORAGE_DTYPES):
dtype_info = torch.finfo(dtype)
unit_roundoff = dtype_info.eps / 2.0
candidate = (
values * (unit_roundoff / (1.0 - unit_roundoff)) + dtype_info.tiny * dtype_info.eps
)
radius = torch.maximum(radius, candidate)
return radius
Comment on lines +216 to +225

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] Taking the max over (tensor.dtype, fp16, bf16) unconditionally applies the coarsest storage tolerance to every model, including ones that were never round-tripped through a narrow dtype. For an FP32 A_log/dt_bias, the relative radius goes from fp32_eps/2 ≈ 6e-8 to bf16_eps/2 ≈ 3.9e-3 — roughly a 65,000× widening of the acceptance band in _storage_cast_horizon_bounds.

Why it matters: the resulting [lower, upper] window on policy.static_horizons is the only check on the horizon values themselves (the head mask is checked exactly at line 396, and DASCPolicy cross-validates mask ↔ horizons at config.py:236-243, so behavior-changing tampering is still caught). But provenance-level drift is now tolerated up to ~0.5% of each horizon instead of ~1e-7. Concretely, tests/.../test_dasc.py:472 tampers a horizon by *= 1.01 and expects rejection — that test now clears the bound by only ~2×, where before the margin was ~5 orders of magnitude. It will silently stop being a meaningful tamper test if the fixture's A_log/dt_bias magnitudes ever change.

Suggested fix: record the storage dtype used at calibration in DASCPolicy (a new field with a permissive default keeps old JSON policies loadable) and apply only that dtype's radius plus the live tensor's own, instead of the union of all candidates.

Second, narrower point: the max-over-dtypes bounds a single cast. A checkpoint saved in FP16 and reloaded as BF16 incurs two lossy roundings (FP16 ULP/2 + BF16 ULP/2 ≈ 1.13× the BF16-only radius), so that path can be spuriously rejected — numerical_slack at line 406 (32·fp64_eps) is far too small to absorb it. The reverse direction (BF16 → FP16) is safe because a BF16 value is exactly representable in FP16. If you want to cover both, sum the current dtype's radius and the storage dtype's rather than taking the max.

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.

Addressed in #2383. DASCConfig and DASCPolicy now record decay_parameter_storage_dtype, defaulting to strict float32 for backward-compatible parsing. Bounds compose only that declared storage cast and any distinct live-dtype materialization cast. Provenance hashing uses the same declared dtype. Tests cover strict 1e-4 horizon tampering, BF16 to FP32 reload, FP16 to BF16, BF16 to FP16, and legacy metadata.



def _storage_cast_horizon_bounds(
Expand Down Expand Up @@ -261,8 +312,8 @@ def build_dasc_policy(
validated_measurements.sort(key=lambda measurement: measurement.wmax)

modules = _get_gdn_modules(model)
horizons = analyze_gdn_decay(
model, epsilon=config.epsilon, static_gate_input=config.static_gate_input
horizons = _analyze_gdn_modules(
modules, epsilon=config.epsilon, static_gate_input=config.static_gate_input
)
_validate_measurement_geometry(horizons, validated_measurements)

Expand Down Expand Up @@ -333,12 +384,12 @@ def validate_dasc_decay_parameters(model: nn.Module, policy: DASCPolicy) -> None
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,
modules = _get_gdn_modules(model)
current_horizons = _analyze_gdn_modules(
modules,
epsilon=policy.epsilon,
static_gate_input=policy.static_gate_input,
)
modules = _get_gdn_modules(model)
for name, values in current_horizons.items():
layer = policy.layers[name]
retained = [head for head, horizon in enumerate(values) if horizon > policy.selected_wmax]
Expand Down
57 changes: 53 additions & 4 deletions tests/unit/torch/sparsity/state_sparsity/test_dasc.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def __init__(self):
class UnsupportedSubclass(GatedDeltaNet):
pass

with pytest.raises(ApplyModeError, match="no supported GDN modules"):
with pytest.raises(ApplyModeError, match="subclasses that are not ModelOpt dynamic modules"):
mtss.calibrate(UnsupportedSubclass(), _config(wmax_candidates=[7]), [_candidate(7)])

same_name_lookalike = type("GatedDeltaNet", (UnsupportedGatedDeltaNet,), {})
Expand All @@ -263,21 +263,42 @@ def test_supported_class_resolution_uses_imported_module_identities(monkeypatch)
("valid", "GatedDeltaNet"),
("invalid", "NotAModule"),
("missing", "Missing"),
("installed", "Missing"),
("broken", "Broken"),
)
modules = {
"valid": type("ValidModule", (), {"GatedDeltaNet": GatedDeltaNet}),
"invalid": type("InvalidModule", (), {"NotAModule": object()}),
}

def import_module(name):
if name == "missing":
raise ImportError(name)
if name in {"missing", "installed"}:
raise ModuleNotFoundError(name)
if name == "broken":
raise RuntimeError(name)
return modules[name]

monkeypatch.setattr(dasc_policy, "_SUPPORTED_GDN_CLASS_PATHS", module_paths)
monkeypatch.setattr(dasc_policy.importlib, "import_module", import_module)
monkeypatch.setattr(
dasc_policy.importlib.util,
"find_spec",
lambda name: object() if name == "installed" else None,
)

assert _resolve_supported_gdn_classes() == (GatedDeltaNet,)
with pytest.warns(UserWarning) as caught:
assert _resolve_supported_gdn_classes() == (GatedDeltaNet,)
assert len(caught) == 3


@pytest.mark.parametrize(("module_name", "class_name"), dasc_policy._SUPPORTED_GDN_CLASS_PATHS)
def test_declared_gdn_paths_resolve_when_framework_is_installed(module_name, class_name):
"""Guard supported identities against upstream dependency path drift."""
root_module = module_name.partition(".")[0]
if dasc_policy.importlib.util.find_spec(root_module) is None:
pytest.skip(f"optional framework {root_module!r} is not installed")
module = dasc_policy.importlib.import_module(module_name)
assert issubclass(getattr(module, class_name), nn.Module)
Comment on lines +297 to +301

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] This is the first test under tests/unit/ that performs a real import megatron.core...; every other unit test either monkeypatches Megatron or avoids it (tests/unit/torch/export/test_get_quantization.py patches the exporter). The guard is find_spec(root_module) is None, which only proves the distribution is present, not importable — on a runner with megatron-core installed but without transformer_engine/CUDA, import_module("megatron.core.ssm.gated_delta_net") raises and this becomes a hard unit-test failure rather than a skip. That's precisely the "installed but unresolvable" case the production code at policy.py:52-64 now degrades to a warning for.

pytest.importorskip(module_name) would fix the CI fragility but defeats the test's purpose — path drift would silently skip instead of failing. Gating on the root package instead keeps both properties:

@pytest.mark.parametrize(("module_name", "class_name"), dasc_policy._SUPPORTED_GDN_CLASS_PATHS)
def test_declared_gdn_paths_resolve_when_framework_is_installed(module_name, class_name):
    """Guard supported identities against upstream dependency path drift."""
    pytest.importorskip(module_name.partition(".")[0])
    module = dasc_policy.importlib.import_module(module_name)
    assert issubclass(getattr(module, class_name), nn.Module)

Root unimportable (missing TE, no CUDA) → skip; root importable but the leaf path moved → the test fails, which is the drift you want to catch. It also drops the reach through dasc_policy.importlib.util.

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.

Addressed in #2383. The real-path guard now uses pytest.importorskip on the framework root. An unimportable optional root skips, while an importable root with a moved leaf path or class still fails.



def test_generic_mode_application_reports_missing_measurements(monkeypatch):
Expand Down Expand Up @@ -357,6 +378,34 @@ def test_dtype_cast_preserves_policy_when_the_selected_mask_is_unchanged(dtype):
assert mtss.export_policy(model) == policy


def test_bf16_storage_round_trip_loaded_in_fp32_preserves_policy():
"""Accept BF16-rounded values after a checkpoint loader materializes FP32 tensors."""
model = mtss.calibrate(
TinyGatedDeltaNetForCausalLM(), _config(wmax_candidates=[7]), [_candidate(7)]
)
policy = mtss.export_policy(model)
original_decay = torch.cat(
[model.linear_attn.A_log.detach(), model.linear_attn.dt_bias.detach()]
)

with torch.no_grad():
model.linear_attn.A_log.copy_(model.linear_attn.A_log.to(torch.bfloat16).float())
model.linear_attn.dt_bias.copy_(model.linear_attn.dt_bias.to(torch.bfloat16).float())

reloaded_decay = torch.cat(
[model.linear_attn.A_log.detach(), model.linear_attn.dt_bias.detach()]
)
assert reloaded_decay.dtype == torch.float32
assert not torch.equal(original_decay, reloaded_decay)
assert mtss.export_policy(model) == policy

model.linear_attn.A_log = nn.Parameter(
model.linear_attn.A_log.detach().to(torch.int64), requires_grad=False
)
with pytest.raises(ApplyModeError, match="floating-point dtype"):
mtss.export_policy(model)


def test_export_rejects_changed_decay_parameters_and_restore_rejects_structure():
"""Keep saving recoverable while rejecting stale or tampered deployment policies."""
model = mtss.calibrate(
Expand Down
Loading