mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-11-08 09:03:20 +08:00
### What does this PR do?
Type of change: New Feature <!-- Use one of the following: Bug fix, new
feature, new example, new tests, documentation. -->
Adds HuggingFace `config.json` export of skip-softmax sparse-attention
calibration for diffusion pipelines (e.g. Wan 2.2), on top of the base
skip-softmax work.
- **`_export_diffusers_checkpoint`** walks every `nn.Module` component
of a diffusers pipeline, calls `export_sparse_attention_config`, and
writes the result into that component's `config.json` under the
`sparse_attention_config` key. The sparse config lives **only** in
`config.json` — there is no standalone `sparse.yaml`.
- **`export_sparse_attention_config`** emits a `config_groups` schema
where each algorithm's parameters are nested inside its own group; only
`config_groups` and `producer` are top-level:
- skip-softmax group → `algorithm: "skip_softmax"`, `targets`, `ignore`
(layers kept dense — e.g. cross-attention + first/last blocks),
`initial_disabled_steps` (opt-in, user-set; emitted only when `> 0`),
`threshold_scale_factor` (`a * exp(b * target_sparsity)`), and
`target_sparsity`.
- N:M group → `algorithm: "sparse_softmax"` with
`sparsity_n`/`sparsity_m`, `dense_sink_tokens`, `dense_recent_tokens`
flattened into the group.
- **Deploy reader**
(`modelopt/torch/sparsity/attention_sparsity/plugins/sparse_attn_config.py`)
reads these per-group params back, keeping the export↔load round-trip
consistent.
- **Example wiring**:
`examples/diffusers/sparsity/wan22_skip_softmax.py` gains
`--export-dir`, `--skip-softmax-threshold`, and
`--initial-disabled-steps`. `--export-dir` runs
`export_hf_checkpoint(pipe, export_dir=...)` after calibration.
- Updated `CHANGELOG.rst`.
### Usage
```bash
python examples/diffusers/sparsity/wan22_skip_softmax.py \
--model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--calibrate --target-sparsity 0.5 --calib-size 4 \
--initial-disabled-steps 5 \
--export-dir ./wan22_skip_softmax_ckpt
```
Resulting layout — a `config.json` per component, **no `sparse.yaml`**:
```
wan22_skip_softmax_ckpt/
├── transformer/config.json # carries sparse_attention_config
├── transformer_2/config.json # carries sparse_attention_config
├── vae/ … text_encoder/ … tokenizer/ … scheduler/ …
└── model_index.json
```
A representative `config.json` entry for a diffusion transformer:
```json
"sparse_attention_config": {
"config_groups": {
"group_0": {
"algorithm": "skip_softmax",
"targets": ["WanAttention"],
"ignore": ["blocks.0.attn1", "blocks.0.attn2", "…"],
"initial_disabled_steps": 5,
"threshold_scale_factor": {
"formula": "a * exp(b * target_sparsity)",
"prefill": {"a": 1443.49, "b": 4.30}
},
"target_sparsity": {"prefill": 0.5}
}
},
"producer": {"name": "modelopt", "version": "0.45.0..."}
}
```
The N:M variant adds a second group:
```json
"group_1": {
"algorithm": "sparse_softmax",
"targets": ["WanAttention"],
"sparsity_n": 2, "sparsity_m": 4,
"dense_sink_tokens": 0, "dense_recent_tokens": 64
}
```
### Testing
- `tests/examples/diffusers_sparsity/test_sparsity.py`: baseline /
triton-baseline / fixed-threshold runs of the Wan 2.2 example, plus a
Python-API calibrate → **export** test asserting the nested
`sparse_attention_config` (`threshold_scale_factor`, `target_sparsity`,
`ignore`, `initial_disabled_steps`) and the absence of any
`sparse.yaml`.
-
`tests/unit/torch/sparsity/attention_sparsity/test_sparse_attention_conversion.py`
and `test_sparse_attn_config.py`: unit coverage of the per-group export
schema and the deploy-reader round-trip (writer nests → reader reads
from groups → internal mtsa config unchanged).
- Validated end-to-end on Wan 2.2 T2V-A14B: full 4-prompt / 40-step /
81-frame calibration; the exported checkpoint carries the nested schema
in both `transformer` and `transformer_2` `config.json`, and runtime
measurement shows ~47–49% tile sparsity at a 0.5 target.
### Before your PR is "*Ready for review*"
Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)
and your commits are signed (`git commit -s -S`).
Make sure you read and follow the [Security Best
Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors)
(e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(...,
weights_only=False)`, `pickle`, etc.).
- Is this change backward compatible?: ❌ The exported
`sparse_attention_config` schema was renamed and nested per-group during
0.45.x development, and the loader reads only the new layout —
checkpoints exported by earlier 0.45.x builds must be re-exported. No
released version is affected. <!--- If ❌, explain why. -->
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: ✅ <!---
Mandatory -->
- Did you write any new necessary tests?: ✅ <!--- Mandatory for new
features or examples. -->
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅ <!--- Only for new features, API changes, critical bug fixes or
backward incompatible changes. -->
### Additional Information
<!-- E.g. related issue. -->
---------
Signed-off-by: Jingyu Xin <jingyux@nvidia.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
9.1 KiB
Python
248 lines
9.1 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for skip-softmax sparse attention on Wan 2.2 (examples/diffusers/sparsity/).
|
|
|
|
Uses a tiny Wan 2.2 model (dual transformer, 2 layers, hidden_dim=24) created
|
|
from scratch. Tests run the wan22_skip_softmax.py example script in baseline,
|
|
triton-baseline, fixed-threshold, and export modes. Also includes a Python API
|
|
test for calibration params + export (calibration can't succeed on tiny models
|
|
via the Triton kernel, so params are injected directly).
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
|
|
import pytest
|
|
import torch
|
|
from _test_utils.examples.run_command import run_example_command
|
|
from _test_utils.torch.diffusers_models import create_tiny_wan22_pipeline_dir
|
|
|
|
EXAMPLE_PATH = "diffusers/sparsity"
|
|
|
|
# Tiny inference settings — fast but exercises all code paths
|
|
_TINY_ARGS = [
|
|
"--num-frames",
|
|
"5",
|
|
"--height",
|
|
"16",
|
|
"--width",
|
|
"16",
|
|
"--num-steps",
|
|
"2",
|
|
"--guidance-scale",
|
|
"1.0",
|
|
"--skip-first-last",
|
|
"0",
|
|
"--negative-prompt",
|
|
"",
|
|
]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def tiny_wan22_path(tmp_path_factory):
|
|
"""Create a tiny Wan 2.2 pipeline saved to disk (session-scoped)."""
|
|
return str(create_tiny_wan22_pipeline_dir(tmp_path_factory.mktemp("tiny_wan22")))
|
|
|
|
|
|
def test_wan22_baseline(tiny_wan22_path, tmp_path):
|
|
"""Dense baseline — no sparsity, default diffusers attention backend."""
|
|
cmd = [
|
|
"python",
|
|
"wan22_skip_softmax.py",
|
|
"--model-path",
|
|
tiny_wan22_path,
|
|
"--baseline",
|
|
"--prompt",
|
|
"test",
|
|
"--output",
|
|
str(tmp_path / "baseline.mp4"),
|
|
*_TINY_ARGS,
|
|
]
|
|
run_example_command(cmd, EXAMPLE_PATH)
|
|
|
|
|
|
def test_wan22_triton_baseline(tiny_wan22_path, tmp_path):
|
|
"""Triton kernel without skip-softmax (threshold=0, apples-to-apples)."""
|
|
cmd = [
|
|
"python",
|
|
"wan22_skip_softmax.py",
|
|
"--model-path",
|
|
tiny_wan22_path,
|
|
"--triton-baseline",
|
|
"--prompt",
|
|
"test",
|
|
"--output",
|
|
str(tmp_path / "triton_baseline.mp4"),
|
|
*_TINY_ARGS,
|
|
]
|
|
run_example_command(cmd, EXAMPLE_PATH)
|
|
|
|
|
|
def test_wan22_skip_softmax_threshold(tiny_wan22_path, tmp_path):
|
|
"""Skip-softmax with a fixed lambda threshold — no calibration needed."""
|
|
cmd = [
|
|
"python",
|
|
"wan22_skip_softmax.py",
|
|
"--model-path",
|
|
tiny_wan22_path,
|
|
"--skip-softmax-threshold",
|
|
"0.03125",
|
|
"--report-avg-sparsity",
|
|
"--prompt",
|
|
"test",
|
|
"--output",
|
|
str(tmp_path / "skip_softmax_threshold.mp4"),
|
|
*_TINY_ARGS,
|
|
]
|
|
run_example_command(cmd, EXAMPLE_PATH)
|
|
|
|
|
|
def test_wan22_export_sparse_checkpoint(tiny_wan22_path, tmp_path):
|
|
"""Export a fixed-threshold checkpoint and verify the structure.
|
|
|
|
A fixed ``--skip-softmax-threshold`` run is uncalibrated, so
|
|
``export_sparse_attention_config`` returns ``None`` and no
|
|
``sparse_attention_config`` is written (the populated-config path is covered
|
|
by ``test_wan22_calibrated_export``). Here we just verify the checkpoint is
|
|
written and that no standalone ``sparse.yaml`` is produced.
|
|
"""
|
|
export_dir = tmp_path / "sparse_export"
|
|
cmd = [
|
|
"python",
|
|
"wan22_skip_softmax.py",
|
|
"--model-path",
|
|
tiny_wan22_path,
|
|
"--skip-softmax-threshold",
|
|
"0.03125",
|
|
"--export-dir",
|
|
str(export_dir),
|
|
*_TINY_ARGS,
|
|
]
|
|
run_example_command(cmd, EXAMPLE_PATH)
|
|
|
|
assert export_dir.exists()
|
|
for component in ["transformer", "transformer_2"]:
|
|
component_dir = export_dir / component
|
|
assert component_dir.exists(), f"Missing component dir: {component}"
|
|
config_path = component_dir / "config.json"
|
|
assert config_path.exists(), f"Missing config.json for {component}"
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
# Fixed (uncalibrated) threshold has nothing to export.
|
|
assert "sparse_attention_config" not in config_data, (
|
|
f"Unexpected sparse_attention_config in {component}/config.json for a "
|
|
"fixed-threshold (uncalibrated) export"
|
|
)
|
|
weight_files = list(component_dir.glob("*.safetensors")) + list(component_dir.glob("*.bin"))
|
|
assert len(weight_files) > 0, f"No weight files for {component}"
|
|
|
|
# Sparse config (when present) lives only in config.json — never a standalone yaml.
|
|
assert not (export_dir / "sparse.yaml").exists(), "Unexpected top-level sparse.yaml"
|
|
|
|
|
|
def test_wan22_calibrated_export(tmp_path):
|
|
"""Inject calibration params via the Python API and verify the exported config.
|
|
|
|
Calibration can't succeed on tiny models via the Triton kernel (not enough
|
|
data points in the 10%-90% sparsity range), so this test sparsifies, injects
|
|
calibration params directly, and exports. Verifies the calibrated config schema
|
|
(top-level ``threshold_scale_factor`` of the form ``a * exp(b * target_sparsity)``)
|
|
and that the dense (cross-attention) layers are recorded under ``ignore``.
|
|
"""
|
|
from diffusers import AutoencoderKLWan, WanPipeline
|
|
|
|
import modelopt.torch.sparsity.attention_sparsity as mtsa
|
|
from modelopt.torch.export import export_hf_checkpoint
|
|
from modelopt.torch.sparsity.attention_sparsity.sparse_attention import SparseAttentionModule
|
|
|
|
pipe_dir = create_tiny_wan22_pipeline_dir(tmp_path / "model")
|
|
vae = AutoencoderKLWan.from_pretrained(pipe_dir, subfolder="vae", torch_dtype=torch.float32)
|
|
pipe = WanPipeline.from_pretrained(pipe_dir, vae=vae, torch_dtype=torch.bfloat16)
|
|
pipe.to("cuda")
|
|
|
|
# Sparsify self-attention only; cross-attention (attn2) stays dense.
|
|
sparse_cfg = {
|
|
"*.attn1*": {
|
|
"method": "triton_skip_softmax",
|
|
"skip_softmax_threshold": 0.1,
|
|
"backend": "triton",
|
|
"is_causal": False,
|
|
"initial_disabled_steps": 5,
|
|
"enable": True,
|
|
},
|
|
"*.attn2*": {"enable": False},
|
|
"default": {"enable": False},
|
|
}
|
|
config = {"sparse_cfg": sparse_cfg}
|
|
for transformer in [pipe.transformer, pipe.transformer_2]:
|
|
mtsa.sparsify(transformer, config)
|
|
|
|
# Inject calibration params (simulating a successful log-space calibration).
|
|
test_log_a = math.log(1.5)
|
|
test_b = 3.0
|
|
calibration_params = {
|
|
"prefill": {
|
|
"a": math.exp(test_log_a),
|
|
"b": test_b,
|
|
"log_a": test_log_a,
|
|
"fit_logspace": True,
|
|
"min_observed_sparsity": 0.15,
|
|
"max_observed_sparsity": 0.85,
|
|
},
|
|
}
|
|
target_sparse_ratio = {"prefill": 0.5}
|
|
for transformer in [pipe.transformer, pipe.transformer_2]:
|
|
for module in transformer.modules():
|
|
if isinstance(module, SparseAttentionModule) and module.is_enabled:
|
|
module._sparse_method_instance.calibration_params = calibration_params
|
|
module._sparse_method_instance.target_sparse_ratio = target_sparse_ratio
|
|
|
|
export_dir = tmp_path / "calibrated_export"
|
|
export_hf_checkpoint(pipe, export_dir=export_dir)
|
|
|
|
for component in ["transformer", "transformer_2"]:
|
|
config_path = export_dir / component / "config.json"
|
|
assert config_path.exists(), f"Missing config.json for {component}"
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
assert "sparse_attention_config" in config_data, (
|
|
f"No sparse_attention_config in {component}/config.json"
|
|
)
|
|
|
|
sa_config = config_data["sparse_attention_config"]
|
|
group_0 = sa_config["config_groups"]["group_0"]
|
|
assert group_0["algorithm"] == "skip_softmax"
|
|
assert group_0["targets"]
|
|
|
|
# Dense (uncalibrated) layers must be recorded so deployment skips them too.
|
|
assert "ignore" in group_0
|
|
assert any(".attn2" in name for name in group_0["ignore"])
|
|
|
|
# Opt-in initial_disabled_steps metadata is carried through (exported only when > 0).
|
|
assert group_0["initial_disabled_steps"] == 5
|
|
|
|
# threshold_scale_factor lives inside the skip_softmax group.
|
|
tsf = group_0["threshold_scale_factor"]
|
|
assert tsf["formula"] == "a * exp(b * target_sparsity)"
|
|
assert tsf["prefill"]["a"] == pytest.approx(math.exp(test_log_a))
|
|
assert tsf["prefill"]["b"] == pytest.approx(test_b)
|
|
|
|
# Calibrated mode — no raw_threshold.
|
|
assert "raw_threshold" not in group_0
|
|
|
|
# Sparse config lives only in config.json — no standalone sparse.yaml.
|
|
assert not (export_dir / "sparse.yaml").exists(), "Unexpected top-level sparse.yaml"
|