Skip to content

Add SANA-WM camera-controlled image-to-video pipeline - #13881

Open
lawrence-cj wants to merge 71 commits into
huggingface:mainfrom
lawrence-cj:feat/sana-wm-diffusers-cleanup
Open

Add SANA-WM camera-controlled image-to-video pipeline#13881
lawrence-cj wants to merge 71 commits into
huggingface:mainfrom
lawrence-cj:feat/sana-wm-diffusers-cleanup

Conversation

@lawrence-cj

@lawrence-cjlawrence-cj commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Hi @sayakpaul@dg845 , Long time no see. Hoping your are doing great. ♥️

Adds SANA-WM, the camera-controlled image-to-video world model from NVIDIA + MIT HAN Lab, as a first-class diffusers pipeline and transformer. Given a first-frame image, a text prompt, and a camera trajectory (explicit c2w poses or a WASD/IJKL action-DSL string), the pipeline generates a video whose motion follows the requested camera path. Trained natively for minute-scale generation at 704×1280.

The pipeline runs in two stages:

  1. Stage 1 — SanaWMTransformer3DModel. A 1.6B-parameter bidirectional DiT with GDN-Triton linear attention and a UCPE camera-control branch; samples with an LTX-style flow-matching Euler scheduler at per-token timesteps. The first latent frame is the conditioning anchor.
  2. Stage 2 — SanaWMLTX2Refiner (optional). A chunk-causal AR refiner that wraps diffusers' LTX2VideoTransformer3DModel + LTX2TextConnectors + Gemma-3 text encoder. Processes 3 latent frames at a time with a sliding window of [source_sink + recent_history + active_block] K/V, so per-block compute is bounded and total refinement cost is linear in video length.

Both stages decode through AutoencoderKLLTX2Video.

Layout

src/diffusers/
├── models/transformers/
│ ├── transformer_sana_wm.py # SanaWMTransformer3DModel + blocks + helpers
│ └── transformer_sana_wm_kernels.py # fused Triton kernels + camera math
└── pipelines/sana_wm/
├── __init__.py
├── pipeline_sana_wm.py # SanaWMPipeline
├── pipeline_output.py # SanaWMPipelineOutput
├── refiner.py # SanaWMLTX2Refiner + RefinerChunkRunner
└── cam_utils.py # action DSL, intrinsics, resize+crop, Plücker/raymap
scripts/sana_wm/convert_sana_wm_to_diffusers.py
docs/source/en/api/{pipelines/sana_wm.md, models/sana_wm_transformer3d.md}

Usage

importtorchfromPILimportImagefromdiffusersimportSanaWMPipelinefromdiffusers.utilsimportexport_to_videopipe=SanaWMPipeline.from_pretrained(
"Efficient-Large-Model/SANA-WM_bidirectional-diffusers",
torch_dtype=torch.bfloat16,
)
pipe.vae.to(torch.float32)
pipe.enable_model_cpu_offload()
out=pipe(
image=Image.open("input.png").convert("RGB"),
prompt="A car driving across a vast desert plain at golden hour.",
action="w-80,jw-40,w-40", # WASD-style action DSLintrinsics=[800.0, 800.0, 845.0, 464.0], # fx, fy, cx, cy in original-image pixelsnum_frames=161,
num_inference_steps=60,
)
export_to_video(list(out.frames), "sana_wm.mp4", fps=16)

Demo

5-second sample (30 stage-1 steps + 3-step distilled AR refiner, official asset/sana_wm/demo_0 inputs, 704×1280 @ 16 fps) :

sana_wm_5s.mp4

Smoke tests

End-to-end on 1× H100 80GB with `enable_model_cpu_offload` and the official `asset/sana_wm/demo_0.{png,txt,_pose.npy,_intrinsics.npy}`:

DurationFramesStage-1 (30 steps)Refiner (AR, 3 blocks)Output
5s801:115:24 / step525 KB
10s1601:1128:55 (7 blocks)1.4 MB
20s3201:57≈ 4 min / block (14)3.2 MB
50s8005:3330:46 (34 blocks)6.3 MB

Checkpoint conversion

scripts/sana_wm/convert_sana_wm_to_diffusers.py --src Efficient-Large-Model/SANA-WM_bidirectional --dst /local/path converts the public release into a `from_pretrained`-loadable directory (VAE, Gemma-2 tokenizer + text_encoder, transformer, scheduler, refiner subfolders, top-level `model_index.json`).

Related

Paper: https://arxiv.org/abs/2605.15178

HaoyiZhuand others added 4 commits June 1, 2026 01:28
…line
Adds the public SANA-WM bidirectional camera-controlled image-to-video
model as a first-class diffusers pipeline + transformer. Layout mirrors
``sana_video``: the model lives under ``src/diffusers/models/transformers/``
as a near-single-file (kernels split off so the ``@triton.jit`` decorators
don't drown the model body); the pipeline lives under
``src/diffusers/pipelines/sana_wm/``.
Files added:
src/diffusers/models/transformers/
├── transformer_sana_wm.py # SanaWMTransformer3DModel + blocks + helpers
└── transformer_sana_wm_kernels.py # fused Triton kernels + camera math
src/diffusers/pipelines/sana_wm/
├── __init__.py
├── pipeline_sana_wm.py
├── pipeline_output.py
├── refiner.py
└── cam_utils.py
Pipeline architecture:
* Stage 1: 1600M ``SanaWMTransformer3DModel`` DiT with bidirectional
GDN-Triton linear attention + UCPE camera-control branch, LTX-style
flow-matching Euler scheduler with per-token timesteps.
* Stage 2: LTX-2 sink-bidirectional Euler refiner (3 distilled sigma
steps, reuses diffusers' ``LTX2VideoTransformer3DModel`` +
``LTX2TextConnectors`` + Gemma-3 text encoder).
* Decode through the LTX-2 VAE (``AutoencoderKLLTX2Video``).
One-line usage:
pipe = SanaWMPipeline.from_pretrained(
"Efficient-Large-Model/SANA-WM_bidirectional-diffusers",
torch_dtype=torch.bfloat16,
).to("cuda")
out = pipe(image=img, prompt="...", action="w-80,jw-40,w-40",
intrinsics=[fx, fy, cx, cy])
End-to-end smoke test (stage-1 + refiner + VAE decode) passes on H100.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…xport
transformer_sana_wm.py:
* License header switched to the "HuggingFace Team and SANA-WM Authors"
style used by merged sana_video.
* Imports rewritten in stdlib -> third-party -> diffusers order; use
diffusers `from ...utils import logging` instead of stdlib `logging`.
* Fix 9 `Optional[X]` annotations written as `X or None` (Python's `or`
short-circuits and silently returns `X`).
* Fix two `assert (cond, msg)` tuple-asserts in PatchEmbedMS3D.forward
that always pass (SyntaxWarning at import time).
* Remove duplicate `__all__` declarations (the second silently overwrote
the first).
* Remove dead `reset_bn` (imports a nonexistent `packages.apps.utils`,
would crash on call).
* Remove the duplicate `logger = logging.getLogger(__name__)` further
down in the file.
transformer_sana_wm_kernels.py:
* License header normalized; collapse three duplicate triton/torch import
blocks into one.
pipeline_sana_wm.py:
* License header normalized.
* `_decode_latents` now returns `(T, H, W, 3)` float in [0, 1], matching
the diffusers convention used by `VideoProcessor`. Returning uint8
silently broke `export_to_video`: it does `frame * 255` assuming float
input, so uint8 overflows to `(-x) mod 256` and inverts colors.
* `__call__` converts to PIL/uint8 only when `output_type="pil"`.
* Intrinsics argument now accepts (4,), (F, 4), (3, 3), and (F, 3, 3)
forms (auto-extracts fx, fy, cx, cy from a 3x3 K) and auto-trims to
`num_frames` when a longer-than-needed trajectory is passed.
* Inline `retrieve_timesteps` with the standard `# Copied from
diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion.retrieve_timesteps`
marker, matching merged sana_video.
* Docstrings + EXAMPLE_DOC_STRING updated to reflect the new return type.
pipeline_output.py:
* Update `frames` field docstring to describe the new float [0, 1] return.
refiner.py, cam_utils.py, scripts/sana_wm/convert_sana_wm_to_diffusers.py:
* License headers normalized.
Docs:
* New `docs/source/en/api/pipelines/sana_wm.md` and
`docs/source/en/api/models/sana_wm_transformer3d.md`, modeled on
sana_video.md / sana_video_transformer3d.md, wired into
`docs/source/en/_toctree.yml` under Models and Pipelines.
5s end-to-end smoke test (81 frames @ 16fps, 30 stage-1 steps + 3-step
LTX-2 refiner) passes on 1x H100 80GB with `enable_model_cpu_offload`.
Round-trip diff vs raw float frames is 2.06/255 mean (h264 lossy noise),
confirming the export_to_video fix.
…+ KV cache hooks)
The first cleanup pass only kept the legacy single-shot refiner path. That
path is what the model was *not* trained on — its docstring even says
"feeding the full sequence at once is out-of-distribution" — and its cost
is O(T^2) attention over the full latent volume, which made longer videos
unusable (~21 min per refiner step at 321 frames on an H100).
Port the chunk-causal AR mode from the upstream reference so the refiner
matches the training contract:
* `refine_latents` now defaults to `block_size=3, kv_max_frames=11`
(the canonical AR recipe). Pass `block_size=None` to fall back to the
legacy single-shot path.
* New `_refine_latents_ar` + `_RefinerChunkRunner` orchestrate the sliding
window: pre-capture pre-RoPE sink K/V on `z_sana[:source_sink_frames]`
at sigma=0, then for each `block_size`-frame chunk run a 3-step Euler
with prefix `{sink_k_pre, sink_v, sink_pe, history_k, history_v}` and
capture post-RoPE K/V to feed the next window. History is bounded to
`kv_max_frames - source_sink_frames` so per-block compute is constant.
* New `_predict_x0_active_block` runs the transformer on the active block
only (Q from active, K/V from prefix+active).
* New `_capture_block_kv` runs sigma=0 forward with a pre_rope/post_rope
capture flag set on each `attn1`.
* New `_forward_video_only_with_rope` takes a pre-built RoPE so each block
can use absolute frame positions in the source video.
* `_streaming_self_attention` extended with the `_kv_cache_capture`,
`_tf_capture_kv`, `_tf_kv_prefix` hook contract that AR mode uses to
inject and capture K/V on each block.
* New helpers: `_build_rotary_emb_for_absolute_positions`,
`_set_kv_prefix_on_blocks`, `_clear_kv_prefix_on_blocks`,
`_set_capture_flag_on_blocks`, `_collect_captured_kv_from_blocks`.
* `_encode_prompt` now also moves the Gemma-3 text encoder back to CPU
after producing the embeds — otherwise it stays resident through the
entire AR loop and gates how much GPU memory the refiner transformer
has left.
Module-level docstring updated to document both modes; existing
single-shot path preserved verbatim.
…eemption)
The AR refiner is expensive (~3-5 min per block) and the refinement loop
ran end-to-end has no in-progress state to recover, so a SLURM preemption
mid-refinement loses all progress. With the canonical
``block_size=3, kv_max_frames=11`` setup, refining a 50s video is 34
blocks of work that has to make it through without preemption on a
backfill queue.
Add per-block atomic checkpointing:
* ``SanaWMLTX2Refiner.refine_latents(checkpoint_dir=Path)`` and
``_refine_latents_ar`` accept a directory. After each completed AR
block, the AR loop writes ``checkpoint_dir/state.pt`` atomically
(tmp + os.replace).
* The payload is ``{block_idx_done, n_blocks, sink_size, block_size,
output_shape, output, runner_state}``. ``runner_state`` is a CPU snapshot
of the runner's ``_sink_kv_pre``, ``_history_kv_post``,
``_history_frames`` and ``torch.Generator`` state.
* On entry, if ``state.pt`` exists with a compatible shape signature, the
AR loop loads the persisted output tensor + runner state and resumes
from ``block_idx_done + 1`` instead of recomputing from scratch.
* ``SanaWMPipeline.__call__(refiner_checkpoint_dir=...)`` plumbs the
directory through to the refiner.
Checkpoint size: ~output_volume + sink_KV (~360MB for 50 layers) +
rolling history KV (~3-4GB at full capacity) — saved once per block,
total per-block save overhead ~10s on lustre.
@github-actionsgithub-actionsBot added size/L PR with diff > 200 LOC documentation Improvements or additions to documentation models pipelines and removed size/L PR with diff > 200 LOC labels Jun 7, 2026
@github-actionsgithub-actionsBot added the size/L PR with diff > 200 LOC label Jun 9, 2026
* CPU unit tests for cam_utils helpers (action DSL → c2w, intrinsics
rescale-for-crop, resize+center-crop, snap_num_frames 8k+1 rounding).
* Public-surface registration tests (top-level diffusers symbols,
SanaWMPipelineOutput dataclass shape, refiner signature has AR defaults
+ checkpoint_dir, pipeline __call__ accepts c2w/action/intrinsics/
refiner_checkpoint_dir).
* @slow @require_torch_accelerator integration stub for an end-to-end I2V
against the public checkpoint, currently @unittest.skip — wires up the
nightly GPU path without exploding regular CI.
SanaWMTransformer3DModel has hardcoded depth/hidden_size/num_heads inside
its inner SanaMSVideoCamCtrl (not exposed through register_to_config), so
the usual PipelineTesterMixin small-config fast tests aren't applicable
without a transformer refactor (followup PR).
@dg845
dg845 requested review from dg845 and yiyixuxuJune 12, 2026 03:54
@dg845

Copy link
Copy Markdown
Collaborator

As a preliminary comment, would it be possible to use PyTorch ops instead of custom Triton kernels (or add pure PyTorch fallback paths) for now? We will work on supporting the custom kernels through kernels. CC @sayakpaul

@lawrence-cj

Copy link
Copy Markdown
ContributorAuthor

As a preliminary comment, would it be possible to use PyTorch ops instead of custom Triton kernels (or add pure PyTorch fallback paths) for now? We will work on supporting the custom kernels through kernels. CC @sayakpaul

Yes, love to do that.

…ttention
`transformer_sana_wm_kernels.py` previously did a hard `import triton`
at the top of the file. That blocked importing the SANA-WM transformer
on any environment without Triton (CPU-only, ROCm without Triton,
older Triton, etc.), even though the model has pure-PyTorch attention
classes for every `*Triton` variant.
Make Triton optional and have the dispatcher transparently fall back:
* Wrap `import triton` / `import triton.language as tl` in try/except.
When unavailable, install a shim where `@triton.jit` is a no-op so
the kernel function definitions still load (they just aren't compiled
by Triton). Module-level `triton.X` / `tl.X` lookups return a
self-shimming sentinel so signature parsing doesn't blow up either.
* Add `is_triton_available()` + `_require_triton(entry_point)`. The four
Triton-backed entry points called by the model (`fused_qk_inv_rms`,
`fused_bigdn_func`, `cam_prep_func`, `cam_scan_bidi_chunkwise`) now
raise a clear RuntimeError on a Triton-less host with a hint to use
the pure-PyTorch attention variants — but the dispatcher does this
automatically (see below) so users shouldn't ever see it.
* Delete the leftover duplicate `import torch / triton / triton.language`
block at line 262 (left over from the upstream port).
* Register `BidirectionalGDNUCPESinglePathLiteLA` in `ATTENTION_BLOCKS`
so the fallback chain can find it.
* New `_resolve_attention_block(name, role)` walks the requested class's
MRO at dispatch time. If Triton isn't usable AND the requested class
name ends in `Triton`, route to the closest registered non-`Triton`
ancestor (BidirectionalGDNUCPESinglePathLiteLABothTriton ->
BidirectionalGDNUCPESinglePathLiteLA, etc.) and log a one-shot warning.
* Rewire both `SanaVideoMSCamCtrlBlock` dispatch sites to use
`_resolve_attention_block` for the GDN+UCPE camera branch and the main
attention branch (the `BidirectionalSoftmaxUCPESinglePathLiteLA` branch
doesn't use Triton at all so it stays hard-coded).
Tests:
* `test_kernels_module_imports_with_triton_hidden` — reloads the kernels
module with `sys.modules['triton'] = None` and verifies the module
imports, `is_triton_available()` is False, and the pure-PyTorch helpers
remain callable.
* `test_resolve_attention_block_cpu_fallback` — on a CPU-only host, the
three `*Triton` attn types resolve to the correct non-Triton ancestor.
* `test_triton_entry_point_raises_clean_error_without_triton` — verifies
the `_require_triton` guard yields a RuntimeError that mentions Triton.
@lawrence-cj

lawrence-cj commented Jun 16, 2026

Copy link
Copy Markdown
ContributorAuthor

Done in c0712d3f8 — Triton is now optional, with an automatic pure-PyTorch fallback at dispatch time. Mapping when Triton isn't usable:

RequestedFalls back to
BidirectionalGDNTritonBidirectionalGDN
BidirectionalGDNUCPESinglePathLiteLATritonBidirectionalGDNUCPESinglePathLiteLA
BidirectionalGDNUCPESinglePathLiteLABothTritonBidirectionalGDNUCPESinglePathLiteLA

Triton remains the default on CUDA + Triton ≥ 3. CPU tests added under tests/pipelines/sana_wm/.

@lawrence-cj

Copy link
Copy Markdown
ContributorAuthor

@dg845@yiyixuxu Gentle ping here.

lawrence-cjand others added 4 commits June 18, 2026 11:26
Three CI checks were failing on the PR:
1. `check_code_quality` (43 ruff errors): mix of unused imports / import
sorting / E731 lambdas (auto-fixable) plus a handful of F821 dead-code
references inherited from the upstream research codebase (`xformers.*`
inside `if _xformers_available:` blocks, an undefined `BlockHook` type
annotation, two `x_sa`/`mlp_out` references in a block forward whose
live assignment was already overridden by subclasses). Ran `ruff check
--fix --unsafe-fixes` + `ruff format`, fixed the type annotation
manually, and added targeted `# noqa: F821` markers on the conditionally
unreachable lines.
2. `check_torch_dependencies`: `transformer_sana_wm.py` hard-imported
`einops`, `fla`, `timm`, `termcolor`. The minimum-deps CI environment
doesn't have them, and diffusers' lazy loader rewrites `ModuleNotFoundError`
as `RuntimeError` so `test_pipeline_imports` blew up. Wrapped each of
the four optional imports in a try/except shim — `rearrange`/
`ShortConvolution`/`DropPath`/`Attention_`/`Mlp` become placeholders
that raise a clear `ImportError` on construction, `colored` falls back
to plain text. Class bodies that subclass these still parse at module
load, so `import diffusers.models.transformers.transformer_sana_wm`
succeeds anywhere. Same treatment for the kernels file's
`from einops import rearrange, repeat`.
3. `build_pr_documentation`: doc-builder imported `SanaWMTransformer3DModel`
from `diffusers.models.transformers` (not the diffusers top level) and
that subpackage's `__init__.py` was missing the entry. Added the import.
* `doc-builder style src/diffusers docs/source --max_len 119` rewraps
docstrings in the six SANA-WM files (transformer, kernels, pipeline,
refiner, output, cam_utils) to the repo-wide 119-column limit. No
behaviour change — purely whitespace inside docstrings.
* `make fix-copies` regenerates `dummy_pt_objects.py` and
`dummy_torch_and_transformers_objects.py` to add `DummyObject` stubs
for the three new public classes (`SanaWMTransformer3DModel`,
`SanaWMPipeline`, `SanaWMLTX2Refiner`), so `from diffusers import …`
gives the standard "missing backend" message on installs without
torch / transformers.
Verified: `make quality` passes (ruff check, ruff format check,
doc-builder style check_only, check_doc_toc). Test suite still
15 passed / 1 skipped.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@dg845dg845 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lawrence-cj thanks for your patience! I have reviewed the refactored code. It would be helpful if you could run another self-review as described in #13881 (comment) after addressing the comments, as this will help speed up the review process.

Comment threadtests/pipelines/sana_wm/test_sana_wm.py
Comment threadtests/pipelines/sana_wm/test_sana_wm.py Outdated
Comment threadtests/pipelines/sana_wm/test_sana_wm.py Outdated
Comment threadsrc/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment threadsrc/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/refiner.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/refiner.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/cam_utils.py Outdated
Comment threadscripts/convert_sana_wm_to_diffusers.py
Comment threadscripts/sana_wm/convert_sana_wm_to_diffusers.py Outdated
Transformer:
* Stop stashing patch-grid shape on `self` during `forward` (`self.f/h/w`) and
thread it through as locals; `unpatchify` now takes it explicitly.
* Replace the 7 `assert`s with `ValueError`s.
* Drop `attn_drop`/`proj_drop` from `MultiHeadCrossAttention` (training-only,
and `attn_drop` was never applied) plus a stale training-era banner comment.
Pipeline / refiner:
* Don't mutate the components handed to the pipeline. The VAE tiling +
framewise settings move to the docs, `padding_side="right"` is passed per
tokenizer call, and the `.eval()` calls are gone (no `self.training`
branches remain, and `from_pretrained` already returns eval-mode modules).
* Gate `cam_utils`' optional imports on `is_torchvision_available()` and a new
`is_pi3_available()` helper.
* Annotate `SanaWMLTX2Refiner.__init__` and inline `_refine_latents_ar` into
its single caller.
Conversion script:
* Move to `scripts/` alongside the other Sana converters.
* Raise on missing/unexpected keys instead of printing, so a bad mapping can't
silently emit a broken transformer.
State dict unchanged (871/871 keys). GPU smoke on the public checkpoint is
byte-identical to the previous run (frame mean 0.5560), including with the VAE
settings applied by the caller rather than the pipeline.
The name implied this was Wan's rotary embedding, but it isn't: the per-axis
split is configurable through `fhw_dim`, and the frequencies stay complex in a
single `freqs` buffer instead of being split into real cos/sin buffers. So it
can't carry a `# Copied from`. Renamed, with a docstring recording why.
The buffer is `persistent=False`, so the state dict is unchanged (871/871).
… pytest
* `tests/models/transformers/test_models_transformer_sana_wm.py` — generated
with `utils/generate_model_tests.py` and filled in, following
`test_models_transformer_sana_video.py`. The tiny config sets
`softmax_every_n=2` so one block exercises the GDN camera branch and the
other the softmax variant. Dummy inputs supply the conditioning the forward
requires: `encoder_attention_mask`, `(B, F, 20)` camera conditions, and
`chunk_plucker`.
* `tests/pipelines/sana_wm/test_sana_wm.py` — rewritten in the pytest style of
`tests/pipelines/sana_video/test_sana_video.py`: no `unittest`, bare
asserts, module-level imports, and `parametrize` in place of loop-style
cases (15 test functions become 31 cases).
`AttentionTesterMixin` is skipped because the model calls
`F.scaled_dot_product_attention` directly rather than going through a
diffusers attention processor.
@lawrence-cj

lawrence-cj commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Thanks @dg845 — pushed in 99d51ebd6, 82c902cf7, d44abcb7a. 15 of 21 threads resolved.

Highlights: forward no longer stashes the patch grid on self (a real hazard under concurrency/torch.compile, not just style); asserts → ValueError; the pipeline no longer mutates the components it's handed (VAE settings are documented instead); WanRotaryPosEmbedSanaWMRotaryPosEmbed, since it genuinely can't be # Copied from Wan; model tests added and pipeline tests migrated to pytest — 47 passed, 27 skipped.

Two flagged rather than done, both with a prerequisite:

  • Gradient checkpointing needs the block **kwargs bag (12 keys, 41 pass-through sites) flattened first, since diffusers' default checkpoint function is positional-only. Worth its own PR.
  • Manual offloading exists only because the refiner is nested; un-nesting it as you suggest makes the block disappear, so I'd rather do both together.

One correction: class SanaWMCamUtilsTests: would have silently disabled those tests — no pytest config, so python_classes is the default Test* and the unittest.TestCase base is the only reason they're collected today. Used TestSanaWMCamUtils.

Still yours + @yiyixuxu's call, since they reshape the public API: the dispatch_attention_fn migration, un-nesting the pipelines, and making the refiner transformer its own model. Happy to take all three.

Verified throughout: 871/871 state-dict keys, GPU smoke unchanged at frame mean 0.5560.

@dg845dg845 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes! I have left some follow up comments; can you also run make style and make quality to fix any code style errors?

@yiyixuxu could you take a look at the following comments?

Comment threadsrc/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment threadsrc/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/pipeline_sana_wm.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/refiner.py Outdated
Comment threadsrc/diffusers/pipelines/sana_wm/refiner.py
Comment threadsrc/diffusers/pipelines/sana_wm/refiner.py Outdated
Comment threadtests/models/transformers/test_models_transformer_sana_wm.py
lawrence-cjand others added 4 commits September 3, 2026 01:20
* `TimestepEmbedder.dtype` used `next(self.parameters()).dtype`, which reports
the storage dtype under layerwise casting rather than the compute dtype. Use
`get_parameter_dtype`, which is layerwise-casting aware. (The model-level
`self.dtype` already routes through it via `ModelMixin`.)
* Stop reading submodule `.weight`/`.bias` inside `forward`. Group-offload
hooks fire on a module's `forward`, so reading its parameters directly
leaves them offloaded and trips a device mismatch. The frame-gate and
output-gate helpers now call their submodules, and the fused camera QKV
projection becomes three `q/k/v` calls -- algebraically the same as one
GEMM over the concatenated weights.
* Don't mutate the tokenizer in `SanaWMLTX2Refiner._encode_prompt`; pass
`padding_side="left"` per call, matching `SanaWMPipeline`.
* Add a `torch.compile` test with `recompile_limit=2` -- the repeated block
compiles once per attention variant.
GPU smoke unchanged (frame mean 0.5560).
…instead of seed
Replace the `**kwargs` bag threaded through model -> block -> attention with
explicit keyword arguments. Only three runtime keys were ever read at the
leaves (`frame_valid_mask`, `precomputed_gates`, `ucpe_ray_transforms`); the
rest were forwarded and silently swallowed. `camera_embedding`,
`chunk_index`, `chunk_index_global` and `chunk_split_strategy` turned out to
be pure dead plumbing -- written into the per-block kwargs dicts and never
read by any attention or MLP forward -- so they are gone.
The pipeline also grows the standard diffusers arguments:
* `prompt_embeds` / `prompt_attention_mask` / `negative_prompt_embeds` /
`negative_prompt_attention_mask` on `encode_prompt` and `__call__`.
* `seed` / `refiner_seed` are replaced by `generator` / `refiner_generator`.
`generator=torch.Generator(device).manual_seed(42)` reproduces exactly what
`seed=42` used to build, so results are unchanged.
State dict unchanged (871/871). CPU old-vs-new equality on a tiny config over
both attention variants is exact (`torch.equal`, max diff 0.0), and the GPU
smoke on the public checkpoint still gives frame mean 0.5560.
@yiyixuxu was right that the model only ever runs uniform chunking; my earlier
reply defending the strategies was wrong. The two sites that actually chunk
both call `normalize_chunk_index(None, T, chunk_size)` with three positional
arguments, so `chunk_split_strategy` always took its `"uniform"` default. The
configured `first_chunk_plus_one` reached the per-block kwargs dict and was
then swallowed by the attention forwards' `**kwargs` without ever being read.
Flattening that bag into explicit arguments is what surfaced it.
Also note the `chunk_size` those call sites use is `chunk_gdn_chunk_size`, a
different attribute from the `chunk_size` that was being threaded through.
So `chunk_index_from_chunk_size`, `normalize_chunk_index`,
`is_uniform_chunking` and `compute_chunk_sizes` are gone, the uniform
boundaries are inlined at both call sites, and `chunk_split_strategy` is
dropped from the model and block constructors.
The released `config.json` still carries the key; loading is unaffected
(`extract_init_dict` ignores it) but it logs an "not expected and will be
ignored" warning, so it should come out of the checkpoint config on the next
export.
State dict unchanged (871/871); GPU smoke still frame mean 0.5560, which
confirms those branches were never taken.
@yiyixuxu asked whether the shared `RMSNorm` could be used here and my earlier
reply overstated the obstacles. Re-checking each one:
* `scale_factor` is not a blocker. `attention_y_norm.weight` is one of the 871
checkpoint keys, so `from_pretrained` overwrites whatever the constructor
initialised -- exactly the same reasoning that removed the other
hand-written inits in this PR. It only ever affected from-scratch models.
* `norm_dim` is not a blocker either; it is always the default `-1`.
* The numerics do differ: ours ran the normalisation and the weight multiply
in fp32, while the shared class computes only the variance in fp32 and does
the scaling and weight multiply in the input/weight dtype.
Measured rather than argued: the end-to-end GPU smoke on the public
checkpoint moves from frame mean 0.5560 to 0.5561, and the decoded video is
visually identical. That is well inside bf16 noise, so the local class is not
worth keeping.
The now-unused `y_norm_scale_factor` config argument goes too. As with
`chunk_split_strategy`, the released `config.json` still carries it and will
log an "not expected and will be ignored" warning until the checkpoint config
is re-exported.
@yiyixuxu

Copy link
Copy Markdown
Collaborator

I agree with @dg845 here - let's make the refiner a transformer model.

Whether to implement the refiner transformer as a new model:

lawrence-cjand others added 4 commits September 4, 2026 05:20
…ttention API
* Split the attention maths into a `SanaWMCrossAttnProcessor` and make
`MultiHeadCrossAttention` an `AttentionModuleMixin`, so the processor can be
swapped and the standard tooling applies.
* Use `dispatch_attention_fn` instead of calling
`F.scaled_dot_product_attention` directly, which lets the model pick up
alternative attention backends. This also drops the two transposes, since
the dispatcher takes `(batch, seq, heads, dim)`.
* Build a boolean padding mask rather than an additive float one. The old
`(1 - mask) * -10000.0` form isn't supported by the varlen backends.
Projection names are unchanged (`q_linear`, `kv_linear`, `proj`, `q_norm`,
`k_norm`), so the state dict stays at 871/871 keys. The GPU smoke on the
public checkpoint is unchanged at frame mean 0.5561, so `-inf` masking makes
no difference against the old `-10000.0` at this precision.
Per @yiyixuxu and @dg845: the stage-2 refiner no longer reaches into
`LTX2VideoTransformer3DModel`. It used to drive AR refinement by setting
attributes on LTX-2's submodules (`attn._tf_kv_prefix`, `_kv_cache_capture`,
`_tf_capture_kv`), which meant a second `forward` implementation smuggled in
from the pipeline.
New `transformer_sana_wm_refiner.py`, following the `transformer_wan_vace.py`
pattern of importing the reusable pieces rather than duplicating them:
* `SanaWMRefinerKVLayerCache` / `SanaWMRefinerKVCache`, modelled on
`Flux2KVCache`, holding the per-layer sink and history K/V.
* `SanaWMLTX2RefinerTransformerBlock`, which subclasses
`LTX2VideoTransformerBlock` and overrides only `forward`, so the submodule
structure stays byte-identical.
* `SanaWMLTX2RefinerTransformer3DModel`, taking `kv_cache` / `kv_cache_mode`
explicitly through `forward`.
`refiner.py` drops from 982 to 638 lines: the four `*_kv_prefix_on_blocks` /
`*_captured_kv_*` helpers, `_forward_video_block`, `_streaming_self_attention`
and `_forward_video_only_with_rope` are all gone, along with a dead
`n_context_tokens > 0` branch that neither call site could reach.
The state dict is unchanged at 3510 keys, identical to
`LTX2VideoTransformer3DModel` at the same config, so only the class name in
the checkpoint metadata has to change. CPU equivalence against the old path
is exact across four AR blocks -- covering sink injection, history growth and
history trimming -- with a negative control confirming the cache path is
actually exercised. The GPU smoke on the public checkpoint is unchanged at
frame mean 0.5561.
Per @dg845, the two stages are no longer nested. `SanaWMPipeline` produces
stage-1 latents and `SanaWMLTX2Refiner` consumes them:
latents = SanaWMPipeline(...)
video = SanaWMLTX2Refiner(...)
The refiner takes an optional `vae` -- pass the base pipeline's so the weights
are shared, the way SDXL shares components between base and refiner -- and
decodes to video when given one. The manual device juggling in
`SanaWMPipeline.__call__` is gone with the nesting that forced it; each
pipeline now manages its own placement through the standard offload hooks.
Un-nesting turned up a real bug: `torch_dtype` never reached the nested
sub-pipeline, so `SanaWMPipeline.from_pretrained(..., torch_dtype=bfloat16)`
silently ran the whole of stage 2 in float32 -- transformer, text encoder and
connectors alike. That is where the "~87 GB of refiner weights" in the old
comment came from. Loaded on its own the refiner honours the requested dtype,
which halves its footprint; the GPU smoke moves from frame mean 0.5561 to
0.5548 and the decoded video is visually identical.
The conversion script writes the refiner to its own output directory
(`--dst-refiner`, default `<dst>-refiner`) rather than a `refiner/`
subfolder. `DiffusionPipeline.from_pretrained` has no `subfolder` argument, so
a nested folder silently loads the *base* pipeline's same-named component
folders instead -- exactly the kind of wrong-weights failure that only
surfaces because `connectors/` happens not to exist at the top level.
@lawrence-cj

Copy link
Copy Markdown
ContributorAuthor

Pushed 8f9af9ae2. 93 of 96 threads resolved.

The refiner is now its own model and its own pipeline, per @yiyixuxu's call:

latents=SanaWMPipeline(...) # stage 1video=SanaWMLTX2Refiner(...) # stage 2, pass vae=pipe.vae to share weights

SanaWMLTX2RefinerTransformer3DModel subclasses LTX2VideoTransformerBlock and overrides only forward, so the state dict is unchanged at 3510 keys; the KV cache follows the Flux2KVCache pattern and is threaded through forward(..., kv_cache=, kv_cache_mode=) instead of being set as attributes on LTX-2's submodules. refiner.py drops 982 → 638 lines and the manual offload is gone with the nesting that forced it.

Two real bugs surfaced on the way, both worth recording:

  1. torch_dtype never reached the nested sub-pipeline.SanaWMPipeline.from_pretrained(..., torch_dtype=torch.bfloat16) was silently running all of stage 2 in float32 — transformer, text encoder and connectors alike. That is where the "~87 GB of refiner weights" came from, and half the reason the manual offload existed. Loading the refiner on its own halves its footprint. This is why the GPU smoke moves 0.5561 → 0.5548: I chased that delta specifically because it was bigger than anything else in this PR, and it's the dtype fix, not a regression. Decoded video is visually identical.

  2. DiffusionPipeline.from_pretrained has no subfolder argument. My first attempt put the refiner in a refiner/ subfolder; the loader silently picked up the base pipeline's identically-named transformer/, tokenizer/, text_encoder/ and scheduler/ folders and only failed because connectors/ doesn't exist at the top level. Had it existed, it would have run to completion on the wrong weights. The refiner now ships as its own repo (--dst-refiner, default <dst>-refiner), matching how SDXL splits base and refiner.

Also in this round: MultiHeadCrossAttention moved to AttentionModuleMixin + a processor using dispatch_attention_fn with a bool mask, and the shared RMSNorm replaced the local copy (measured rather than argued — 0.5560 → 0.5561, inside bf16 noise).

Still open (3):

  • Timesteps/TimestepEmbedding — would rename t_embedder.mlp.0/2 to linear_1/linear_2 and break the released checkpoint.
  • y_embedder.y_embedding — unused in forward but one of the 871 checkpoint keys.
  • Flattening the GDN inheritance chain — GDN is registered standalone in ATTENTION_BLOCKS, so I asked whether dropping those registry entries is acceptable first.

All three are one-line changes gated on whether you want the checkpoint re-exported; happy to do them together if so.

The heavy CI jobs are sitting on action_required for this fork again whenever one of you gets a chance.

@DN6DN6 added this to the Release 0.41.0 milestone Sep 7, 2026
@dg845

dg845 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

@bot /style

@github-actions

github-actionsBot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Style bot fixed some files and pushed the changes.

github-actionsBotand others added 2 commits September 8, 2026 00:33
`check_forward_call_docstrings` flagged `SanaWMLTX2Refiner.__call__` as
missing an entry for `output_type`, which the un-nesting added.

@yiyixuxuyiyixuxu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, i did another round of review on the transformer model and left more comments

Comment on lines +23 to +25
The state-dict layout matches the public SANA-WM release one-to-one — the diffusers wrapper places the inner DiT
under a `_inner.` prefix. See [`SanaWMTransformer3DModel.add_inner_prefix`] for the helper used by the conversion
script.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The state-dict layout matches the public SANA-WM release one-to-one — the diffusers wrapper places the inner DiT
under a `_inner.` prefix. See [`SanaWMTransformer3DModel.add_inner_prefix`] for the helper used by the conversion
script.

diffusers has its own checkpoint, no?

Comment on lines +126 to +137
# String-keyed registry for the GDN/softmax attention block variants used by the SANA-WM DiT.
# `SanaWMTransformer3DModel` looks classes up here by its `attn_type` / `camctrl_type` config strings.
# Populated after the class definitions below.
ATTENTION_BLOCKS: dict[str, type] = {}


def _resolve_attention_block(name: str, *, role: str) -> type:
"""Look up a registered attention class by its config string."""
cls = ATTENTION_BLOCKS.get(name)
if cls is None:
raise ValueError(f"Unknown {role}: {name!r}. Available: {sorted(ATTENTION_BLOCKS)}")
return cls

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# String-keyed registry for the GDN/softmax attention block variants used by the SANA-WM DiT.
# `SanaWMTransformer3DModel` looks classes up here by its `attn_type` / `camctrl_type` config strings.
# Populated after the class definitions below.
ATTENTION_BLOCKS: dict[str, type] = {}
def_resolve_attention_block(name: str, *, role: str) ->type:
"""Look up a registered attention class by its config string."""
cls=ATTENTION_BLOCKS.get(name)
ifclsisNone:
raiseValueError(f"Unknown {role}: {name!r}. Available: {sorted(ATTENTION_BLOCKS)}")
returncls

can we just use the map inline?

Comment on lines +3131 to +3147
# Name used by the `camctrl_type` config string and the block-name mappings below.
BidirectionalSoftmaxUCPESinglePathLiteLA = _SoftmaxUCPESinglePathLiteLA


# The released `config.json` names the fused-Triton variants (`attn_type="BidirectionalGDNTriton"`,
# `camctrl_type="BidirectionalGDNUCPESinglePathLiteLABothTriton"`). The Triton kernels now live outside
# `diffusers`, so those names resolve to the equivalent pure-PyTorch implementations.
ATTENTION_BLOCKS.update(
{
"GDN": GDN,
"BidirectionalGDN": BidirectionalGDN,
"BidirectionalGDNTriton": BidirectionalGDN,
"BidirectionalGDNUCPESinglePathLiteLA": BidirectionalGDNUCPESinglePathLiteLA,
"BidirectionalGDNUCPESinglePathLiteLATriton": BidirectionalGDNUCPESinglePathLiteLA,
"BidirectionalGDNUCPESinglePathLiteLABothTriton": BidirectionalGDNUCPESinglePathLiteLA,
}
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Name used by the `camctrl_type` config string and the block-name mappings below.
BidirectionalSoftmaxUCPESinglePathLiteLA=_SoftmaxUCPESinglePathLiteLA
# The released `config.json` names the fused-Triton variants (`attn_type="BidirectionalGDNTriton"`,
# `camctrl_type="BidirectionalGDNUCPESinglePathLiteLABothTriton"`). The Triton kernels now live outside
# `diffusers`, so those names resolve to the equivalent pure-PyTorch implementations.
ATTENTION_BLOCKS.update(
{
"GDN": GDN,
"BidirectionalGDN": BidirectionalGDN,
"BidirectionalGDNTriton": BidirectionalGDN,
"BidirectionalGDNUCPESinglePathLiteLA": BidirectionalGDNUCPESinglePathLiteLA,
"BidirectionalGDNUCPESinglePathLiteLATriton": BidirectionalGDNUCPESinglePathLiteLA,
"BidirectionalGDNUCPESinglePathLiteLABothTriton": BidirectionalGDNUCPESinglePathLiteLA,
}
)

let;s just support the attn_type and camctrl_type of released checkpoint

padding=(t_kernel_size // 2, 0),
bias=False,
)
nn.init.zeros_(self.t_conv.weight)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
nn.init.zeros_(self.t_conv.weight)

Comment on lines +1196 to +1197
def restore_shape(tensor, target_d):
return tensor.permute(0, 1, 3, 2, 4).reshape(B, H, target_d, N)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defrestore_shape(tensor, target_d):
returntensor.permute(0, 1, 3, 2, 4).reshape(B, H, target_d, N)

can you inline this? just one line

num_heads,
mlp_ratio=mlp_ratio,
qk_norm=qk_norm,
attn_type=attn_type_list[i],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
attn_type=attn_type_list[i],

linear_head_dim=linear_head_dim,
cross_norm=cross_norm,
t_kernel_size=t_kernel_size,
camctrl_type=camctrl_type_list[i],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
camctrl_type=camctrl_type_list[i],
attn_cls=attn_cls_list[i],

Comment on lines +3622 to +3626
attn_type_list, camctrl_type_list = _inject_softmax_layers(
attn_type_list,
camctrl_type_list,
softmax_every_n,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
attn_type_list, camctrl_type_list=_inject_softmax_layers(
attn_type_list,
camctrl_type_list,
softmax_every_n,
)
foriinrange(depth):
ifsoftmax_every_n>0and (i+1) %softmax_every_n==0:
attn_cls=_SoftmaxUCPESinglePathLiteLA
else:
attn_cls=xx
attn_cls_list.append(attn_cls)

frame_valid_mask=frame_valid_mask,
ucpe_ray_transforms=ucpe_ray_transforms,
)
else:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove unuused branch here?

return x_out.type_as(hidden_states)


class GDN(nn.Module):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible for us to only support what the released checkopint actually uses? if so, can we clean up all the class inherit from this accordingly? i.e remove all the classes not used by the released checkopint, try to flatten the ones that we have to keep?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationmodelspipelinessize/LPR with diff > 200 LOCtestsutils

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

7 participants

@lawrence-cj@dg845@HuggingFaceDocBuilderDev@yiyixuxu@sayakpaul@DN6@HaoyiZhu