Skip to content

Commit 41ec7e8

Browse files
committed
Fix long camera inference decode and scale guards
1 parent 41c6b4b commit 41ec7e8

8 files changed

Lines changed: 247 additions & 9 deletions

File tree

configs/examples/wan22_ti2v_5b/infer_stage2_sgf_camera_length.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ validation:
8787
sample_count: 1
8888
selection_seed: 42
8989
noise_seed: 42
90+
max_rel_translation: null
91+
max_camera_abs: null
9092
passes:
9193
- {name: model_self_forcing_nfe4, weights: model, mode: autoregressive, solver: self_forcing, num_inference_steps: 4}
9294
checkpoint:

docs/backends/wan22-ti2v-5b.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,13 @@ torchrun --standalone --nproc-per-node=1 -m solarwm infer \
147147
--set runtime.output_dir="$SOLAR_OUTPUT_ROOT/wan5-stage2-sgf-infer"
148148
```
149149

150-
The Stage2 command loads the checkpoint directory as one model and uses the
151-
longest camera-backed horizon available for each selected test sample. The
152-
horizon is rounded down to complete three-latent chunks; at the configured
153-
16 fps, a 960-frame camera track produces 240 latents and 957 decoded frames.
150+
The Stage2 command loads the checkpoint directory as one model, resolves its
151+
weight role from `release-manifest.json` (`ema` for the published checkpoint),
152+
and uses the longest camera-backed horizon available for each selected test
153+
sample. The horizon is rounded down to complete three-latent chunks; at the
154+
configured 16 fps, a 960-frame camera track produces 240 latents and 957
155+
decoded frames. Outputs longer than 60 latents are VAE-decoded as consecutive
156+
60-latent tiles with one continuous temporal cache.
154157
Override `data.test_index` to select a different raw-WDS test index. Outputs
155158
are written below `runtime.output_dir/generation`.
156159

skills/solar-training-ops/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ the weight selection declared by the chosen route before model allocation.
221221
The Wan5B Stage2 camera-length inference route accepts the released checkpoint
222222
directory as one model and does not expose LIVE/EMA selection. Accept a saved
223223
checkpoint only after its completion marker and declared members are durable.
224+
Resolve the model role from `release-manifest.json` and retain that resolved
225+
role in output provenance. Decode horizons longer than 60 latents as consecutive
226+
cached VAE tiles, clearing the temporal cache only before and after the sequence.
224227

225228
### 7. Keep inference and validation aligned
226229

src/solarwm/backends/wan22/runtime/components.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,48 @@ def decode(self, latents_btchw: Any, *, use_cache: bool = False) -> Any:
218218
output.append(decoded.float().clamp_(-1, 1).squeeze(0))
219219
return torch.stack(output, dim=0).permute(0, 2, 1, 3, 4)
220220

221+
def decode_streaming(
222+
self,
223+
latents_btchw: Any,
224+
*,
225+
chunk_latent_frames: int = 60,
226+
) -> Any:
227+
"""Decode consecutive temporal tiles with one continuous VAE cache."""
228+
229+
import torch
230+
231+
if latents_btchw.ndim != 5 or int(latents_btchw.shape[1]) <= 0:
232+
raise BackendContractError("Wan streaming VAE decode requires non-empty BTCHW latents")
233+
if chunk_latent_frames <= 0:
234+
raise BackendContractError("Wan streaming VAE chunk size must be positive")
235+
clear_cache = getattr(self.module, "clear_cache", None)
236+
cached_decode = getattr(self.module, "cached_decode", None)
237+
if not callable(clear_cache) or not callable(cached_decode):
238+
raise BackendContractError(
239+
"Wan streaming VAE decode requires cache-aware decoder methods"
240+
)
241+
242+
clips = latents_btchw.permute(0, 2, 1, 3, 4)
243+
output = []
244+
for clip in clips:
245+
decoded_chunks = []
246+
clear_cache()
247+
try:
248+
for start in range(0, int(clip.shape[1]), chunk_latent_frames):
249+
chunk = clip[:, start : start + chunk_latent_frames].contiguous()
250+
with torch.autocast(device_type=chunk.device.type, dtype=chunk.dtype):
251+
decoded = cached_decode(chunk.unsqueeze(0), self._scale(chunk))
252+
if not bool(torch.isfinite(decoded).all().item()):
253+
raise BackendContractError(
254+
"Wan streaming VAE decode produced non-finite pixels"
255+
)
256+
decoded_chunks.append(decoded.float().clamp_(-1, 1).squeeze(0).cpu())
257+
del decoded
258+
finally:
259+
clear_cache()
260+
output.append(torch.cat(decoded_chunks, dim=1))
261+
return torch.stack(output, dim=0).permute(0, 2, 1, 3, 4)
262+
221263

222264
class WanA14BVAE:
223265
"""Official deterministic 16-channel Wan2.1 VAE used by I2V-A14B."""

src/solarwm/backends/wan22/runtime/stage2.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import copy
1111
import gc
12+
import json
1213
import math
1314
import os
1415
import random
@@ -61,6 +62,7 @@
6162

6263
_CHECKPOINT_FORMAT = "solarwm.wan22-stage2-sgf.v1"
6364
_TORCHRUN_OWNER_ENV = "SOLARWM_TORCHRUN_LIFECYCLE_OWNER"
65+
_STREAMING_VAE_LATENT_CHUNK = 60
6466

6567

6668
class Stage2GenerationRunner(Protocol):
@@ -830,9 +832,19 @@ def _stage2_generated_sample(
830832
generation_pass.rollout_latent_frames,
831833
)
832834
)
833-
decoded = provider.vae.decode(
834-
latents[:, :output_latent_frames].contiguous(), use_cache=False
835-
)
835+
output_latents = latents[:, :output_latent_frames].contiguous()
836+
if output_latent_frames > _STREAMING_VAE_LATENT_CHUNK:
837+
decoded = provider.vae.decode_streaming(
838+
output_latents,
839+
chunk_latent_frames=_STREAMING_VAE_LATENT_CHUNK,
840+
)
841+
vae_decode = {
842+
"mode": "continuous_cached_tiles",
843+
"chunk_latent_frames": _STREAMING_VAE_LATENT_CHUNK,
844+
}
845+
else:
846+
decoded = provider.vae.decode(output_latents, use_cache=False)
847+
vae_decode = {"mode": "direct", "chunk_latent_frames": output_latent_frames}
836848
finite_fraction = float(torch.isfinite(decoded).float().mean().item())
837849
if finite_fraction != 1.0:
838850
raise BackendContractError("Stage2 VAE decode produced non-finite pixels")
@@ -868,6 +880,10 @@ def _stage2_generated_sample(
868880
"camera_translation_transform": str(
869881
provider.config["model"]["camera_translation_transform"]
870882
),
883+
"resolved_weights_role": str(
884+
getattr(provider, "_model_weight_role", generation_pass.weights)
885+
),
886+
"vae_decode": vae_decode,
871887
},
872888
)
873889

@@ -1040,6 +1056,38 @@ def _verified_stage2_inference_checkpoint(
10401056
return requested, verified.manifest_digest, int(verified.step)
10411057

10421058

1059+
def _published_default_weight_role(source: str | Path) -> str:
1060+
"""Resolve the release-selected live/EMA role without exposing a CLI choice."""
1061+
1062+
requested = Path(source).expanduser().resolve()
1063+
root = requested if requested.is_dir() else requested.parent
1064+
manifest_path = root / "release-manifest.json"
1065+
try:
1066+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
1067+
except Exception as exc:
1068+
raise BackendContractError(
1069+
f"Stage2 camera-length checkpoint lacks a readable release manifest: {exc}"
1070+
) from exc
1071+
if not isinstance(manifest, Mapping):
1072+
raise BackendContractError("Stage2 release manifest must be a mapping")
1073+
load = manifest.get("load")
1074+
identity = manifest.get("identity")
1075+
model = identity.get("model") if isinstance(identity, Mapping) else None
1076+
if (
1077+
manifest.get("schema") != "solarwm.public-weight-manifest.v1"
1078+
or not isinstance(load, Mapping)
1079+
or load.get("format") != "solarwm_wan_stage2_transaction_v1"
1080+
or load.get("entrypoint") != "."
1081+
or not isinstance(model, Mapping)
1082+
):
1083+
raise BackendContractError("Stage2 release manifest contract differs")
1084+
role = str(load.get("default_weights", "")).strip().lower()
1085+
available = str(model.get("weight_role", "")).strip().lower().split("+")
1086+
if role not in {"live", "ema"} or role not in available:
1087+
raise BackendContractError("Stage2 release default weight role is invalid")
1088+
return role
1089+
1090+
10431091
def _plain(value: Any) -> Any:
10441092
if isinstance(value, Mapping):
10451093
return {str(key): _plain(item) for key, item in value.items()}
@@ -2236,6 +2284,11 @@ def __init__(self, values: Mapping[str, Any], generation_plan: Any) -> None:
22362284
values,
22372285
str(values["checkpoint"]["path"]),
22382286
)
2287+
self._model_weight_role = (
2288+
_published_default_weight_role(values["checkpoint"]["path"])
2289+
if self._direct_model
2290+
else None
2291+
)
22392292
self.checkpoint_id = f"manifest:{checkpoint_manifest_id}"
22402293
layout = WanAssetLayout.from_config(values)
22412294
self.diffusion = build_diffusion_architecture(values)
@@ -2253,12 +2306,12 @@ def _root(module: Any) -> Any:
22532306

22542307
def weight_id(self, role: str) -> str:
22552308
if role == "model" and self._direct_model:
2256-
return self.checkpoint_id
2309+
return f"{self.checkpoint_id}#release-default:{self._model_weight_role}"
22572310
return super().weight_id(role)
22582311

22592312
def _checkpoint_state_field(self, role: str) -> str:
22602313
if role == "model" and self._direct_model:
2261-
return "generator_ema"
2314+
return super()._checkpoint_state_field(str(self._model_weight_role))
22622315
return super()._checkpoint_state_field(role)
22632316

22642317
def allocate_kv_cache(

tests/backends/wan22/test_configs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ def test_stage2_camera_length_inference_selects_one_direct_model() -> None:
157157
assert config["checkpoint"] == {"path": "/path/to/SolarWM-models/SolarWM-5B-sgf-stage2-81f"}
158158
assert config["inference"] == {"source": "validation", "length": "camera"}
159159
assert config["data"]["random_start"] is False
160+
assert config["validation"]["max_rel_translation"] is None
161+
assert config["validation"]["max_camera_abs"] is None
160162
assert [item["weights"] for item in config["validation"]["passes"]] == ["model"]
161163

162164

tests/backends/wan22/test_stage2_runtime.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from solarwm.backends.wan22.runtime.stage2 import (
1212
RoleCheckpointReceipt,
1313
Wan5BStage2Runtime,
14+
_published_default_weight_role,
1415
_stage2_generated_sample,
1516
_stage2_initialization_receipt,
1617
_stage2_self_forcing_latents,
@@ -265,6 +266,97 @@ def test_stage2_generated_sample_preserves_configured_denoising_steps(
265266
assert generated.provenance["denoising_step_list"] == sentinel
266267

267268

269+
def test_stage2_long_generation_uses_streaming_vae_tiles(
270+
monkeypatch: pytest.MonkeyPatch,
271+
) -> None:
272+
torch = pytest.importorskip("torch")
273+
from solarwm.backends.wan22.runtime import inference, stage2
274+
275+
calls: list[int] = []
276+
277+
class _VAE:
278+
@staticmethod
279+
def decode_streaming(value: object, *, chunk_latent_frames: int) -> object:
280+
calls.extend((int(value.shape[1]), chunk_latent_frames))
281+
return torch.zeros((1, 957, 3, 1, 1))
282+
283+
@staticmethod
284+
def decode(*_args: object, **_kwargs: object) -> object:
285+
raise AssertionError("long Stage2 generation must not use one-shot VAE decode")
286+
287+
monkeypatch.setattr(
288+
stage2,
289+
"_stage2_self_forcing_latents",
290+
lambda *_: (
291+
torch.zeros((1, 240, 1, 1, 1)),
292+
{"timesteps": [1000, 750, 500, 250]},
293+
),
294+
)
295+
monkeypatch.setattr(inference, "_encode_compare_mp4", lambda *_args, **_kwargs: b"compare")
296+
provider = SimpleNamespace(
297+
device=torch.device("cpu"),
298+
config={
299+
"data": {"fps": 16.0},
300+
"model": {"camera_translation_transform": "linear"},
301+
"train": {"denoising_step_list": [1000, 750, 500, 250]},
302+
},
303+
_conditions=lambda *_args, **_kwargs: (
304+
torch.zeros((1, 1, 1, 1, 1)),
305+
{},
306+
{},
307+
None,
308+
),
309+
vae=_VAE(),
310+
video_encoder=lambda *_args, **_kwargs: b"video",
311+
_prepared={0: object()},
312+
_model_weight_role="ema",
313+
)
314+
case = SimpleNamespace(
315+
slot=0,
316+
noise_seed=42,
317+
metadata={
318+
"generation_pass": {
319+
"name": "model_self_forcing_nfe4",
320+
"weights": "model",
321+
"mode": "autoregressive",
322+
"solver": "self_forcing",
323+
"num_inference_steps": 4,
324+
"rollout_latent_frames": 240,
325+
}
326+
},
327+
)
328+
329+
generated = _stage2_generated_sample(provider, case, weights_id="release#ema")
330+
331+
assert calls == [240, 60]
332+
assert generated.shape == (1, 957, 3, 1, 1)
333+
assert generated.provenance["resolved_weights_role"] == "ema"
334+
assert generated.provenance["vae_decode"] == {
335+
"mode": "continuous_cached_tiles",
336+
"chunk_latent_frames": 60,
337+
}
338+
339+
340+
def test_stage2_camera_length_resolves_release_default_weights(tmp_path: Path) -> None:
341+
manifest = {
342+
"schema": "solarwm.public-weight-manifest.v1",
343+
"identity": {"model": {"weight_role": "live+ema"}},
344+
"load": {
345+
"default_weights": "ema",
346+
"entrypoint": ".",
347+
"format": "solarwm_wan_stage2_transaction_v1",
348+
},
349+
}
350+
(tmp_path / "release-manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
351+
352+
assert _published_default_weight_role(tmp_path) == "ema"
353+
354+
manifest["load"]["default_weights"] = "unknown"
355+
(tmp_path / "release-manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
356+
with pytest.raises(BackendContractError, match="default weight role is invalid"):
357+
_published_default_weight_role(tmp_path)
358+
359+
268360
def test_stage2_unconditional_matches_conditional_dtype() -> None:
269361
torch = pytest.importorskip("torch")
270362

@@ -432,6 +524,7 @@ def to(self, *args: object, **kwargs: object) -> _Movable:
432524
"_verified_stage2_inference_checkpoint",
433525
lambda *_: (tmp_path / "model.pt", "b" * 64, 200),
434526
)
527+
monkeypatch.setattr(stage2, "_published_default_weight_role", lambda *_: "ema")
435528
monkeypatch.setattr(
436529
stage2.WanAssetLayout,
437530
"from_config",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from solarwm.backends.wan22.runtime.components import Wan5BVAE
6+
7+
8+
def test_wan5b_streaming_decode_keeps_one_cache_across_temporal_tiles() -> None:
9+
torch = pytest.importorskip("torch")
10+
11+
class _Module:
12+
def __init__(self) -> None:
13+
self.clear_calls = 0
14+
self.chunk_sizes: list[int] = []
15+
self.first = True
16+
17+
def clear_cache(self) -> None:
18+
self.clear_calls += 1
19+
self.first = True
20+
21+
def cached_decode(self, value: object, _scale: object) -> object:
22+
latent_frames = int(value.shape[2])
23+
self.chunk_sizes.append(latent_frames)
24+
pixel_frames = 1 + 4 * (latent_frames - 1) if self.first else 4 * latent_frames
25+
self.first = False
26+
return torch.zeros((1, 3, pixel_frames, 1, 1), dtype=torch.bfloat16)
27+
28+
module = _Module()
29+
vae = Wan5BVAE.__new__(Wan5BVAE)
30+
vae.module = module
31+
vae._mean = torch.zeros(48, dtype=torch.float32)
32+
vae._std = torch.ones(48, dtype=torch.float32)
33+
latents = torch.zeros((1, 240, 48, 1, 1), dtype=torch.bfloat16)
34+
35+
decoded = vae.decode_streaming(latents, chunk_latent_frames=60)
36+
37+
assert module.chunk_sizes == [60, 60, 60, 60]
38+
assert module.clear_calls == 2
39+
assert decoded.device.type == "cpu"
40+
assert decoded.shape == (1, 957, 3, 1, 1)

0 commit comments

Comments
 (0)