multimodalart HF Staff commited on
Commit
1811aab
·
verified ·
1 Parent(s): 4afa793

prep stage

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +8 -8
  2. app.py +124 -0
  3. cwm_h3_inference/__init__.py +6 -0
  4. cwm_h3_inference/__main__.py +3 -0
  5. cwm_h3_inference/cache.py +321 -0
  6. cwm_h3_inference/cli.py +53 -0
  7. cwm_h3_inference/config.py +174 -0
  8. cwm_h3_inference/constants.py +43 -0
  9. cwm_h3_inference/duv.py +72 -0
  10. cwm_h3_inference/engine.py +340 -0
  11. cwm_h3_inference/io.py +44 -0
  12. cwm_h3_inference/presentation.py +63 -0
  13. cwm_h3_inference/prompts/system_w0.txt +51 -0
  14. cwm_h3_inference/prompts/system_wn.txt +57 -0
  15. cwm_h3_inference/runtime.py +78 -0
  16. materialize_hf_examples.py +369 -0
  17. musubi_tuner/__init__.py +0 -0
  18. musubi_tuner/minimax_h3/__init__.py +1 -0
  19. musubi_tuner/minimax_h3/audio_vae.py +467 -0
  20. musubi_tuner/minimax_h3/checkpoint.py +154 -0
  21. musubi_tuner/minimax_h3/media.py +299 -0
  22. musubi_tuner/minimax_h3/model.py +952 -0
  23. musubi_tuner/minimax_h3/packing.py +591 -0
  24. musubi_tuner/minimax_h3/sampling.py +396 -0
  25. musubi_tuner/minimax_h3/text_encoder.py +384 -0
  26. musubi_tuner/minimax_h3/video_vae.py +686 -0
  27. musubi_tuner/modules/__init__.py +0 -0
  28. musubi_tuner/modules/adafactor_fused.py +141 -0
  29. musubi_tuner/modules/attention.py +297 -0
  30. musubi_tuner/modules/custom_offloading_utils.py +1096 -0
  31. musubi_tuner/modules/fp8_optimization_utils.py +564 -0
  32. musubi_tuner/modules/lr_schedulers.py +67 -0
  33. musubi_tuner/modules/scheduling_flow_match_discrete.py +257 -0
  34. musubi_tuner/modules/unet_causal_3d_blocks.py +818 -0
  35. musubi_tuner/utils/__init__.py +0 -0
  36. musubi_tuner/utils/device_utils.py +28 -0
  37. musubi_tuner/utils/huggingface_utils.py +89 -0
  38. musubi_tuner/utils/image_utils.py +40 -0
  39. musubi_tuner/utils/lora_utils.py +300 -0
  40. musubi_tuner/utils/model_utils.py +295 -0
  41. musubi_tuner/utils/safetensors_utils.py +483 -0
  42. musubi_tuner/utils/sai_model_spec.py +403 -0
  43. musubi_tuner/utils/train_utils.py +201 -0
  44. requirements.txt +10 -0
  45. rtcwm/__init__.py +3 -0
  46. rtcwm/data/__init__.py +2 -0
  47. rtcwm/data/anchors.py +60 -0
  48. rtcwm/data/captions.py +184 -0
  49. rtcwm/data/clip.py +85 -0
  50. rtcwm/data/geometry.py +72 -0
README.md CHANGED
@@ -1,13 +1,13 @@
1
  ---
2
  title: Streaming Code World Model
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Streaming Code World Model
3
+ emoji: 🌍
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 5.50.0
 
8
  app_file: app.py
9
+ short_description: Causal chunk-by-chunk world-model video streaming
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  ---
13
+ (prep stage)
 
app.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TEMPORARY prep stage: encode the conditioning caches of a few released CWM examples, exactly as
2
+ `python -m rtcwm.eval.open_world prepare` does (Qwen3-VL-32B states of the static caption, Picture 1 latent,
3
+ proxy (Depth + Semantic-ID) latents, silent audio latent). Replaced by the real demo afterwards."""
4
+ import os
5
+ import sys
6
+
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+
9
+ import spaces
10
+ import io
11
+ import json
12
+ import shutil
13
+ import tempfile
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import torch
19
+ import gradio as gr
20
+ from huggingface_hub import hf_hub_download, snapshot_download
21
+ from PIL import Image
22
+ from safetensors.torch import save_file
23
+
24
+ H3_REPO, H3_REV = "Comfy-Org/MiniMax-H3", "0543966fbdce5ba05709a8f2031c94bdba629b4a"
25
+ QWEN_REPO, QWEN_REV = "Qwen/Qwen3-VL-32B-Instruct", "0cfaf48183f594c314753d30a4c4974bc75f3ccb"
26
+ EX_REPO, EX_REV = "NTU-yiwen/code-world-model-inference-examples-40", "f8307bf50cb19bed191682734fdf444738160f91"
27
+ EXAMPLES = [3, 6, 8, 29, 31, 36]
28
+ CHUNKS = 24
29
+ CLIP_FRAMES = 17
30
+
31
+ models = Path(snapshot_download(H3_REPO, revision=H3_REV, allow_patterns=[
32
+ "text_encoders/qwen3vl_32b_minimax_h3_bf16.safetensors", "vae/minimax_h3_video_vae_fp16.safetensors",
33
+ "vae/minimax_h3_audio_vae_fp32.safetensors"]))
34
+ proc_dir = Path(snapshot_download(QWEN_REPO, revision=QWEN_REV, allow_patterns=[
35
+ "chat_template.json", "config.json", "generation_config.json", "merges.txt", "preprocessor_config.json",
36
+ "tokenizer_config.json", "tokenizer.json", "video_preprocessor_config.json", "vocab.json"]))
37
+ src = Path(snapshot_download(EX_REPO, repo_type="dataset", revision=EX_REV, allow_patterns=[
38
+ "manifest.json", "semantic_id_palette.json"] + [f"{i}/*" for i in EXAMPLES] + [f"{i}/**" for i in EXAMPLES]))
39
+
40
+ import materialize_hf_examples as mat
41
+
42
+ REL = Path("/tmp/release")
43
+ for i in EXAMPLES:
44
+ mat.materialize_example(src, REL, i, require_config=False, frame_limit=(CHUNKS + 1) * CLIP_FRAMES)
45
+ print("materialized", flush=True)
46
+
47
+ from musubi_tuner.minimax_h3.audio_vae import load_audio_vae
48
+ from musubi_tuner.minimax_h3.text_encoder import load_h3_processor, load_h3_text_encoder
49
+ from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae
50
+
51
+ vae = load_video_vae(models / "vae/minimax_h3_video_vae_fp16.safetensors", device="cpu", dtype=torch.float16).eval().requires_grad_(False)
52
+ vae.to("cuda")
53
+ avae = load_audio_vae(models / "vae/minimax_h3_audio_vae_fp32.safetensors", device="cpu", dtype=torch.float32).eval()
54
+ avae.to("cuda")
55
+ proc = load_h3_processor(str(proc_dir))
56
+ qwen = load_h3_text_encoder(str(models / "text_encoders/qwen3vl_32b_minimax_h3_bf16.safetensors"),
57
+ processor_path=str(proc_dir), device="cpu", dtype=torch.bfloat16).eval()
58
+ qwen.to("cuda")
59
+ print("models loaded", flush=True)
60
+
61
+
62
+ def _prepare_pixels(frames): # musubi_tuner.minimax_h3_generate_video._prepare_pixels
63
+ if frames.dtype == torch.uint8:
64
+ frames = frames.float().div(127.5).sub(1.0)
65
+ else:
66
+ frames = frames.float().mul(2.0).sub(1.0)
67
+ return frames.permute(3, 0, 1, 2).unsqueeze(0).contiguous()
68
+
69
+
70
+ @spaces.GPU(duration=600, size="xlarge")
71
+ def prep() -> str:
72
+ """Encode the example caches and return a zip."""
73
+ from cwm_h3_inference.cache import _load_anchor
74
+ from cwm_h3_inference.config import _canonical_caption
75
+ from cwm_h3_inference.constants import TARGET_HEIGHT, TARGET_WIDTH, WINDOW_AUDIO_SAMPLES
76
+ from cwm_h3_inference.duv import load_duv_frame
77
+ from rtcwm.eval.open_world import static_caption
78
+ from rtcwm.train.preencode import static_text_states
79
+
80
+ dev = torch.device("cuda")
81
+ out = Path(tempfile.mkdtemp())
82
+ with torch.inference_mode():
83
+ a = avae.encode(torch.zeros(1, 2, WINDOW_AUDIO_SAMPLES, device=dev)).float().cpu()[0]
84
+ save_file({"audio": a.contiguous()}, str(out / "silence_audio.safetensors"))
85
+ log = []
86
+ for i in EXAMPLES:
87
+ ex = REL / "examples" / f"example_{i:02d}"
88
+ cond = ex / "conditions"
89
+ prompt = (src / str(i) / "prompts" / "000.txt").read_text()
90
+ k = min(CHUNKS, len(list(cond.glob("*.semantic_id.png"))) // CLIP_FRAMES)
91
+ duv = np.stack([load_duv_frame(cond, f, for_qwen=False) for f in range(k * CLIP_FRAMES)])
92
+ duv = np.concatenate([duv, np.repeat(duv[-1:], CLIP_FRAMES, 0)], 0)
93
+ with torch.inference_mode():
94
+ ref = encode_video_condition(vae, _prepare_pixels(torch.from_numpy(duv)).to(dev, torch.float16))[0]
95
+ ref = ref.float().cpu()[:, :5 * k].contiguous()
96
+ picture1 = _load_anchor(ex / "first_frame.png", size=(TARGET_WIDTH, TARGET_HEIGHT))
97
+ png = out / f"ex{i:02d}_anchor.png"
98
+ Image.fromarray(picture1).save(png)
99
+ with torch.inference_mode():
100
+ lat = encode_video_condition(vae, _prepare_pixels(torch.from_numpy(picture1).unsqueeze(0)).to(dev, torch.float16))
101
+ caption = _canonical_caption(static_caption(prompt), f"ow_example_{i:02d}/static_c00")
102
+ hidden, tags = static_text_states(proc, qwen, picture1, png, cond, 0, caption)
103
+ sem = np.stack([np.asarray(Image.open(cond / f"{f:06d}.semantic_id.png")) for f in range(k * CLIP_FRAMES)])
104
+ np.savez_compressed(out / f"ex{i:02d}_semantic.npz", semantic_id=sem.astype(np.uint8))
105
+ save_file({"duv": ref, "anchor": lat[0].float().cpu().contiguous(),
106
+ "hidden": hidden.to("cpu", torch.bfloat16).contiguous(), "tags": tags.to("cpu", torch.int64).contiguous()},
107
+ str(out / f"ex{i:02d}.safetensors"), metadata={"chunks": str(k), "caption": caption})
108
+ log.append(dict(id=i, chunks=k, duv=list(ref.shape), anchor=list(lat.shape), hidden=list(hidden.shape)))
109
+ print(log[-1], flush=True)
110
+ (out / "log.json").write_text(json.dumps(log))
111
+ z = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name
112
+ with zipfile.ZipFile(z, "w", zipfile.ZIP_STORED) as zf:
113
+ for p in out.iterdir():
114
+ zf.write(p, p.name)
115
+ return z
116
+
117
+
118
+ with gr.Blocks() as demo:
119
+ gr.Markdown("Preparing example caches (temporary).")
120
+ b = gr.Button("prep")
121
+ f = gr.File()
122
+ b.click(prep, None, f, api_name="prep")
123
+
124
+ demo.launch()
cwm_h3_inference/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """MiniMax H3 inference."""
2
+
3
+ from .config import RunConfig, load_config
4
+
5
+ __all__ = ["RunConfig", "load_config"]
6
+ __version__ = "0.1.0"
cwm_h3_inference/__main__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cwm_h3_inference/cache.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prepare the DUV, RGB-anchor, fixed-first-frame, and Qwen caches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import hashlib
7
+ from io import BytesIO
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .config import RunConfig
13
+ from .constants import (
14
+ ANCHOR_SHORT_EDGE,
15
+ CACHE_SCHEMA,
16
+ CANVAS_MULTIPLE,
17
+ CONDITION_HEIGHT,
18
+ CONDITION_WIDTH,
19
+ FPS,
20
+ QWEN_OFFSETS,
21
+ REFERENCE_LATENT_SHAPE,
22
+ SINGLE_FRAME_LATENT_SHAPE,
23
+ TARGET_HEIGHT,
24
+ TARGET_WIDTH,
25
+ VIDEO_LATENT_SHAPE,
26
+ )
27
+ from .duv import load_duv_window
28
+ from .io import atomic_json, atomic_safetensors, load_json
29
+ from .presentation import _system_prompt, build_ref2va_presentation
30
+
31
+
32
+ def official_short_edge_size(width: int, height: int) -> tuple[int, int]:
33
+ if min(width, height) <= 0 or width > 4 * height or height > 4 * width:
34
+ raise ValueError("anchor aspect ratio must be between 1:4 and 4:1")
35
+ scale = ANCHOR_SHORT_EDGE / min(width, height)
36
+
37
+ def round_axis(value: float) -> int:
38
+ return max(CANVAS_MULTIPLE, round(value / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
39
+
40
+ return round_axis(width * scale), round_axis(height * scale)
41
+
42
+
43
+ def _load_anchor(path: Path, *, size: tuple[int, int] | None = None):
44
+ import numpy as np
45
+ from PIL import Image
46
+
47
+ payload = path.read_bytes()
48
+ with Image.open(BytesIO(payload)) as image:
49
+ image = image.convert("RGB")
50
+ target = official_short_edge_size(*image.size) if size is None else size
51
+ pixels = np.asarray(image.resize(target, Image.Resampling.LANCZOS), dtype=np.uint8).copy()
52
+ return pixels
53
+
54
+
55
+ def cache_paths(config: RunConfig, window_index: int) -> dict[str, Path]:
56
+ root = config.cache_dir
57
+ window = root / f"window_{window_index:03d}"
58
+ return {
59
+ "anchor": root / "anchor_reference.safetensors",
60
+ "input_video": root / "input_video.safetensors",
61
+ "reference": window / "duv_reference.safetensors",
62
+ "qwen": window / "qwen.safetensors",
63
+ "manifest": root / "manifest.json",
64
+ }
65
+
66
+
67
+ def _cache_contract(config: RunConfig) -> dict[str, Any]:
68
+ return {
69
+ "video_vae": str(config.models.video_vae),
70
+ "text_encoder": str(config.models.text_encoder),
71
+ "processor": str(config.models.processor),
72
+ "anchor_image": str(config.anchor_image),
73
+ "anchor_bytes": config.anchor_image.stat().st_size,
74
+ "condition_root": str(config.condition_root),
75
+ "system_prompt_w0": _system_prompt(0),
76
+ "system_prompt_wn": _system_prompt(1),
77
+ "windows": [
78
+ {"prompt": window.prompt, "start_frame": window.start_frame}
79
+ for window in config.windows
80
+ ],
81
+ }
82
+
83
+
84
+ def _contract_id(value: dict[str, Any]) -> str:
85
+ payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
86
+ return hashlib.sha256(payload).hexdigest()
87
+
88
+
89
+ def _begin_manifest(config: RunConfig) -> dict[str, Any]:
90
+ path = cache_paths(config, 0)["manifest"]
91
+ contract = _cache_contract(config)
92
+ contract_id = _contract_id(contract)
93
+ if path.exists():
94
+ existing = load_json(path)
95
+ if existing.get("schema") != CACHE_SCHEMA or existing.get("contract_id") != contract_id:
96
+ raise ValueError(f"cache directory belongs to another run: {config.cache_dir}")
97
+ return existing
98
+ if config.cache_dir.exists() and any(config.cache_dir.iterdir()):
99
+ raise ValueError(f"non-empty cache directory has no manifest: {config.cache_dir}")
100
+ manifest = {
101
+ "schema": CACHE_SCHEMA,
102
+ "status": "preparing",
103
+ "contract_id": contract_id,
104
+ "contract": contract,
105
+ "window_count": len(config.windows),
106
+ "output_frames": config.output_frames,
107
+ "files": [],
108
+ }
109
+ atomic_json(path, manifest)
110
+ return manifest
111
+
112
+
113
+ def _require_one_gpu(torch: Any) -> Any:
114
+ if not torch.cuda.is_available() or torch.cuda.device_count() != 1:
115
+ raise RuntimeError("cache preparation requires exactly one visible CUDA GPU")
116
+ torch.cuda.set_device(0)
117
+ return torch.device("cuda:0")
118
+
119
+
120
+ def _prepare_visual_caches(config: RunConfig) -> None:
121
+ import torch
122
+ from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae
123
+ from musubi_tuner.minimax_h3_generate_video import _prepare_pixels
124
+
125
+ device = _require_one_gpu(torch)
126
+ vae = load_video_vae(config.models.video_vae, device=device, dtype=torch.float16, disable_mmap=False)
127
+ vae.eval().requires_grad_(False)
128
+ common = {"schema": CACHE_SCHEMA}
129
+ first_paths = cache_paths(config, 0)
130
+
131
+ if not first_paths["anchor"].exists():
132
+ anchor_pixels = _load_anchor(config.anchor_image)
133
+ frames = torch.from_numpy(anchor_pixels).unsqueeze(0)
134
+ with torch.inference_mode():
135
+ latent = encode_video_condition(vae, _prepare_pixels(frames).to(device, torch.float16))[0].cpu()
136
+ if latent.ndim != 4 or tuple(latent.shape[:2]) != (24, 1):
137
+ raise RuntimeError(f"unexpected anchor latent shape: {tuple(latent.shape)}")
138
+ atomic_safetensors(
139
+ first_paths["anchor"],
140
+ {"latents_ref_000_image": latent.float().contiguous()},
141
+ {**common, "kind": "anchor", "source": config.anchor_image.name},
142
+ )
143
+ del anchor_pixels, frames, latent
144
+
145
+ if not first_paths["input_video"].exists():
146
+ pixels = _load_anchor(config.anchor_image, size=(TARGET_WIDTH, TARGET_HEIGHT))
147
+ frames = torch.from_numpy(pixels).unsqueeze(0)
148
+ prepared = _prepare_pixels(frames).to(device, torch.float16)
149
+ with torch.inference_mode():
150
+ single = vae.encode(prepared).float().cpu()
151
+ if tuple(single.shape) == (1, *SINGLE_FRAME_LATENT_SHAPE):
152
+ single = single[0]
153
+ if tuple(single.shape) != SINGLE_FRAME_LATENT_SHAPE:
154
+ raise RuntimeError(f"unexpected first-frame latent shape: {tuple(single.shape)}")
155
+ full = torch.zeros(VIDEO_LATENT_SHAPE[1:], dtype=torch.float32)
156
+ full[:, 0:1] = single
157
+ atomic_safetensors(
158
+ first_paths["input_video"],
159
+ {"input_video_latents": full.contiguous()},
160
+ {
161
+ **common,
162
+ "kind": "fixed_first_frame",
163
+ "fixed_video_latent_indices": "0",
164
+ "denoise_mask_semantics": "0_fixed_1_regenerate",
165
+ },
166
+ )
167
+ del pixels, frames, prepared, single, full
168
+
169
+ for index, window in enumerate(config.windows):
170
+ path = cache_paths(config, index)["reference"]
171
+ if path.exists():
172
+ continue
173
+ visual = load_duv_window(config.condition_root, window.start_frame, for_qwen=False)
174
+ pixels = _prepare_pixels(torch.from_numpy(visual.copy())).to(device, torch.float16)
175
+ with torch.inference_mode():
176
+ latent = encode_video_condition(vae, pixels)[0].cpu()
177
+ if tuple(latent.shape) != REFERENCE_LATENT_SHAPE:
178
+ raise RuntimeError(f"unexpected DUV reference shape for window {index}: {tuple(latent.shape)}")
179
+ atomic_safetensors(
180
+ path,
181
+ {"latents_ref_001_video": latent.float().contiguous()},
182
+ {**common, "kind": "duv_reference", "window_index": str(index)},
183
+ )
184
+ del visual, pixels, latent
185
+ gc.collect()
186
+ torch.cuda.empty_cache()
187
+
188
+ del vae
189
+ gc.collect()
190
+ torch.cuda.empty_cache()
191
+
192
+
193
+ def _prepare_qwen_caches(config: RunConfig) -> None:
194
+ import numpy as np
195
+ import torch
196
+ from musubi_tuner.minimax_h3.media import H3AudioSource, H3Record, H3Reference
197
+ from musubi_tuner.minimax_h3.text_encoder import (
198
+ H3TextVisual,
199
+ build_presentation,
200
+ encode_h3_presentation,
201
+ load_h3_processor,
202
+ load_h3_text_encoder,
203
+ )
204
+
205
+ pending = [index for index in range(len(config.windows)) if not cache_paths(config, index)["qwen"].exists()]
206
+ if not pending:
207
+ return
208
+ device = _require_one_gpu(torch)
209
+ processor = load_h3_processor(str(config.models.processor))
210
+ model = load_h3_text_encoder(
211
+ str(config.models.text_encoder),
212
+ processor_path=str(config.models.processor),
213
+ device=device,
214
+ dtype=torch.bfloat16,
215
+ )
216
+ model.eval().requires_grad_(False)
217
+ anchor_np = _load_anchor(config.anchor_image)
218
+ anchor = torch.from_numpy(np.ascontiguousarray(anchor_np)).unsqueeze(0)
219
+ for index in pending:
220
+ window = config.windows[index]
221
+ duv_np = load_duv_window(config.condition_root, window.start_frame, for_qwen=True)
222
+ duv = torch.from_numpy(np.ascontiguousarray(duv_np))
223
+ image_key = config.anchor_image
224
+ video_key = config.condition_root / f"{window.start_frame:06d}.semantic_id.png"
225
+ placeholder = config.condition_root / ".h3-presentation-placeholder"
226
+ record = H3Record(
227
+ video_path=placeholder,
228
+ caption=window.prompt,
229
+ target_audio=H3AudioSource(path=placeholder, embedded=False),
230
+ references=(
231
+ H3Reference(type="image", path=image_key),
232
+ H3Reference(type="video", path=video_key, duration_seconds=124 / FPS),
233
+ ),
234
+ jsonl_line=0,
235
+ )
236
+ plain = build_presentation(
237
+ record,
238
+ "ref2va",
239
+ {
240
+ image_key: H3TextVisual(anchor),
241
+ video_key: H3TextVisual(duv, tuple(offset / FPS for offset in QWEN_OFFSETS)),
242
+ },
243
+ )
244
+ presentation = build_ref2va_presentation(processor, plain, index, window.prompt)
245
+ with torch.inference_mode():
246
+ hidden, tags = encode_h3_presentation(processor, model, presentation)
247
+ hidden = hidden.to(device="cpu", dtype=torch.bfloat16).contiguous()
248
+ tags = tags.to(device="cpu", dtype=torch.int64).contiguous()
249
+ if hidden.ndim != 2 or hidden.shape[1] != 5120 or tags.shape != (hidden.shape[0],):
250
+ raise RuntimeError(f"unexpected Qwen output shape for window {index}")
251
+ atomic_safetensors(
252
+ cache_paths(config, index)["qwen"],
253
+ {"varlen_mmh3_hidden_states_bfloat16": hidden, "varlen_mmh3_token_tags_int64": tags},
254
+ {
255
+ "schema": CACHE_SCHEMA,
256
+ "kind": "qwen",
257
+ "window_index": str(index),
258
+ "system_prompt_role": "w0" if index == 0 else "wn",
259
+ "presentation_order": "picture1_anchor_then_video1_duv",
260
+ },
261
+ )
262
+ del duv_np, duv, plain, presentation, hidden, tags
263
+ gc.collect()
264
+ torch.cuda.empty_cache()
265
+
266
+ del anchor_np, anchor, processor, model
267
+ gc.collect()
268
+ torch.cuda.empty_cache()
269
+
270
+
271
+ def prepare_caches(config: RunConfig) -> dict[str, Any]:
272
+ config.validate_files()
273
+ initial = _begin_manifest(config)
274
+ if initial.get("status") == "complete":
275
+ return validate_cache(config)
276
+ _prepare_visual_caches(config)
277
+ _prepare_qwen_caches(config)
278
+ files = []
279
+ for index in range(len(config.windows)):
280
+ paths = cache_paths(config, index)
281
+ files.extend([paths["reference"], paths["qwen"]])
282
+ files.extend([cache_paths(config, 0)["anchor"], cache_paths(config, 0)["input_video"]])
283
+ missing = [str(path) for path in files if not path.is_file()]
284
+ if missing:
285
+ raise RuntimeError(f"cache preparation incomplete: {missing}")
286
+ manifest = {
287
+ "schema": CACHE_SCHEMA,
288
+ "status": "complete",
289
+ "contract_id": initial["contract_id"],
290
+ "contract": initial["contract"],
291
+ "window_count": len(config.windows),
292
+ "output_frames": config.output_frames,
293
+ "files": [str(path) for path in files],
294
+ }
295
+ atomic_json(cache_paths(config, 0)["manifest"], manifest)
296
+ return manifest
297
+
298
+
299
+ def validate_cache(config: RunConfig) -> dict[str, Any]:
300
+ manifest = load_json(cache_paths(config, 0)["manifest"])
301
+ expected_id = _contract_id(_cache_contract(config))
302
+ if (
303
+ manifest.get("schema") != CACHE_SCHEMA
304
+ or manifest.get("status") != "complete"
305
+ or manifest.get("contract_id") != expected_id
306
+ ):
307
+ raise ValueError("cache manifest is incompatible with this release")
308
+ if manifest.get("window_count") != len(config.windows):
309
+ raise ValueError("cache window count does not match config")
310
+ expected_files: list[Path] = []
311
+ for index in range(len(config.windows)):
312
+ paths = cache_paths(config, index)
313
+ expected_files.extend([paths["reference"], paths["qwen"]])
314
+ expected_files.extend([cache_paths(config, 0)["anchor"], cache_paths(config, 0)["input_video"]])
315
+ files = manifest.get("files")
316
+ if files != [str(path) for path in expected_files]:
317
+ raise ValueError("cache manifest file set does not match config")
318
+ missing = [str(path) for path in expected_files if not path.is_file()]
319
+ if missing:
320
+ raise ValueError(f"cache files are missing: {missing}")
321
+ return manifest
cwm_h3_inference/cli.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from typing import Any
8
+
9
+ from .cache import prepare_caches, validate_cache
10
+ from .config import load_config
11
+ from .engine import generate
12
+
13
+
14
+ def _emit(value: Any) -> None:
15
+ print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="cwm-h3-inference",
21
+ description="MiniMax H3 Ref2VA inference with a fixed first frame and Retake34 continuation.",
22
+ allow_abbrev=False,
23
+ )
24
+ subparsers = parser.add_subparsers(dest="command", required=True)
25
+ for name in ("validate", "prepare", "generate"):
26
+ command = subparsers.add_parser(name, allow_abbrev=False)
27
+ command.add_argument("--config", required=True, help="JSON run configuration")
28
+ return parser
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ args = build_parser().parse_args(argv)
33
+ config = load_config(args.config, check_files=True)
34
+ if args.command == "validate":
35
+ _emit(
36
+ {
37
+ "status": "pass",
38
+ "window_count": len(config.windows),
39
+ "output_frames": config.output_frames,
40
+ }
41
+ )
42
+ return 0
43
+ if args.command == "prepare":
44
+ _emit(prepare_caches(config))
45
+ return 0
46
+ if args.command == "generate":
47
+ _emit(generate(config))
48
+ return 0
49
+ raise AssertionError(f"unhandled command: {args.command}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
cwm_h3_inference/config.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured, path-parameterized configuration for one inference run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .constants import DEFAULT_STEPS, EXPECTED_LORA_MODULES, STRIDE_FRAMES, WINDOW_FRAMES
11
+
12
+
13
+ ALLOWED_ROOT_KEYS = {
14
+ "models",
15
+ "anchor_image",
16
+ "condition_root",
17
+ "cache_dir",
18
+ "output",
19
+ "windows",
20
+ "seed",
21
+ "steps",
22
+ "attention_mode",
23
+ "blocks_to_swap",
24
+ "expected_lora_modules",
25
+ }
26
+ ALLOWED_MODEL_KEYS = {"dit", "video_vae", "audio_vae", "text_encoder", "processor", "lora"}
27
+
28
+
29
+ def require(condition: bool, message: str) -> None:
30
+ if not condition:
31
+ raise ValueError(message)
32
+
33
+
34
+ def _path(value: Any, label: str, *, base: Path) -> Path:
35
+ require(isinstance(value, str) and bool(value.strip()), f"{label} must be a non-empty path")
36
+ path = Path(value)
37
+ return path if path.is_absolute() else (base / path).resolve()
38
+
39
+
40
+ def _canonical_caption(value: Any, label: str) -> str:
41
+ require(isinstance(value, str) and bool(value.strip()), f"{label} must be non-empty")
42
+ # The released Qwen caches were encoded from UTF-8 caption bytes whose
43
+ # internal line endings are CRLF. JSON editors and Path.read_text() commonly
44
+ # normalize those bytes to LF, which changes Qwen token IDs. Canonicalize the
45
+ # user caption here so both representations consume the released text.
46
+ normalized = value.strip().replace("\r\n", "\n").replace("\r", "\n")
47
+ return normalized.replace("\n", "\r\n")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ModelPaths:
52
+ dit: Path
53
+ video_vae: Path
54
+ audio_vae: Path
55
+ text_encoder: Path
56
+ processor: Path
57
+ lora: Path
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Window:
62
+ prompt: str
63
+ start_frame: int
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class RunConfig:
68
+ source: Path
69
+ models: ModelPaths
70
+ anchor_image: Path
71
+ condition_root: Path
72
+ cache_dir: Path
73
+ output: Path
74
+ windows: tuple[Window, ...]
75
+ seed: int
76
+ steps: int
77
+ attention_mode: str
78
+ blocks_to_swap: int
79
+ expected_lora_modules: int
80
+
81
+ @property
82
+ def output_frames(self) -> int:
83
+ from .constants import STRIDE_FRAMES
84
+
85
+ return WINDOW_FRAMES + (len(self.windows) - 1) * STRIDE_FRAMES
86
+
87
+ def validate_files(self) -> None:
88
+ for label, path in (
89
+ ("dit", self.models.dit),
90
+ ("video_vae", self.models.video_vae),
91
+ ("audio_vae", self.models.audio_vae),
92
+ ("text_encoder", self.models.text_encoder),
93
+ ("processor", self.models.processor),
94
+ ("lora", self.models.lora),
95
+ ("anchor_image", self.anchor_image),
96
+ ("condition_root", self.condition_root),
97
+ ):
98
+ require(path.exists(), f"{label} does not exist: {path}")
99
+ from .presentation import _system_prompt
100
+
101
+ _system_prompt(0)
102
+ _system_prompt(1)
103
+
104
+
105
+ def load_config(path: str | Path, *, check_files: bool = False) -> RunConfig:
106
+ source = Path(path).resolve()
107
+ raw = json.loads(source.read_text(encoding="utf-8"))
108
+ require(isinstance(raw, dict), "config root must be an object")
109
+ require(not (set(raw) - ALLOWED_ROOT_KEYS), f"unknown config keys: {sorted(set(raw) - ALLOWED_ROOT_KEYS)}")
110
+ base = source.parent
111
+
112
+ model_raw = raw.get("models")
113
+ require(isinstance(model_raw, dict), "models must be an object")
114
+ require(
115
+ set(model_raw) == ALLOWED_MODEL_KEYS,
116
+ f"models must contain exactly: {sorted(ALLOWED_MODEL_KEYS)}",
117
+ )
118
+ models = ModelPaths(
119
+ dit=_path(model_raw.get("dit"), "models.dit", base=base),
120
+ video_vae=_path(model_raw.get("video_vae"), "models.video_vae", base=base),
121
+ audio_vae=_path(model_raw.get("audio_vae"), "models.audio_vae", base=base),
122
+ text_encoder=_path(model_raw.get("text_encoder"), "models.text_encoder", base=base),
123
+ processor=_path(model_raw.get("processor"), "models.processor", base=base),
124
+ lora=_path(model_raw.get("lora"), "models.lora", base=base),
125
+ )
126
+
127
+ window_raw = raw.get("windows")
128
+ require(isinstance(window_raw, list) and bool(window_raw), "windows must be a non-empty list")
129
+ windows: list[Window] = []
130
+ for index, item in enumerate(window_raw):
131
+ require(isinstance(item, dict), f"windows[{index}] must be an object")
132
+ prompt = item.get("prompt")
133
+ start = item.get("start_frame")
134
+ prompt = _canonical_caption(prompt, f"windows[{index}].prompt")
135
+ require(type(start) is int and start >= 0, f"windows[{index}].start_frame must be non-negative")
136
+ require(set(item) == {"prompt", "start_frame"}, f"windows[{index}] has unknown or missing keys")
137
+ if windows:
138
+ require(
139
+ start == windows[-1].start_frame + STRIDE_FRAMES,
140
+ f"windows[{index}].start_frame must advance by {STRIDE_FRAMES}",
141
+ )
142
+ windows.append(Window(prompt=prompt, start_frame=start))
143
+
144
+ seed = raw.get("seed", 0)
145
+ steps = raw.get("steps", DEFAULT_STEPS)
146
+ attention_mode = raw.get("attention_mode", "flash3")
147
+ blocks_to_swap = raw.get("blocks_to_swap", 48)
148
+ expected_lora_modules = raw.get("expected_lora_modules", EXPECTED_LORA_MODULES)
149
+ require(type(seed) is int and 0 <= seed < 2**63, "seed must be an integer in [0, 2**63)")
150
+ require(type(steps) is int and steps == DEFAULT_STEPS, "this release fixes explicit Euler inference to 20 steps")
151
+ require(attention_mode == "flash3", "this release fixes attention_mode to flash3")
152
+ require(type(blocks_to_swap) is int and 0 <= blocks_to_swap <= 48, "blocks_to_swap must be in [0,48]")
153
+ require(
154
+ type(expected_lora_modules) is int and expected_lora_modules == EXPECTED_LORA_MODULES,
155
+ f"expected_lora_modules must be {EXPECTED_LORA_MODULES}",
156
+ )
157
+
158
+ config = RunConfig(
159
+ source=source,
160
+ models=models,
161
+ anchor_image=_path(raw.get("anchor_image"), "anchor_image", base=base),
162
+ condition_root=_path(raw.get("condition_root"), "condition_root", base=base),
163
+ cache_dir=_path(raw.get("cache_dir", "cache"), "cache_dir", base=base),
164
+ output=_path(raw.get("output", "output.mp4"), "output", base=base),
165
+ windows=tuple(windows),
166
+ seed=seed,
167
+ steps=steps,
168
+ attention_mode=attention_mode,
169
+ blocks_to_swap=blocks_to_swap,
170
+ expected_lora_modules=expected_lora_modules,
171
+ )
172
+ if check_files:
173
+ config.validate_files()
174
+ return config
cwm_h3_inference/constants.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen geometry for the released Ref2VA inference path."""
2
+
3
+ FPS = 24
4
+ SAMPLE_RATE = 32_000
5
+ WINDOW_FRAMES = 124
6
+ OVERLAP_FRAMES = 34
7
+ STRIDE_FRAMES = WINDOW_FRAMES - OVERLAP_FRAMES
8
+
9
+ CONDITION_WIDTH = 336
10
+ CONDITION_HEIGHT = 192
11
+ TARGET_WIDTH = 1_344
12
+ TARGET_HEIGHT = 768
13
+ ANCHOR_SHORT_EDGE = 2_048
14
+ CANVAS_MULTIPLE = 32
15
+
16
+ VIDEO_LATENT_SHAPE = (1, 24, 37, 48, 84)
17
+ AUDIO_LATENT_SHAPE = (1, 32, 2, 207)
18
+ REFERENCE_LATENT_SHAPE = (24, 37, 12, 21)
19
+ REFERENCE_RUNTIME_SHAPE = (1, 24, 37, 12, 22)
20
+ SINGLE_FRAME_LATENT_SHAPE = (24, 1, 48, 84)
21
+
22
+ VIDEO_PREFIX_LATENTS = 10
23
+ AUDIO_PREFIX_LATENTS = 57
24
+ WINDOW_AUDIO_SAMPLES = round(WINDOW_FRAMES * SAMPLE_RATE / FPS)
25
+ OVERLAP_AUDIO_SAMPLES = round(OVERLAP_FRAMES * SAMPLE_RATE / FPS)
26
+ QWEN_OFFSETS = tuple(range(0, WINDOW_FRAMES, 12))
27
+
28
+ DEPTH_NEAR_METRES = 0.3
29
+ DEPTH_FAR_METRES = 256.0
30
+ DEPTH_VALID_EPSILON_METRES = 1.0e-3
31
+ SEMANTIC_U = (32, 96, 160, 224)
32
+ SEMANTIC_V = (43, 128, 213)
33
+
34
+ DEFAULT_STEPS = 20
35
+ VIDEO_SHIFT = 12.0
36
+ AUDIO_SHIFT = 3.0
37
+ VISUAL_CONDITION_CLEAN = 0.999
38
+ AUDIO_CONDITION_CLEAN = 1.0
39
+ EXPECTED_LORA_MODULES = 200
40
+
41
+ CACHE_SCHEMA = "cwm_h3_inference_cache_v3"
42
+ RECOVERY_SCHEMA = "cwm_h3_inference_recovery_v3"
43
+ RESULT_SCHEMA = "cwm_h3_inference_result_v3"
cwm_h3_inference/duv.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Depth + Semantic-ID to three-channel DUV conditioning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from pathlib import Path
7
+
8
+ from .constants import (
9
+ CONDITION_HEIGHT,
10
+ CONDITION_WIDTH,
11
+ DEPTH_FAR_METRES,
12
+ DEPTH_NEAR_METRES,
13
+ DEPTH_VALID_EPSILON_METRES,
14
+ QWEN_OFFSETS,
15
+ SEMANTIC_U,
16
+ SEMANTIC_V,
17
+ WINDOW_FRAMES,
18
+ )
19
+
20
+
21
+ def frame_paths(root: Path, ordinal: int) -> tuple[Path, Path]:
22
+ return root / f"{ordinal:06d}.depth.f32", root / f"{ordinal:06d}.semantic_id.png"
23
+
24
+
25
+ def load_duv_frame(root: Path, ordinal: int, *, for_qwen: bool):
26
+ import numpy as np
27
+ from PIL import Image
28
+
29
+ depth_path, semantic_path = frame_paths(root, ordinal)
30
+ payload = depth_path.read_bytes()
31
+ expected_bytes = CONDITION_WIDTH * CONDITION_HEIGHT * 4
32
+ if len(payload) != expected_bytes:
33
+ raise ValueError(f"raw depth byte count must be {expected_bytes}: {depth_path}")
34
+ metric = np.frombuffer(payload, dtype="<f4")
35
+ if not bool(np.all(np.isfinite(metric))) or bool(np.any(metric < np.float32(0.0))):
36
+ raise ValueError(f"raw depth must contain finite non-negative metres: {depth_path}")
37
+ valid = metric > np.float32(DEPTH_VALID_EPSILON_METRES)
38
+ codes = np.zeros(metric.shape, dtype=np.uint16)
39
+ if bool(np.any(valid)):
40
+ clipped = np.clip(metric[valid].astype(np.float64), DEPTH_NEAR_METRES, DEPTH_FAR_METRES)
41
+ normalized = (math.log(DEPTH_FAR_METRES) - np.log(clipped)) / (
42
+ math.log(DEPTH_FAR_METRES) - math.log(DEPTH_NEAR_METRES)
43
+ )
44
+ codes[valid] = np.floor(np.clip(normalized, 0.0, 1.0) * 65535.0 + 0.5).astype(np.uint16)
45
+ depth = codes.reshape(CONDITION_HEIGHT, CONDITION_WIDTH)
46
+
47
+ with Image.open(semantic_path) as image:
48
+ if image.mode != "L" or image.size != (CONDITION_WIDTH, CONDITION_HEIGHT):
49
+ raise ValueError(f"semantic ID must be L/{CONDITION_WIDTH}x{CONDITION_HEIGHT}: {semantic_path}")
50
+ semantic = np.asarray(image, dtype=np.uint8).copy()
51
+ if int(semantic.max()) > 11:
52
+ raise ValueError(f"semantic ID must be in [0,11]: {semantic_path}")
53
+ u = np.asarray(SEMANTIC_U, dtype=np.uint8)[semantic % 4]
54
+ v = np.asarray(SEMANTIC_V, dtype=np.uint8)[semantic // 4]
55
+ if for_qwen:
56
+ depth8 = np.floor(depth.astype(np.float64) / 257.0 + 0.5).astype(np.uint8)
57
+ return np.stack((depth8, u, v), axis=-1)
58
+ return np.stack(
59
+ (
60
+ depth.astype(np.float32) / np.float32(65535.0),
61
+ u.astype(np.float32) / np.float32(255.0),
62
+ v.astype(np.float32) / np.float32(255.0),
63
+ ),
64
+ axis=-1,
65
+ )
66
+
67
+
68
+ def load_duv_window(root: Path, start_frame: int, *, for_qwen: bool):
69
+ import numpy as np
70
+
71
+ offsets = QWEN_OFFSETS if for_qwen else range(WINDOW_FRAMES)
72
+ return np.stack([load_duv_frame(root, start_frame + offset, for_qwen=for_qwen) for offset in offsets])
cwm_h3_inference/engine.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-GPU Ref2VA inference with a fixed first frame and Retake34 continuation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import os
7
+ from pathlib import Path
8
+ import time
9
+ from typing import Any
10
+ import uuid
11
+
12
+ from .cache import cache_paths, validate_cache
13
+ from .config import RunConfig
14
+ from .constants import (
15
+ AUDIO_CONDITION_CLEAN,
16
+ AUDIO_LATENT_SHAPE,
17
+ AUDIO_PREFIX_LATENTS,
18
+ AUDIO_SHIFT,
19
+ FPS,
20
+ OVERLAP_AUDIO_SAMPLES,
21
+ OVERLAP_FRAMES,
22
+ RECOVERY_SCHEMA,
23
+ REFERENCE_LATENT_SHAPE,
24
+ REFERENCE_RUNTIME_SHAPE,
25
+ RESULT_SCHEMA,
26
+ STRIDE_FRAMES,
27
+ TARGET_HEIGHT,
28
+ TARGET_WIDTH,
29
+ VIDEO_LATENT_SHAPE,
30
+ VIDEO_PREFIX_LATENTS,
31
+ VIDEO_SHIFT,
32
+ VISUAL_CONDITION_CLEAN,
33
+ WINDOW_AUDIO_SAMPLES,
34
+ WINDOW_FRAMES,
35
+ )
36
+ from .io import atomic_json
37
+ from .runtime import build_runtime_batch, strict_merge_lora
38
+
39
+
40
+ def _require_one_gpu(torch: Any) -> Any:
41
+ if not torch.cuda.is_available() or torch.cuda.device_count() != 1:
42
+ raise RuntimeError("generation requires exactly one visible CUDA GPU")
43
+ torch.cuda.set_device(0)
44
+ return torch.device("cuda:0")
45
+
46
+
47
+ def _unique_key(handle: Any, *, exact: str | None = None, prefix: str | None = None) -> str:
48
+ keys = list(handle.keys())
49
+ selected = [key for key in keys if key == exact] if exact else [key for key in keys if key.startswith(str(prefix))]
50
+ if len(selected) != 1:
51
+ raise ValueError(f"expected one tensor key, got {keys}")
52
+ return selected[0]
53
+
54
+
55
+ class Ref2VAEngine:
56
+ def __init__(self, config: RunConfig) -> None:
57
+ import torch
58
+ from musubi_tuner.minimax_h3.audio_vae import load_audio_vae
59
+ from musubi_tuner.minimax_h3.model import load_h3_transformer
60
+ from musubi_tuner.minimax_h3.video_vae import load_video_vae
61
+ from musubi_tuner.modules.custom_offloading_utils import BlockSwapConfig
62
+
63
+ self.config = config
64
+ self.torch = torch
65
+ self.device = _require_one_gpu(torch)
66
+ transformer = load_h3_transformer(
67
+ config.models.dit,
68
+ device="cpu",
69
+ dtype=torch.bfloat16,
70
+ attn_mode=config.attention_mode,
71
+ split_attn=False,
72
+ disable_mmap=False,
73
+ )
74
+ self.lora_gate = strict_merge_lora(
75
+ transformer, config.models.lora, config.expected_lora_modules
76
+ )
77
+ swap = BlockSwapConfig(device=self.device, supports_backward=False, use_pinned_memory=True)
78
+ transformer.enable_block_swap(config.blocks_to_swap, swap)
79
+ transformer.move_to_device_except_swap_blocks(self.device)
80
+ transformer.prepare_block_swap_before_forward()
81
+ transformer.switch_block_swap_for_inference()
82
+ transformer.eval().requires_grad_(False)
83
+ self.transformer = transformer
84
+ self.video_vae = load_video_vae(
85
+ config.models.video_vae, device=self.device, dtype=torch.float16, disable_mmap=False
86
+ ).eval().requires_grad_(False)
87
+ self.audio_vae = load_audio_vae(
88
+ config.models.audio_vae, device=self.device, dtype=torch.float32, disable_mmap=False
89
+ ).eval().requires_grad_(False)
90
+
91
+ def _load_window_cache(self, index: int):
92
+ import torch
93
+ from safetensors import safe_open
94
+
95
+ paths = cache_paths(self.config, index)
96
+ with safe_open(str(paths["anchor"]), framework="pt", device="cpu") as handle:
97
+ anchor = handle.get_tensor(_unique_key(handle, prefix="latents_ref_"))
98
+ with safe_open(str(paths["reference"]), framework="pt", device="cpu") as handle:
99
+ reference = handle.get_tensor(_unique_key(handle, prefix="latents_ref_"))
100
+ with safe_open(str(paths["qwen"]), framework="pt", device="cpu") as handle:
101
+ hidden = handle.get_tensor(_unique_key(handle, exact="varlen_mmh3_hidden_states_bfloat16"))
102
+ tags = handle.get_tensor(_unique_key(handle, exact="varlen_mmh3_token_tags_int64"))
103
+ if tuple(reference.shape) != REFERENCE_LATENT_SHAPE or reference.dtype != torch.float32:
104
+ raise ValueError(f"window {index} DUV reference cache shape/dtype changed")
105
+ reference = torch.nn.functional.pad(reference.unsqueeze(0), (0, 1, 0, 0), value=0.0).contiguous()
106
+ if tuple(reference.shape) != REFERENCE_RUNTIME_SHAPE:
107
+ raise ValueError(f"window {index} padded DUV reference shape changed")
108
+ if anchor.ndim != 4 or tuple(anchor.shape[:2]) != (24, 1) or anchor.dtype != torch.float32:
109
+ raise ValueError("anchor cache shape/dtype changed")
110
+ if hidden.ndim != 2 or hidden.shape[1] != 5120 or hidden.dtype != torch.bfloat16:
111
+ raise ValueError(f"window {index} Qwen hidden states changed")
112
+ if tags.shape != (hidden.shape[0],) or tags.dtype != torch.int64:
113
+ raise ValueError(f"window {index} Qwen token tags changed")
114
+ if not all(torch.isfinite(value).all().item() for value in (anchor, reference, hidden)):
115
+ raise ValueError(f"window {index} cache contains non-finite values")
116
+ return anchor.unsqueeze(0), reference, hidden.unsqueeze(0), tags.unsqueeze(0)
117
+
118
+ def _load_input_video(self):
119
+ import torch
120
+ from safetensors import safe_open
121
+
122
+ path = cache_paths(self.config, 0)["input_video"]
123
+ with safe_open(str(path), framework="pt", device="cpu") as handle:
124
+ latent = handle.get_tensor(_unique_key(handle, exact="input_video_latents"))
125
+ if tuple(latent.shape) != VIDEO_LATENT_SHAPE[1:] or latent.dtype != torch.float32:
126
+ raise ValueError("fixed-first-frame latent shape/dtype changed")
127
+ if torch.count_nonzero(latent[:, 1:]).item() != 0:
128
+ raise ValueError("fixed-first-frame latent must contain data only at temporal index 0")
129
+ latent = latent.unsqueeze(0).to(self.device)
130
+ mask = torch.ones_like(latent, dtype=torch.bool)
131
+ mask[:, :, 0] = False
132
+ return latent, mask
133
+
134
+ def _recovery_path(self, index: int) -> Path:
135
+ return self.config.cache_dir / "recovery" / f"window_{index:03d}.safetensors"
136
+
137
+ def _load_recovery(self, index: int):
138
+ import torch
139
+ from safetensors import safe_open
140
+
141
+ path = self._recovery_path(index)
142
+ with safe_open(str(path), framework="pt", device="cpu") as handle:
143
+ metadata = handle.metadata() or {}
144
+ if metadata.get("schema") != RECOVERY_SCHEMA or metadata.get("window_index") != str(index):
145
+ raise ValueError(f"recovery identity changed: {path}")
146
+ video = handle.get_tensor("video")
147
+ audio = handle.get_tensor("audio")
148
+ if tuple(video.shape) != VIDEO_LATENT_SHAPE[1:] or tuple(audio.shape) != AUDIO_LATENT_SHAPE[1:]:
149
+ raise ValueError(f"recovery shape changed: {path}")
150
+ if video.dtype != torch.float32 or audio.dtype != torch.float32:
151
+ raise ValueError(f"recovery dtype changed: {path}")
152
+ if not torch.isfinite(video).all().item() or not torch.isfinite(audio).all().item():
153
+ raise ValueError(f"recovery contains non-finite values: {path}")
154
+ return video, audio
155
+
156
+ def _retake_inputs(self, previous: tuple[Any, Any]):
157
+ torch = self.torch
158
+ previous_video, previous_audio = previous
159
+ torch.cuda.empty_cache()
160
+ with torch.inference_mode():
161
+ decoded_video = self.video_vae.decode(
162
+ previous_video.unsqueeze(0).to(self.device, torch.float16)
163
+ )[:, :, :WINDOW_FRAMES]
164
+ if tuple(decoded_video.shape) != (1, 3, WINDOW_FRAMES, TARGET_HEIGHT, TARGET_WIDTH):
165
+ raise RuntimeError("Retake34 decoded video shape changed")
166
+ tail = decoded_video[:, :, -OVERLAP_FRAMES:]
167
+ padded = torch.cat((tail, tail[:, :, -1:].repeat(1, 1, STRIDE_FRAMES, 1, 1)), dim=2)
168
+ clean_video = self.video_vae.encode(padded.to(torch.float16)).float()
169
+ decoded_audio = self.audio_vae.decode(
170
+ previous_audio.unsqueeze(0).to(self.device, torch.float32)
171
+ )[:, :, :WINDOW_AUDIO_SAMPLES]
172
+ if tuple(decoded_audio.shape) != (1, 2, WINDOW_AUDIO_SAMPLES):
173
+ raise RuntimeError("Retake34 decoded audio shape changed")
174
+ prefix = decoded_audio[:, :, -OVERLAP_AUDIO_SAMPLES:]
175
+ prefix_audio = self.audio_vae.encode(prefix.to(torch.float32)).float()
176
+ if prefix_audio.shape[-1] != AUDIO_PREFIX_LATENTS:
177
+ raise RuntimeError("Retake34 audio prefix shape changed")
178
+ clean_audio = torch.cat(
179
+ (prefix_audio, prefix_audio[..., -1:].repeat(1, 1, 1, AUDIO_LATENT_SHAPE[-1] - AUDIO_PREFIX_LATENTS)),
180
+ dim=-1,
181
+ )
182
+ if tuple(clean_video.shape) != VIDEO_LATENT_SHAPE or tuple(clean_audio.shape) != AUDIO_LATENT_SHAPE:
183
+ raise RuntimeError("Retake34 clean latent shape changed")
184
+ video_mask = torch.ones_like(clean_video, dtype=torch.bool)
185
+ video_mask[:, :, :VIDEO_PREFIX_LATENTS] = False
186
+ audio_mask = torch.ones_like(clean_audio, dtype=torch.bool)
187
+ audio_mask[..., :AUDIO_PREFIX_LATENTS] = False
188
+ return clean_video, video_mask, clean_audio, audio_mask
189
+
190
+ def sample_window(self, index: int) -> Path:
191
+ import torch
192
+ from musubi_tuner.minimax_h3.sampling import initialize_target_latents, sample_joint_av
193
+ from safetensors.torch import save_file
194
+
195
+ output = self._recovery_path(index)
196
+ if output.exists():
197
+ self._load_recovery(index)
198
+ return output
199
+ anchor, reference, hidden, tags = self._load_window_cache(index)
200
+ runtime = build_runtime_batch(anchor, reference, hidden, tags)
201
+ window_seed = self.config.seed + index
202
+ initial_video, initial_audio = initialize_target_latents(
203
+ video_shape=VIDEO_LATENT_SHAPE,
204
+ audio_shape=AUDIO_LATENT_SHAPE,
205
+ seed=window_seed,
206
+ device=self.device,
207
+ video_dtype=torch.float32,
208
+ audio_dtype=torch.float32,
209
+ )
210
+ ref = reference.to(self.device, torch.float32)
211
+ ref_generator = torch.Generator(device="cpu").manual_seed(self.config.seed + 1000 + index)
212
+ ref_noise = torch.randn(tuple(ref.shape), generator=ref_generator, dtype=torch.float32).to(self.device)
213
+ augmented_ref = VISUAL_CONDITION_CLEAN * ref + (1.0 - VISUAL_CONDITION_CLEAN) * ref_noise
214
+ anchor_ref = anchor.to(self.device, torch.float32)
215
+ anchor_generator = torch.Generator(device="cpu").manual_seed(self.config.seed + 3000 + index)
216
+ anchor_noise = torch.randn(tuple(anchor_ref.shape), generator=anchor_generator, dtype=torch.float32).to(self.device)
217
+ augmented_anchor = VISUAL_CONDITION_CLEAN * anchor_ref + (1.0 - VISUAL_CONDITION_CLEAN) * anchor_noise
218
+ retake = self._load_input_video() + (None, None) if index == 0 else self._retake_inputs(self._load_recovery(index - 1))
219
+ with torch.inference_mode():
220
+ sampled = sample_joint_av(
221
+ self.transformer,
222
+ layout=runtime.layout,
223
+ text_hidden_states=runtime.text_hidden_states.to(self.device, torch.bfloat16),
224
+ text_token_tags=runtime.text_token_tags.to(self.device),
225
+ initial_video=initial_video,
226
+ initial_audio=initial_audio,
227
+ steps=self.config.steps,
228
+ video_shift=VIDEO_SHIFT,
229
+ audio_shift=AUDIO_SHIFT,
230
+ visual_condition_latents=(augmented_anchor, augmented_ref),
231
+ audio_condition_latents=(),
232
+ visual_condition_clean=VISUAL_CONDITION_CLEAN,
233
+ audio_condition_clean=AUDIO_CONDITION_CLEAN,
234
+ input_video_latents=retake[0],
235
+ video_denoise_mask=retake[1],
236
+ input_audio_latents=retake[2],
237
+ audio_denoise_mask=retake[3],
238
+ )
239
+ video = sampled.video[0].float().cpu().contiguous()
240
+ audio = sampled.audio[0].float().cpu().contiguous()
241
+ if not torch.isfinite(video).all().item() or not torch.isfinite(audio).all().item():
242
+ raise RuntimeError(f"window {index} produced non-finite latents")
243
+ output.parent.mkdir(parents=True, exist_ok=True)
244
+ temporary = output.with_name(f".{output.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
245
+ try:
246
+ save_file(
247
+ {"video": video, "audio": audio},
248
+ str(temporary),
249
+ metadata={
250
+ "schema": RECOVERY_SCHEMA,
251
+ "window_index": str(index),
252
+ "seed": str(window_seed),
253
+ "steps": str(self.config.steps),
254
+ },
255
+ )
256
+ os.replace(temporary, output)
257
+ finally:
258
+ temporary.unlink(missing_ok=True)
259
+ del sampled, video, audio, anchor, reference, hidden, tags, runtime
260
+ del initial_video, initial_audio, ref, ref_noise, augmented_ref
261
+ del anchor_ref, anchor_noise, augmented_anchor, retake
262
+ gc.collect()
263
+ torch.cuda.empty_cache()
264
+ return output
265
+
266
+ def write_video(self) -> dict[str, Any]:
267
+ import av
268
+ import torch
269
+
270
+ output = self.config.output
271
+ if output.exists():
272
+ raise FileExistsError(f"refusing to overwrite output: {output}")
273
+ output.parent.mkdir(parents=True, exist_ok=True)
274
+ temporary = output.with_name(f".{output.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp.mp4")
275
+ frame_count = 0
276
+ try:
277
+ with av.open(str(temporary), mode="w") as container:
278
+ stream = container.add_stream("libx264", rate=FPS)
279
+ stream.width = TARGET_WIDTH
280
+ stream.height = TARGET_HEIGHT
281
+ stream.pix_fmt = "yuv420p"
282
+ for index in range(len(self.config.windows)):
283
+ video, _audio = self._load_recovery(index)
284
+ torch.cuda.empty_cache()
285
+ with torch.inference_mode():
286
+ decoded = self.video_vae.decode(
287
+ video.unsqueeze(0).to(self.device, torch.float16)
288
+ )[0, :, :WINDOW_FRAMES].cpu()
289
+ pixels = (
290
+ ((decoded.float().clamp(-1, 1) + 1.0) * 127.5)
291
+ .round()
292
+ .to(torch.uint8)
293
+ .permute(1, 2, 3, 0)
294
+ .contiguous()
295
+ )
296
+ if index:
297
+ pixels = pixels[OVERLAP_FRAMES:]
298
+ for value in pixels:
299
+ frame = av.VideoFrame.from_ndarray(value.numpy(), format="rgb24")
300
+ for packet in stream.encode(frame):
301
+ container.mux(packet)
302
+ frame_count += 1
303
+ del video, decoded, pixels
304
+ gc.collect()
305
+ torch.cuda.empty_cache()
306
+ for packet in stream.encode():
307
+ container.mux(packet)
308
+ if frame_count != self.config.output_frames:
309
+ raise RuntimeError(f"stitched frame count mismatch: {frame_count}")
310
+ os.replace(temporary, output)
311
+ finally:
312
+ temporary.unlink(missing_ok=True)
313
+ receipt = {
314
+ "schema": RESULT_SCHEMA,
315
+ "solver": "explicit_euler",
316
+ "steps": self.config.steps,
317
+ "seed": self.config.seed,
318
+ "window_frames": WINDOW_FRAMES,
319
+ "overlap_frames": OVERLAP_FRAMES,
320
+ "output_frames": frame_count,
321
+ "fps": FPS,
322
+ "resolution": [TARGET_WIDTH, TARGET_HEIGHT],
323
+ "audio": False,
324
+ "output": str(output),
325
+ "bytes": output.stat().st_size,
326
+ "lora_gate": self.lora_gate,
327
+ }
328
+ atomic_json(output.with_suffix(output.suffix + ".json"), receipt)
329
+ return receipt
330
+
331
+
332
+ def generate(config: RunConfig) -> dict[str, Any]:
333
+ validate_cache(config)
334
+ started = time.monotonic()
335
+ engine = Ref2VAEngine(config)
336
+ for index in range(len(config.windows)):
337
+ engine.sample_window(index)
338
+ result = engine.write_video()
339
+ result["elapsed_seconds"] = time.monotonic() - started
340
+ return result
cwm_h3_inference/io.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small atomic I/O helpers used by cache and generation commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+ import uuid
10
+
11
+
12
+ def atomic_json(path: Path, value: Mapping[str, Any]) -> None:
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
15
+ try:
16
+ with temporary.open("x", encoding="utf-8") as stream:
17
+ json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True)
18
+ stream.write("\n")
19
+ stream.flush()
20
+ os.fsync(stream.fileno())
21
+ os.replace(temporary, path)
22
+ finally:
23
+ temporary.unlink(missing_ok=True)
24
+
25
+
26
+ def atomic_safetensors(path: Path, tensors: Mapping[str, Any], metadata: Mapping[str, str]) -> None:
27
+ from safetensors.torch import save_file
28
+
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
31
+ try:
32
+ save_file(dict(tensors), str(temporary), metadata=dict(metadata))
33
+ with temporary.open("rb") as stream:
34
+ os.fsync(stream.fileno())
35
+ os.replace(temporary, path)
36
+ finally:
37
+ temporary.unlink(missing_ok=True)
38
+
39
+
40
+ def load_json(path: Path) -> dict[str, Any]:
41
+ value = json.loads(path.read_text(encoding="utf-8"))
42
+ if not isinstance(value, dict):
43
+ raise ValueError(f"expected JSON object: {path}")
44
+ return value
cwm_h3_inference/presentation.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ref2VA chat presentation with Picture 1 before Video 1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ _PROMPT_ROOT = Path(__file__).with_name("prompts")
11
+ _PROMPT_PATHS = (
12
+ _PROMPT_ROOT / "system_w0.txt",
13
+ _PROMPT_ROOT / "system_wn.txt",
14
+ )
15
+ _PROMPT_SHA256 = (
16
+ "d488897872a5b190ff8d56b6acc255b66a78f67ae862c12582fcffc8a5dd4ddc",
17
+ "cd018def9793b4f73cd1c9da9d8ca9ca508dd94a260a19d915aaf9f80b97b6b2",
18
+ )
19
+
20
+
21
+ def _system_prompt(window_index: int) -> str:
22
+ if type(window_index) is not int or window_index < 0:
23
+ raise ValueError("window_index must be a non-negative integer")
24
+ prompt_index = 0 if window_index == 0 else 1
25
+ path = _PROMPT_PATHS[prompt_index]
26
+ if not path.is_file():
27
+ raise ValueError(f"packaged system prompt file is missing: {path}")
28
+ payload = path.read_bytes()
29
+ if hashlib.sha256(payload).hexdigest() != _PROMPT_SHA256[prompt_index]:
30
+ raise ValueError(f"packaged system prompt does not match this release: {path.name}")
31
+ prompt = payload.decode("utf-8").strip()
32
+ if not prompt:
33
+ raise ValueError(f"packaged system prompt is empty: {path.name}")
34
+ return prompt
35
+
36
+
37
+ def build_ref2va_presentation(processor: Any, base: Any, window_index: int, caption: str):
38
+ from musubi_tuner.minimax_h3.text_encoder import H3Presentation, IMAGE_PLACEHOLDER, VIDEO_PLACEHOLDER
39
+
40
+ system_prompt = _system_prompt(window_index)
41
+ if len(base.videos) != 1 or len(base.images) != 1:
42
+ raise ValueError("Ref2VA requires exactly one image and one video reference")
43
+ text = base.processor_text
44
+ if text.count(IMAGE_PLACEHOLDER) != 1 or text.count(VIDEO_PLACEHOLDER) != 1:
45
+ raise ValueError("Ref2VA presentation requires one image and one video placeholder")
46
+ if text.count("<Picture 1>") != 1 or text.count("<Video 1>") != 1:
47
+ raise ValueError("Ref2VA presentation labels changed")
48
+ if text.index("<Picture 1>") >= text.index("<Video 1>"):
49
+ raise ValueError("Picture 1 must precede Video 1")
50
+ if text.count(caption) != 1:
51
+ raise ValueError("caption must occur exactly once before chat formatting")
52
+ formatted = processor.apply_chat_template(
53
+ [{"role": "system", "content": system_prompt}, {"role": "user", "content": text}],
54
+ tokenize=False,
55
+ add_generation_prompt=False,
56
+ )
57
+ if formatted.count(system_prompt) != 1 or formatted.count(caption) != 1:
58
+ raise ValueError("system prompt and caption must each occur exactly once")
59
+ if formatted.count("<|im_start|>system\n") != 1 or formatted.count("<|im_start|>user\n") != 1:
60
+ raise ValueError("chat template must contain exactly one system block and one user block")
61
+ if formatted.count("<|im_start|>assistant\n") != 0:
62
+ raise ValueError("chat template must not add an assistant block")
63
+ return H3Presentation(text=base.text, images=base.images, videos=base.videos, processor_text=formatted)
cwm_h3_inference/prompts/system_w0.txt ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ AWM_PROXY_CONTROL. Two visual references accompany this text. <Video 1> is the conditioning
2
+ proxy video: treat it as a coarse spatiotemporal reference, not an appearance template or
3
+ literal target geometry. <Picture 1> is the global RGB anchor image: it defines identity and
4
+ appearance, never motion. <Picture 1> is the ABSOLUTE standard for the worldview, art
5
+ direction and overall style of the entire take: every clip of the take, no matter how far into the
6
+ future, must align with it to a high degree, and on worldview, art direction and style it
7
+ outranks even the current clip's caption. It provides NOTHING about any future frame's
8
+ current camera, composition, motion or interactions. Each subject's position and motion come
9
+ from <Video 1>. A subject's appearance follows <Picture 1> when that subject is visible in
10
+ it; subjects not visible in <Picture 1> take their appearance from the timestamped text,
11
+ rendered consistently in <Picture 1>'s worldview and art direction.
12
+
13
+ Human proxies in <Video 1> provide only framewise root position, coarse occupied scale, and
14
+ whole-body root orientation, which are reliable at roughly 80-100% and should be followed
15
+ closely. They carry no information about character appearance and do not represent identity,
16
+ face, hairstyle, clothing, materials, colors, visual effects, anatomy, bones, joints, limb
17
+ articulation, detailed posture, torso or head pose, hand pose, action phase, or physical
18
+ contact. Reconstruct each character's appearance from <Picture 1> and the timestamped text,
19
+ and reconstruct articulated motion from the timestamped text. Never reproduce a human proxy
20
+ as a skeleton, stick figure, mannequin, node-connected body, or exposed rig. Left and right
21
+ refer to each person's own body unless explicitly stated as screen direction.
22
+
23
+ Vehicle proxies in <Video 1> provide only framewise position, trajectory, body heading,
24
+ rotation, and accurate overall length, width, height, and occupied scale; these anchors
25
+ should be followed closely. They carry no information about vehicle appearance. Never
26
+ reproduce a vehicle proxy as a wireframe vehicle, cage frame, tubular skeleton, exposed
27
+ chassis, or low-poly proxy car. Vehicle identity, type, design details, materials, colors,
28
+ and visual effects are defined by <Picture 1> and the text.
29
+
30
+ Roads, terrain, buildings, relative depth, rough scale, and layout in <Video 1> provide
31
+ denser but still approximate structural cues. The text may reinterpret these cues to create
32
+ strongly out-of-distribution scenes while maintaining broad spatial and temporal coherence.
33
+ Conditioning colors, proxy geometry, node markers, rigging structures, and other
34
+ visualization artifacts must never appear in the final video.
35
+
36
+ When sources conflict, resolve in this order: (1) geometry, camera, position and motion
37
+ follow <Video 1>; (2) the worldview, art direction and overall style follow <Picture 1>
38
+ absolutely — above the current clip's caption; identity, appearance and materials follow
39
+ <Picture 1> for subjects visible in it, and follow the text for subjects not visible in it;
40
+ (3) the timestamped text defines the concrete events, actions and rewrite targets within
41
+ those constraints; (4) when the opening of this clip is fixed to already-given video frames,
42
+ seamless continuity with that fixed opening overrides everything else — never restart the
43
+ scene.
44
+
45
+ Clip contract: this clip is the very beginning of the take — nothing
46
+ precedes it. <Picture 1> is the global
47
+ appearance anchor for the whole take, and the first frame of the target is locked to this
48
+ exact image: the clip begins at precisely this visual state — identities, appearance,
49
+ materials, art direction, lighting, camera framing and layout — and continues directly from
50
+ it while performing the timestamped actions. Do not restart, redesign or re-interpret any
51
+ identity.
cwm_h3_inference/prompts/system_wn.txt ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ AWM_PROXY_CONTROL. Two visual references accompany this text. <Video 1> is the conditioning
2
+ proxy video: treat it as a coarse spatiotemporal reference, not an appearance template or
3
+ literal target geometry. <Picture 1> is the global RGB anchor image: it defines identity and
4
+ appearance, never motion. <Picture 1> is the ABSOLUTE standard for the worldview, art
5
+ direction and overall style of the entire take: every clip of the take, no matter how far into the
6
+ future, must align with it to a high degree, and on worldview, art direction and style it
7
+ outranks even the current clip's caption. It provides NOTHING about any future frame's
8
+ current camera, composition, motion or interactions. Each subject's position and motion come
9
+ from <Video 1>. A subject's appearance follows <Picture 1> when that subject is visible in
10
+ it; subjects not visible in <Picture 1> take their appearance from the timestamped text,
11
+ rendered consistently in <Picture 1>'s worldview and art direction.
12
+
13
+ Human proxies in <Video 1> provide only framewise root position, coarse occupied scale, and
14
+ whole-body root orientation, which are reliable at roughly 80-100% and should be followed
15
+ closely. They carry no information about character appearance and do not represent identity,
16
+ face, hairstyle, clothing, materials, colors, visual effects, anatomy, bones, joints, limb
17
+ articulation, detailed posture, torso or head pose, hand pose, action phase, or physical
18
+ contact. Reconstruct each character's appearance from <Picture 1> and the timestamped text,
19
+ and reconstruct articulated motion from the timestamped text. Never reproduce a human proxy
20
+ as a skeleton, stick figure, mannequin, node-connected body, or exposed rig. Left and right
21
+ refer to each person's own body unless explicitly stated as screen direction.
22
+
23
+ Vehicle proxies in <Video 1> provide only framewise position, trajectory, body heading,
24
+ rotation, and accurate overall length, width, height, and occupied scale; these anchors
25
+ should be followed closely. They carry no information about vehicle appearance. Never
26
+ reproduce a vehicle proxy as a wireframe vehicle, cage frame, tubular skeleton, exposed
27
+ chassis, or low-poly proxy car. Vehicle identity, type, design details, materials, colors,
28
+ and visual effects are defined by <Picture 1> and the text.
29
+
30
+ Roads, terrain, buildings, relative depth, rough scale, and layout in <Video 1> provide
31
+ denser but still approximate structural cues. The text may reinterpret these cues to create
32
+ strongly out-of-distribution scenes while maintaining broad spatial and temporal coherence.
33
+ Conditioning colors, proxy geometry, node markers, rigging structures, and other
34
+ visualization artifacts must never appear in the final video.
35
+
36
+ When sources conflict, resolve in this order: (1) geometry, camera, position and motion
37
+ follow <Video 1>; (2) the worldview, art direction and overall style follow <Picture 1>
38
+ absolutely — above the current clip's caption; identity, appearance and materials follow
39
+ <Picture 1> for subjects visible in it, and follow the text for subjects not visible in it;
40
+ (3) the timestamped text defines the concrete events, actions and rewrite targets within
41
+ those constraints; (4) when the opening of this clip is fixed to already-given video frames,
42
+ seamless continuity with that fixed opening overrides everything else — never restart the
43
+ scene.
44
+
45
+ Clip contract: this clip continues a take already in progress. <Picture 1> is the absolute standard for the take's
46
+ worldview, art direction and style, and for the appearance of the subjects visible in it —
47
+ and it provides ONLY that. It is NOT the first frame of this clip and must never be read as
48
+ the current composition, camera, motion or interaction state. The first 34 frames (1.4167
49
+ seconds) of this clip are ALREADY GIVEN: they are fixed, existing video footage — the moments
50
+ that immediately precede the new content — locked in place, not to be invented or altered.
51
+ From that fixed opening, continue the video seamlessly: the same ongoing time, positions,
52
+ poses, action phase and camera simply carry forward — never a scene cut, restart,
53
+ re-introduction, or reset toward the anchor image's opening composition. Current-clip
54
+ motion follows <Video 1>. Keep subjects that are visible in <Picture 1> continuously
55
+ consistent with it in appearance; render everything else — including subjects that first
56
+ appear in this clip — from the timestamped text, in <Picture 1>'s established art
57
+ direction.
cwm_h3_inference/runtime.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure inference layout construction and strict LoRA loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any
9
+
10
+ from .constants import AUDIO_LATENT_SHAPE, VIDEO_LATENT_SHAPE
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RuntimeBatch:
15
+ layout: Any
16
+ text_hidden_states: Any
17
+ text_token_tags: Any
18
+
19
+
20
+ def build_runtime_batch(anchor: Any, reference: Any, hidden: Any, tags: Any) -> RuntimeBatch:
21
+ import torch
22
+ from musubi_tuner.minimax_h3.packing import H3ReferenceGeometry, H3VideoGeometry, build_h3_layout
23
+
24
+ if anchor.ndim != 5 or tuple(anchor.shape[:3]) != (1, 24, 1):
25
+ raise ValueError(f"anchor must be [1,24,1,H,W], got {tuple(anchor.shape)}")
26
+ if reference.ndim != 5 or tuple(reference.shape[:3]) != (1, 24, 37):
27
+ raise ValueError(f"reference must be [1,24,37,H,W], got {tuple(reference.shape)}")
28
+ if hidden.ndim != 3 or hidden.shape[0] != 1 or hidden.shape[2] != 5120:
29
+ raise ValueError(f"text hidden states must be [1,L,5120], got {tuple(hidden.shape)}")
30
+ if tags.shape != hidden.shape[:2] or tags.dtype != torch.int64:
31
+ raise ValueError("text token tags must be int64 [1,L]")
32
+ layout = build_h3_layout(
33
+ task="ref2va",
34
+ text_length=hidden.shape[1],
35
+ target_video=H3VideoGeometry(*VIDEO_LATENT_SHAPE[2:]),
36
+ target_audio_frames=AUDIO_LATENT_SHAPE[-1],
37
+ references=(
38
+ H3ReferenceGeometry("image", video=H3VideoGeometry(*anchor.shape[2:])),
39
+ H3ReferenceGeometry("video", video=H3VideoGeometry(*reference.shape[2:])),
40
+ ),
41
+ )
42
+ return RuntimeBatch(layout=layout, text_hidden_states=hidden, text_token_tags=tags)
43
+
44
+
45
+ def strict_merge_lora(transformer: Any, checkpoint: Path, expected_modules: int) -> dict[str, int]:
46
+ import torch
47
+ from safetensors.torch import load_file
48
+ from musubi_tuner.networks import lora_minimax_h3
49
+ from musubi_tuner.utils.lora_utils import filter_lora_state_dict
50
+
51
+ state = filter_lora_state_dict(load_file(str(checkpoint)), None, None)
52
+ network = lora_minimax_h3.create_arch_network_from_weights(
53
+ 1.0, state, unet=transformer, for_inference=True
54
+ )
55
+ if network.text_encoder_loras:
56
+ raise ValueError("LoRA unexpectedly targets the text encoder")
57
+ if len(network.unet_loras) != expected_modules:
58
+ raise ValueError(
59
+ f"LoRA module count mismatch: expected {expected_modules}, got {len(network.unet_loras)}"
60
+ )
61
+ expected_keys: set[str] = set()
62
+ names: set[str] = set()
63
+ for module in network.unet_loras:
64
+ name = str(module.lora_name)
65
+ if name in names or module.split_dims is not None or re.search(r"[^A-Za-z0-9_.]", name):
66
+ raise ValueError(f"invalid LoRA target: {name}")
67
+ names.add(name)
68
+ expected_keys.update(
69
+ {f"{name}.alpha", f"{name}.lora_down.weight", f"{name}.lora_up.weight"}
70
+ )
71
+ missing = expected_keys - set(state)
72
+ unexpected = set(state) - expected_keys
73
+ if missing or unexpected:
74
+ raise ValueError(f"LoRA key mismatch: missing={len(missing)} unexpected={len(unexpected)}")
75
+ network.merge_to(None, transformer, state, dtype=torch.bfloat16, device="cpu")
76
+ result = {"loaded_modules": len(names), "missing_keys": 0, "unexpected_keys": 0}
77
+ del network, state
78
+ return result
materialize_hf_examples.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Expand the published compact CWM examples into inference-ready inputs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import os
11
+ from pathlib import Path
12
+ import shutil
13
+ import tempfile
14
+ from typing import Any, Iterable
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+
20
+ WIDTH = 336
21
+ HEIGHT = 192
22
+ WINDOW_FRAMES = 124
23
+ STRIDE_FRAMES = 90
24
+ NEAR_METRES = 0.3
25
+ FAR_METRES = 256.0
26
+ METRIC_DEPTH = "float32_le_metric_first_surface_metres"
27
+ FIXED_LOG_DEPTH = "uint16_le_fixed_log_gray16_codes"
28
+ SUPPORTED_DEPTH_FORMATS = {METRIC_DEPTH, FIXED_LOG_DEPTH}
29
+ RELEASE_MODELS = {
30
+ "audio_vae": "../../models/vae/minimax_h3_audio_vae_fp32.safetensors",
31
+ "dit": "../../models/diffusion_models/minimax_h3_ref2va_bf16.safetensors",
32
+ "lora": "../../models/lora/minimax-h3-cwm.safetensors",
33
+ "processor": "../../models/processors/Qwen3-VL-32B-Instruct",
34
+ "text_encoder": "../../models/text_encoders/qwen3vl_32b_minimax_h3_bf16.safetensors",
35
+ "video_vae": "../../models/vae/minimax_h3_video_vae_fp16.safetensors",
36
+ }
37
+ RELEASE_SEEDS = {
38
+ 1: 9942001,
39
+ 2: 9800002,
40
+ 3: 9700005,
41
+ 4: 9700006,
42
+ 5: 9700007,
43
+ 6: 9700013,
44
+ 7: 9700020,
45
+ 8: 9700021,
46
+ 9: 9700030,
47
+ 10: 9700036,
48
+ 11: 9700037,
49
+ 12: 9700040,
50
+ 13: 9700044,
51
+ 14: 9700045,
52
+ 15: 9700046,
53
+ 16: 9700047,
54
+ 17: 9700051,
55
+ 18: 9700054,
56
+ 19: 9700055,
57
+ 20: 9700057,
58
+ 21: 9700063,
59
+ 22: 9700065,
60
+ 23: 9700069,
61
+ 24: 9700071,
62
+ 25: 9700078,
63
+ 26: 9700088,
64
+ 27: 9700094,
65
+ 28: 9600014,
66
+ 29: 9600020,
67
+ 30: 9600023,
68
+ 31: 9600028,
69
+ 32: 9600040,
70
+ 33: 9600042,
71
+ 34: 9300038,
72
+ 35: 9400059,
73
+ 36: 9300066,
74
+ 37: 9300069,
75
+ 38: 9800006,
76
+ 39: 9800010,
77
+ 40: 9800013,
78
+ }
79
+
80
+
81
+ def require(condition: bool, message: str) -> None:
82
+ if not condition:
83
+ raise ValueError(message)
84
+
85
+
86
+ def _read_json(path: Path) -> Any:
87
+ return json.loads(path.read_text(encoding="utf-8"))
88
+
89
+
90
+ def _canonical_prompt(value: str) -> str:
91
+ return value.strip().replace("\r\n", "\n").replace("\r", "\n")
92
+
93
+
94
+ def build_release_config(
95
+ example_root: Path, metadata: dict[str, Any], example_id: int
96
+ ) -> dict[str, Any]:
97
+ windows = []
98
+ for prompt_item in metadata["prompts"]:
99
+ prompt = _canonical_prompt(
100
+ (example_root / prompt_item["file"]).read_text(encoding="utf-8")
101
+ )
102
+ # Preserve the text representation used by the released configurations.
103
+ if example_id != 2:
104
+ prompt = prompt.replace("\n", "\r\n")
105
+ windows.append(
106
+ {
107
+ "prompt": prompt,
108
+ "start_frame": prompt_item["conditioning_frame_start"],
109
+ }
110
+ )
111
+ name = f"example_{example_id:02d}"
112
+ return {
113
+ "anchor_image": "first_frame.png",
114
+ "attention_mode": "flash3",
115
+ "blocks_to_swap": 48,
116
+ "cache_dir": f"../../outputs/{name}/cache",
117
+ "condition_root": "conditions",
118
+ "expected_lora_modules": 200,
119
+ "models": RELEASE_MODELS,
120
+ "output": f"../../outputs/{name}/output.mp4",
121
+ "seed": RELEASE_SEEDS[example_id],
122
+ "steps": 20,
123
+ "windows": windows,
124
+ }
125
+
126
+
127
+ def ensure_release_config(
128
+ config_path: Path, example_root: Path, metadata: dict[str, Any], example_id: int
129
+ ) -> None:
130
+ if not config_path.exists():
131
+ payload = json.dumps(
132
+ build_release_config(example_root, metadata, example_id),
133
+ ensure_ascii=False,
134
+ indent=2,
135
+ sort_keys=True,
136
+ )
137
+ _atomic_bytes(config_path, (payload + "\n").encode("utf-8"))
138
+ validate_config(config_path, example_root, metadata, example_id)
139
+
140
+
141
+ def fixed_log_codes_to_metric(codes: np.ndarray) -> np.ndarray:
142
+ """Choose float32 depths that round-trip to every published uint16 code."""
143
+ require(codes.dtype == np.dtype("uint16"), f"fixed-log depth must be uint16, got {codes.dtype}")
144
+ metric = np.zeros(codes.shape, dtype="<f4")
145
+ valid = codes > 0
146
+ if bool(np.any(valid)):
147
+ log_near = math.log(NEAR_METRES)
148
+ log_far = math.log(FAR_METRES)
149
+ normalized = codes[valid].astype(np.float64) / 65535.0
150
+ metric[valid] = np.exp(log_far - normalized * (log_far - log_near)).astype(np.float32)
151
+ return np.ascontiguousarray(metric)
152
+
153
+
154
+ def metric_to_fixed_log_codes(metric: np.ndarray) -> np.ndarray:
155
+ """Reference implementation matching the inference loader."""
156
+ metric = np.asarray(metric, dtype=np.float32)
157
+ valid = metric > np.float32(0.001)
158
+ codes = np.zeros(metric.shape, dtype=np.uint16)
159
+ if bool(np.any(valid)):
160
+ clipped = np.clip(metric[valid].astype(np.float64), NEAR_METRES, FAR_METRES)
161
+ normalized = (math.log(FAR_METRES) - np.log(clipped)) / (
162
+ math.log(FAR_METRES) - math.log(NEAR_METRES)
163
+ )
164
+ codes[valid] = np.floor(np.clip(normalized, 0.0, 1.0) * 65535.0 + 0.5).astype(
165
+ np.uint16
166
+ )
167
+ return codes
168
+
169
+
170
+ def _atomic_bytes(path: Path, payload: bytes) -> None:
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ if path.exists():
173
+ require(path.read_bytes() == payload, f"existing file differs: {path}")
174
+ return
175
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
176
+ temporary = Path(temporary_name)
177
+ try:
178
+ with os.fdopen(descriptor, "wb") as handle:
179
+ handle.write(payload)
180
+ handle.flush()
181
+ os.fsync(handle.fileno())
182
+ temporary.replace(path)
183
+ finally:
184
+ temporary.unlink(missing_ok=True)
185
+
186
+
187
+ def _atomic_semantic_png(path: Path, semantic_id: np.ndarray) -> None:
188
+ semantic_id = np.ascontiguousarray(semantic_id, dtype=np.uint8)
189
+ if path.exists():
190
+ with Image.open(path) as image:
191
+ existing = np.asarray(image.convert("L"), dtype=np.uint8)
192
+ require(np.array_equal(existing, semantic_id), f"existing Semantic-ID differs: {path}")
193
+ return
194
+ path.parent.mkdir(parents=True, exist_ok=True)
195
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".png", dir=path.parent)
196
+ os.close(descriptor)
197
+ temporary = Path(temporary_name)
198
+ try:
199
+ Image.fromarray(semantic_id, mode="L").save(temporary, format="PNG", compress_level=6)
200
+ temporary.replace(path)
201
+ finally:
202
+ temporary.unlink(missing_ok=True)
203
+
204
+
205
+ def _copy_exact(source: Path, destination: Path) -> None:
206
+ require(source.is_file(), f"missing source file: {source}")
207
+ payload = source.read_bytes()
208
+ _atomic_bytes(destination, payload)
209
+
210
+
211
+ def validate_metadata(example_root: Path, metadata: dict[str, Any], example_id: int) -> None:
212
+ require(metadata.get("id") == example_id, f"example {example_id}: metadata id mismatch")
213
+ require(metadata.get("fps") == 24, f"example {example_id}: fps must be 24")
214
+ require(metadata.get("resolution") == [WIDTH, HEIGHT], f"example {example_id}: bad resolution")
215
+ require(metadata.get("depth_format") in SUPPORTED_DEPTH_FORMATS, f"example {example_id}: unsupported depth format")
216
+ frames = metadata.get("frames")
217
+ require(type(frames) is int and frames >= WINDOW_FRAMES, f"example {example_id}: invalid frame count")
218
+ prompts = metadata.get("prompts")
219
+ require(isinstance(prompts, list) and prompts, f"example {example_id}: prompts missing")
220
+ require(frames == WINDOW_FRAMES + STRIDE_FRAMES * (len(prompts) - 1), f"example {example_id}: frame/window mismatch")
221
+ for index, item in enumerate(prompts):
222
+ require(item.get("index") == index, f"example {example_id}: prompt index mismatch")
223
+ require(item.get("conditioning_frame_start") == index * STRIDE_FRAMES, f"example {example_id}: prompt start mismatch")
224
+ require(item.get("conditioning_frame_end_exclusive") == index * STRIDE_FRAMES + WINDOW_FRAMES, f"example {example_id}: prompt end mismatch")
225
+ prompt_path = example_root / item.get("file", "")
226
+ require(prompt_path.is_file(), f"example {example_id}: missing prompt {prompt_path}")
227
+ chunks = metadata.get("condition_chunks")
228
+ require(isinstance(chunks, list) and chunks, f"example {example_id}: condition chunks missing")
229
+ cursor = 0
230
+ for item in chunks:
231
+ start = item.get("start_frame")
232
+ end = item.get("end_frame_exclusive")
233
+ require(start == cursor and type(end) is int and end > start, f"example {example_id}: non-contiguous chunks")
234
+ require(item.get("frames") == end - start, f"example {example_id}: chunk frame count mismatch")
235
+ chunk_path = example_root / item.get("file", "")
236
+ require(chunk_path.is_file(), f"example {example_id}: missing chunk {chunk_path}")
237
+ cursor = end
238
+ require(cursor == frames, f"example {example_id}: chunks do not cover all frames")
239
+
240
+
241
+ def validate_config(config_path: Path, example_root: Path, metadata: dict[str, Any], example_id: int) -> None:
242
+ require(config_path.is_file(), f"missing released config: {config_path}")
243
+ config = _read_json(config_path)
244
+ require(config.get("anchor_image") == "first_frame.png", f"example {example_id}: config anchor path mismatch")
245
+ require(config.get("condition_root") == "conditions", f"example {example_id}: config condition path mismatch")
246
+ require(config.get("seed") == RELEASE_SEEDS[example_id], f"example {example_id}: released seed mismatch")
247
+ require(config.get("steps") == 20, f"example {example_id}: steps mismatch")
248
+ require(config.get("attention_mode") == "flash3", f"example {example_id}: attention mode mismatch")
249
+ require(config.get("blocks_to_swap") == 48, f"example {example_id}: block swap count mismatch")
250
+ require(config.get("expected_lora_modules") == 200, f"example {example_id}: LoRA module count mismatch")
251
+ require(config.get("models") == RELEASE_MODELS, f"example {example_id}: model paths mismatch")
252
+ name = f"example_{example_id:02d}"
253
+ require(config.get("cache_dir") == f"../../outputs/{name}/cache", f"example {example_id}: cache path mismatch")
254
+ require(config.get("output") == f"../../outputs/{name}/output.mp4", f"example {example_id}: output path mismatch")
255
+ windows = config.get("windows")
256
+ prompt_items = metadata["prompts"]
257
+ require(isinstance(windows, list) and len(windows) == len(prompt_items), f"example {example_id}: config window count mismatch")
258
+ for window, prompt_item in zip(windows, prompt_items, strict=True):
259
+ require(window.get("start_frame") == prompt_item["conditioning_frame_start"], f"example {example_id}: config window start mismatch")
260
+ published = (example_root / prompt_item["file"]).read_text(encoding="utf-8")
261
+ require(_canonical_prompt(window.get("prompt", "")) == _canonical_prompt(published), f"example {example_id}: config prompt differs from published prompt")
262
+
263
+
264
+ def _iter_chunks(example_root: Path, metadata: dict[str, Any]) -> Iterable[tuple[int, np.ndarray, np.ndarray]]:
265
+ depth_format = metadata["depth_format"]
266
+ for chunk in metadata["condition_chunks"]:
267
+ path = example_root / chunk["file"]
268
+ with np.load(path, allow_pickle=False) as payload:
269
+ require(set(payload.files) == {"depth", "semantic_id"}, f"unexpected arrays in {path}")
270
+ depth = payload["depth"]
271
+ semantic_id = payload["semantic_id"]
272
+ expected_shape = (chunk["frames"], HEIGHT, WIDTH)
273
+ require(depth.shape == expected_shape, f"bad depth shape in {path}: {depth.shape}")
274
+ require(semantic_id.shape == expected_shape, f"bad Semantic-ID shape in {path}: {semantic_id.shape}")
275
+ require(semantic_id.dtype == np.dtype("uint8"), f"bad Semantic-ID dtype in {path}: {semantic_id.dtype}")
276
+ require(int(semantic_id.max(initial=0)) <= 11, f"Semantic-ID outside [0,11] in {path}")
277
+ if depth_format == METRIC_DEPTH:
278
+ require(depth.dtype == np.dtype("float32"), f"bad metric depth dtype in {path}: {depth.dtype}")
279
+ require(bool(np.all(np.isfinite(depth))) and not bool(np.any(depth < 0)), f"invalid metric depth in {path}")
280
+ metric = np.ascontiguousarray(depth, dtype="<f4")
281
+ else:
282
+ require(depth.dtype == np.dtype("uint16"), f"bad fixed-log depth dtype in {path}: {depth.dtype}")
283
+ metric = fixed_log_codes_to_metric(depth)
284
+ require(np.array_equal(metric_to_fixed_log_codes(metric), depth), f"fixed-log round-trip failed in {path}")
285
+ start = chunk["start_frame"]
286
+ for offset in range(chunk["frames"]):
287
+ yield start + offset, metric[offset], semantic_id[offset]
288
+
289
+
290
+ def materialize_example(
291
+ source_root: Path,
292
+ output_root: Path,
293
+ example_id: int,
294
+ *,
295
+ require_config: bool,
296
+ frame_limit: int | None = None,
297
+ ) -> dict[str, Any]:
298
+ example_root = source_root / str(example_id)
299
+ metadata_path = example_root / "metadata.json"
300
+ require(metadata_path.is_file(), f"missing metadata: {metadata_path}")
301
+ metadata = _read_json(metadata_path)
302
+ validate_metadata(example_root, metadata, example_id)
303
+ destination = output_root / "examples" / f"example_{example_id:02d}"
304
+ config_path = destination / "config.json"
305
+ if require_config:
306
+ ensure_release_config(config_path, example_root, metadata, example_id)
307
+ _copy_exact(example_root / metadata["first_frame"], destination / "first_frame.png")
308
+ written = 0
309
+ for ordinal, depth, semantic_id in _iter_chunks(example_root, metadata):
310
+ if frame_limit is not None and ordinal >= frame_limit:
311
+ break
312
+ condition_root = destination / "conditions"
313
+ _atomic_bytes(condition_root / f"{ordinal:06d}.depth.f32", np.ascontiguousarray(depth, dtype="<f4").tobytes(order="C"))
314
+ _atomic_semantic_png(condition_root / f"{ordinal:06d}.semantic_id.png", semantic_id)
315
+ written += 1
316
+ expected = min(metadata["frames"], frame_limit) if frame_limit is not None else metadata["frames"]
317
+ require(written == expected, f"example {example_id}: materialized {written}, expected {expected}")
318
+ return {
319
+ "id": example_id,
320
+ "depth_format": metadata["depth_format"],
321
+ "frames": metadata["frames"],
322
+ "materialized_frames": written,
323
+ "windows": len(metadata["prompts"]),
324
+ }
325
+
326
+
327
+ def parse_ids(values: list[int] | None) -> list[int]:
328
+ ids = list(range(1, 41)) if not values else sorted(set(values))
329
+ require(ids and all(1 <= value <= 40 for value in ids), "example ids must be in [1,40]")
330
+ return ids
331
+
332
+
333
+ def main(argv: list[str] | None = None) -> int:
334
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
335
+ parser.add_argument("--source", required=True, type=Path, help="downloaded code-world-model-inference-examples-40 directory")
336
+ parser.add_argument("--output", required=True, type=Path, help="release root containing models/, examples/, and outputs/")
337
+ parser.add_argument("--example", action="append", type=int, dest="examples", help="materialize one public example id; repeat as needed")
338
+ parser.add_argument("--without-config-check", action="store_true", help=argparse.SUPPRESS)
339
+ parser.add_argument("--frame-limit", type=int, help=argparse.SUPPRESS)
340
+ args = parser.parse_args(argv)
341
+ source = args.source.resolve()
342
+ output = args.output.resolve()
343
+ manifest = _read_json(source / "manifest.json")
344
+ require(manifest.get("schema") == "code_world_model_inference_examples_v3", "unsupported dataset manifest")
345
+ require(manifest.get("count") == 40 and manifest.get("expected_count") == 40, "dataset manifest does not contain 40 examples")
346
+ results = []
347
+ for example_id in parse_ids(args.examples):
348
+ result = materialize_example(
349
+ source,
350
+ output,
351
+ example_id,
352
+ require_config=not args.without_config_check,
353
+ frame_limit=args.frame_limit,
354
+ )
355
+ results.append(result)
356
+ print(f"example_{example_id:02d}: {result['materialized_frames']} frames", flush=True)
357
+ receipt = {
358
+ "schema": "cwm_h3_materialized_examples_v1",
359
+ "source_manifest_sha256": hashlib.sha256((source / "manifest.json").read_bytes()).hexdigest(),
360
+ "examples": results,
361
+ }
362
+ if args.frame_limit is None and len(results) == 40:
363
+ path = output / "examples" / "materialization.json"
364
+ _atomic_bytes(path, (json.dumps(receipt, indent=2, sort_keys=True) + "\n").encode("utf-8"))
365
+ return 0
366
+
367
+
368
+ if __name__ == "__main__":
369
+ raise SystemExit(main())
musubi_tuner/__init__.py ADDED
File without changes
musubi_tuner/minimax_h3/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """MiniMax-H3 model, cache, training, and sampling support."""
musubi_tuner/minimax_h3/audio_vae.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniMax H3 audio VAE: DAC-lineage waveform encoder + BigVGAN decoder.
2
+ # Weight-norm parametrizations are folded into plain conv weights, so this
3
+ # module uses ordinary torch Conv1d / ConvTranspose1d layers and loads the
4
+ # converted checkpoint's plain "*.weight" tensors through the shared loader.
5
+ #
6
+ # Lineage / licenses of the reference implementation:
7
+ # DAC encoder: descript-audio-codec (MIT)
8
+ # BigVGAN decoder: NVIDIA BigVGAN (MIT), adapted from hifi-gan (MIT)
9
+ # Alias-free ops: junjun3518/alias-free-torch (Apache-2.0), julius (MIT)
10
+
11
+ import math
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+ # Snake activations
18
+
19
+
20
+ def snake(x, alpha, beta):
21
+ # x + 1/beta * sin^2(alpha * x)
22
+ t = torch.sin(alpha * x)
23
+ return t.mul_(t).mul_((beta + 1e-9).reciprocal()).add_(x)
24
+
25
+
26
+ class Snake1d(nn.Module):
27
+ """Snake activation with per-channel alpha (encoder side)."""
28
+
29
+ def __init__(self, channels):
30
+ super().__init__()
31
+ self.alpha = nn.Parameter(torch.empty(1, channels, 1))
32
+
33
+ def forward(self, x):
34
+ return snake(x, self.alpha, self.alpha)
35
+
36
+
37
+ class SnakeBeta(nn.Module):
38
+ """SnakeBeta := x + 1/beta * sin^2(alpha * x); alpha/beta stored in log scale."""
39
+
40
+ def __init__(self, in_features):
41
+ super().__init__()
42
+ self.alpha = nn.Parameter(torch.empty(in_features))
43
+ self.beta = nn.Parameter(torch.empty(in_features))
44
+
45
+ def forward(self, x):
46
+ alpha = torch.exp(self.alpha).view(1, -1, 1)
47
+ beta = torch.exp(self.beta).view(1, -1, 1)
48
+ return snake(x, alpha, beta)
49
+
50
+
51
+ # Alias-free (anti-aliased) activation: kaiser-windowed sinc resampling
52
+
53
+
54
+ def kaiser_sinc_filter1d(cutoff, half_width, kernel_size):
55
+ # returns filter [1, 1, kernel_size]
56
+ even = kernel_size % 2 == 0
57
+ half_size = kernel_size // 2
58
+
59
+ # kaiser window design
60
+ delta_f = 4 * half_width
61
+ A = 2.285 * (half_size - 1) * math.pi * delta_f + 7.95
62
+ if A > 50.0:
63
+ beta = 0.1102 * (A - 8.7)
64
+ elif A >= 21.0:
65
+ beta = 0.5842 * (A - 21) ** 0.4 + 0.07886 * (A - 21.0)
66
+ else:
67
+ beta = 0.0
68
+ window = torch.kaiser_window(kernel_size, beta=beta, periodic=False)
69
+
70
+ if even:
71
+ time = torch.arange(-half_size, half_size) + 0.5
72
+ else:
73
+ time = torch.arange(kernel_size) - half_size
74
+
75
+ filter_ = 2 * cutoff * window * torch.sinc(2 * cutoff * time)
76
+ # Normalize filter to have sum = 1, otherwise there is a small leakage of
77
+ # the constant component in the input signal.
78
+ filter_ /= filter_.sum()
79
+ return filter_.view(1, 1, kernel_size)
80
+
81
+
82
+ class UpSample1d(nn.Module):
83
+ def __init__(self, ratio=2, kernel_size=12):
84
+ super().__init__()
85
+ self.ratio = ratio
86
+ self.stride = ratio
87
+ self.pad = kernel_size // ratio - 1
88
+ self.pad_left = self.pad * ratio + (kernel_size - ratio) // 2
89
+ self.pad_right = self.pad * ratio + (kernel_size - ratio + 1) // 2
90
+ self.register_buffer(
91
+ "filter",
92
+ kaiser_sinc_filter1d(cutoff=0.5 / ratio, half_width=0.6 / ratio, kernel_size=kernel_size),
93
+ )
94
+
95
+ def forward(self, x):
96
+ _, C, _ = x.shape
97
+ x = F.pad(x, (self.pad, self.pad), mode="replicate")
98
+ x = F.conv_transpose1d(x, self.filter.expand(C, -1, -1).to(x.dtype), stride=self.stride, groups=C).mul_(self.ratio)
99
+ x = x[..., self.pad_left : -self.pad_right]
100
+ return x
101
+
102
+
103
+ class LowPassFilter1d(nn.Module):
104
+ def __init__(self, cutoff=0.5, half_width=0.6, stride=1, kernel_size=12):
105
+ super().__init__()
106
+ self.pad_left = kernel_size // 2 - int(kernel_size % 2 == 0)
107
+ self.pad_right = kernel_size // 2
108
+ self.stride = stride
109
+ self.register_buffer("filter", kaiser_sinc_filter1d(cutoff, half_width, kernel_size))
110
+
111
+ def forward(self, x):
112
+ _, C, _ = x.shape
113
+ x = F.pad(x, (self.pad_left, self.pad_right), mode="replicate")
114
+ return F.conv1d(x, self.filter.expand(C, -1, -1).to(x.dtype), stride=self.stride, groups=C)
115
+
116
+
117
+ class DownSample1d(nn.Module):
118
+ def __init__(self, ratio=2, kernel_size=12):
119
+ super().__init__()
120
+ self.ratio = ratio
121
+ self.kernel_size = kernel_size
122
+ self.lowpass = LowPassFilter1d(
123
+ cutoff=0.5 / ratio,
124
+ half_width=0.6 / ratio,
125
+ stride=ratio,
126
+ kernel_size=self.kernel_size,
127
+ )
128
+
129
+ def forward(self, x):
130
+ return self.lowpass(x)
131
+
132
+
133
+ class Activation1d(nn.Module):
134
+ """upsample x2 -> pointwise activation -> downsample x2 (anti-aliased)."""
135
+
136
+ def __init__(self, activation, up_ratio=2, down_ratio=2, up_kernel_size=12, down_kernel_size=12):
137
+ super().__init__()
138
+ self.act = activation
139
+ self.upsample = UpSample1d(up_ratio, up_kernel_size)
140
+ self.downsample = DownSample1d(down_ratio, down_kernel_size)
141
+
142
+ def forward(self, x):
143
+ x = self.upsample(x)
144
+ x = self.act(x)
145
+ x = self.downsample(x)
146
+ return x
147
+
148
+
149
+ # DAC encoder
150
+
151
+
152
+ class ResidualUnit(nn.Module):
153
+ def __init__(self, dim=16, dilation=1):
154
+ super().__init__()
155
+ pad = ((7 - 1) * dilation) // 2
156
+ self.block = nn.Sequential(
157
+ Snake1d(dim),
158
+ nn.Conv1d(dim, dim, kernel_size=7, dilation=dilation, padding=pad),
159
+ Snake1d(dim),
160
+ nn.Conv1d(dim, dim, kernel_size=1),
161
+ )
162
+
163
+ def forward(self, x):
164
+ y = self.block(x)
165
+ pad = (x.shape[-1] - y.shape[-1]) // 2
166
+ if pad > 0:
167
+ x = x[..., pad:-pad]
168
+ return y.add_(x)
169
+
170
+
171
+ class EncoderBlock(nn.Module):
172
+ def __init__(self, dim=16, stride=1):
173
+ super().__init__()
174
+ self.block = nn.Sequential(
175
+ ResidualUnit(dim // 2, dilation=1),
176
+ ResidualUnit(dim // 2, dilation=3),
177
+ ResidualUnit(dim // 2, dilation=9),
178
+ Snake1d(dim // 2),
179
+ nn.Conv1d(
180
+ dim // 2,
181
+ dim,
182
+ kernel_size=2 * stride,
183
+ stride=stride,
184
+ padding=math.ceil(stride / 2),
185
+ ),
186
+ )
187
+
188
+ def forward(self, x):
189
+ return self.block(x)
190
+
191
+
192
+ class Encoder(nn.Module):
193
+ def __init__(self, d_model=64, strides=(2, 4, 4, 5, 5), d_latent=2048):
194
+ super().__init__()
195
+ block = [nn.Conv1d(1, d_model, kernel_size=7, padding=3)]
196
+ for stride in strides:
197
+ d_model *= 2
198
+ block += [EncoderBlock(d_model, stride=stride)]
199
+ block += [
200
+ Snake1d(d_model),
201
+ nn.Conv1d(d_model, d_latent, kernel_size=3, padding=1),
202
+ ]
203
+ self.block = nn.Sequential(*block)
204
+
205
+ def forward(self, x):
206
+ return self.block(x)
207
+
208
+
209
+ # Attention projection (encoder posterior head)
210
+
211
+
212
+ class GeGluMlp(nn.Module):
213
+ def __init__(self, in_features, hidden_features):
214
+ super().__init__()
215
+ self.norm = nn.LayerNorm(in_features)
216
+ self.act = nn.GELU(approximate="tanh")
217
+ self.w0 = nn.Linear(in_features, hidden_features)
218
+ self.w1 = nn.Linear(in_features, hidden_features)
219
+ self.w2 = nn.Linear(hidden_features, in_features)
220
+
221
+ def forward(self, x):
222
+ x = self.norm(x)
223
+ return self.w2(self.act(self.w0(x)).mul_(self.w1(x)))
224
+
225
+
226
+ class CausalAttention(nn.Module):
227
+ def __init__(self, in_dim, out_dim, num_heads):
228
+ super().__init__()
229
+ self.head_dim = in_dim // num_heads
230
+ self.num_heads = num_heads
231
+ self.out_dim = out_dim
232
+ self.qkv = nn.Linear(in_dim, in_dim * 3, bias=False)
233
+ self.q_bias = nn.Parameter(torch.empty(in_dim))
234
+ self.v_bias = nn.Parameter(torch.empty(in_dim))
235
+ self.register_buffer("zero_k_bias", torch.empty(in_dim))
236
+ self.proj = nn.Linear(out_dim, out_dim)
237
+
238
+ def forward(self, x):
239
+ B, N, C = x.shape
240
+ qkv = F.linear(x, weight=self.qkv.weight, bias=torch.cat((self.q_bias, self.zero_k_bias, self.v_bias)))
241
+ q, k, v = qkv.reshape(B, N, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4).unbind(0)
242
+
243
+ # mean over heads then pool down to the latent width (in_dim >> out_dim)
244
+ x = F.scaled_dot_product_attention(q, k, v, is_causal=True)
245
+ x = F.adaptive_avg_pool1d(torch.mean(x, dim=1), self.out_dim)
246
+ return self.proj(x)
247
+
248
+
249
+ class AttnProjection(nn.Module):
250
+ def __init__(self, in_dim, out_dim, num_heads, mlp_ratio=2):
251
+ super().__init__()
252
+ self.norm1 = nn.LayerNorm(in_dim)
253
+ self.attn = CausalAttention(in_dim, out_dim, num_heads)
254
+ self.proj = nn.Linear(in_dim, out_dim)
255
+ self.norm3 = nn.LayerNorm(in_dim)
256
+
257
+ self.norm2 = nn.LayerNorm(out_dim)
258
+ hidden_dim = int(out_dim * mlp_ratio)
259
+ self.mlp = GeGluMlp(in_features=out_dim, hidden_features=hidden_dim)
260
+
261
+ def forward(self, x):
262
+ # x: [B, T, in_dim]
263
+ x = self.proj(self.norm3(x)).add_(self.attn(self.norm1(x)))
264
+ return x.add_(self.mlp(self.norm2(x)))
265
+
266
+
267
+ # BigVGAN decoder
268
+
269
+
270
+ def get_padding(kernel_size, dilation=1):
271
+ return int((kernel_size * dilation - dilation) / 2)
272
+
273
+
274
+ class AMPBlock1(nn.Module):
275
+ def __init__(self, channels, kernel_size=3, dilation=(1, 3, 5)):
276
+ super().__init__()
277
+ self.convs1 = nn.ModuleList(
278
+ [
279
+ nn.Conv1d(channels, channels, kernel_size, stride=1, dilation=d, padding=get_padding(kernel_size, d))
280
+ for d in dilation
281
+ ]
282
+ )
283
+ self.convs2 = nn.ModuleList(
284
+ [
285
+ nn.Conv1d(channels, channels, kernel_size, stride=1, dilation=1, padding=get_padding(kernel_size, 1))
286
+ for _ in range(len(dilation))
287
+ ]
288
+ )
289
+ self.num_layers = len(self.convs1) + len(self.convs2)
290
+ self.activations = nn.ModuleList([Activation1d(activation=SnakeBeta(channels)) for _ in range(self.num_layers)])
291
+
292
+ def forward(self, x):
293
+ acts1, acts2 = self.activations[::2], self.activations[1::2]
294
+ for c1, c2, a1, a2 in zip(self.convs1, self.convs2, acts1, acts2):
295
+ xt = a1(x)
296
+ xt = c1(xt)
297
+ xt = a2(xt)
298
+ xt = c2(xt)
299
+ x = xt.add_(x)
300
+ return x
301
+
302
+
303
+ class BigVGAN(nn.Module):
304
+ """BigVGAN vocoder (MiniMax H3 32 kHz configuration).
305
+
306
+ use_bias_at_final=False, use_tanh_at_final=False (output clamped to [-1, 1]).
307
+ """
308
+
309
+ def __init__(
310
+ self,
311
+ num_mels=2048,
312
+ upsample_initial_channel=1024,
313
+ upsample_rates=(5, 5, 2, 2, 2, 2, 2),
314
+ upsample_kernel_sizes=(9, 9, 4, 4, 4, 4, 4),
315
+ resblock_kernel_sizes=(3, 7, 11),
316
+ resblock_dilation_sizes=((1, 3, 5), (1, 3, 5), (1, 3, 5)),
317
+ ):
318
+ super().__init__()
319
+ self.num_kernels = len(resblock_kernel_sizes)
320
+ self.num_upsamples = len(upsample_rates)
321
+
322
+ self.conv_pre = nn.Conv1d(num_mels, upsample_initial_channel, 7, 1, padding=3)
323
+
324
+ self.ups = nn.ModuleList()
325
+ for i, (u, k) in enumerate(zip(upsample_rates, upsample_kernel_sizes)):
326
+ self.ups.append(
327
+ nn.ModuleList(
328
+ [
329
+ nn.ConvTranspose1d(
330
+ upsample_initial_channel // (2**i),
331
+ upsample_initial_channel // (2 ** (i + 1)),
332
+ k,
333
+ u,
334
+ padding=(k - u) // 2,
335
+ )
336
+ ]
337
+ )
338
+ )
339
+
340
+ self.resblocks = nn.ModuleList()
341
+ for i in range(len(self.ups)):
342
+ ch = upsample_initial_channel // (2 ** (i + 1))
343
+ for k, d in zip(resblock_kernel_sizes, resblock_dilation_sizes):
344
+ self.resblocks.append(AMPBlock1(ch, k, d))
345
+
346
+ self.activation_post = Activation1d(activation=SnakeBeta(ch))
347
+ self.conv_post = nn.Conv1d(ch, 1, 7, 1, padding=3, bias=False)
348
+
349
+ def forward(self, x):
350
+ x = self.conv_pre(x)
351
+
352
+ for i in range(self.num_upsamples):
353
+ for i_up in range(len(self.ups[i])):
354
+ x = self.ups[i][i_up](x)
355
+ xs = None
356
+ for j in range(self.num_kernels):
357
+ if xs is None:
358
+ xs = self.resblocks[i * self.num_kernels + j](x)
359
+ else:
360
+ xs += self.resblocks[i * self.num_kernels + j](x)
361
+ x = xs.div_(self.num_kernels)
362
+
363
+ x = self.activation_post(x)
364
+ return self.conv_post(x).clamp_(-1.0, 1.0)
365
+
366
+
367
+ # Top-level VAE
368
+
369
+
370
+ class MiniMaxH3AudioVAE(nn.Module):
371
+ """MiniMax H3 stereo audio VAE at 32 kHz.
372
+
373
+ Latents are [B, 32, 2, T]: 32 channels, 2 stereo channels, T frames at
374
+ 40 latent frames per second (800 audio samples per latent frame). The
375
+ stereo channels are processed independently by the mono encoder/decoder.
376
+ Latents are normalized with the stored per-channel latents_mean/std.
377
+ """
378
+
379
+ def __init__(
380
+ self,
381
+ encoder_dim=64,
382
+ encoder_rates=(2, 4, 4, 5, 5),
383
+ latent_dim=2048,
384
+ decoder_dim=1024,
385
+ vae_latent_channels=32,
386
+ ):
387
+ super().__init__()
388
+ self.sample_rate = 32000
389
+
390
+ self.hop_length = 1
391
+ for r in encoder_rates:
392
+ self.hop_length *= r
393
+ self.samples_per_latent = self.hop_length # 800
394
+ self.latents_per_second = self.sample_rate // self.hop_length # 40
395
+ self.output_sample_rate = self.sample_rate # exposed for downstream decode and mux helpers
396
+
397
+ self.encoder = Encoder(encoder_dim, encoder_rates, latent_dim)
398
+
399
+ self.pre_block = AttnProjection(latent_dim, vae_latent_channels, num_heads=8)
400
+
401
+ self.mean_proj = nn.Conv1d(vae_latent_channels, vae_latent_channels, 1)
402
+ # logs_proj exists in the checkpoint but is unused at inference
403
+ # (encode returns the posterior mean, no sampling).
404
+ self.logs_proj = nn.Conv1d(vae_latent_channels, vae_latent_channels, 1)
405
+
406
+ self.dec_in_proj = nn.Conv1d(vae_latent_channels, latent_dim, 1)
407
+ self.decoder = BigVGAN(num_mels=latent_dim, upsample_initial_channel=decoder_dim)
408
+
409
+ self.register_buffer("latents_mean", torch.empty(vae_latent_channels))
410
+ self.register_buffer("latents_std", torch.empty(vae_latent_channels))
411
+
412
+ def decode(self, z):
413
+ """Decode normalized latents [B, 32, 2, T] to stereo waveforms [B, 2, L] at 32 kHz."""
414
+ b, c, s, t = z.shape
415
+ z = z.permute(0, 2, 1, 3).reshape(b * s, c, t)
416
+ mean = self.latents_mean.view(1, -1, 1).to(device=z.device, dtype=z.dtype)
417
+ std = self.latents_std.view(1, -1, 1).to(device=z.device, dtype=z.dtype)
418
+ z = z * std + mean
419
+ x = self.dec_in_proj(z)
420
+ x = self.decoder(x) # [b * s, 1, L], already clamped to [-1, 1]
421
+ return x.reshape(b, s, -1)
422
+
423
+ def encode(self, waveform):
424
+ """Encode stereo waveforms [B, 2, L] at 32 kHz (in [-1, 1]) to normalized latents [B, 32, 2, T].
425
+
426
+ L is right-padded with zeros to a multiple of 800 samples; the returned
427
+ posterior mean is used directly (no sampling).
428
+ """
429
+ b, s, length = waveform.shape
430
+ right_pad = math.ceil(length / self.hop_length) * self.hop_length - length
431
+ waveform = F.pad(waveform, (0, right_pad))
432
+ x = waveform.reshape(b * s, 1, -1)
433
+ x = self.encoder(x) # [b * s, latent_dim, T]
434
+ x = self.pre_block(x.transpose(1, 2)).transpose(1, 2) # [b * s, 32, T]
435
+ z = self.mean_proj(x)
436
+ mean = self.latents_mean.view(1, -1, 1).to(device=z.device, dtype=z.dtype)
437
+ std = self.latents_std.view(1, -1, 1).to(device=z.device, dtype=z.dtype)
438
+ z = (z - mean) / std
439
+ return z.reshape(b, s, z.shape[1], z.shape[2]).permute(0, 2, 1, 3)
440
+
441
+
442
+ @torch.no_grad()
443
+ def encode_audio_mode(vae, waveform: torch.Tensor) -> torch.Tensor:
444
+ latents = vae.encode(waveform)
445
+ if not isinstance(latents, torch.Tensor) or latents.ndim != 4 or latents.shape[1:3] != (32, 2):
446
+ shape = tuple(latents.shape) if isinstance(latents, torch.Tensor) else type(latents).__name__
447
+ raise ValueError(f"Expected MiniMax-H3 audio latent [B,32,2,A], got {shape}")
448
+ return latents
449
+
450
+
451
+ def load_audio_vae(
452
+ path,
453
+ device: str | torch.device = "cpu",
454
+ dtype: torch.dtype = torch.float32,
455
+ disable_mmap: bool = False,
456
+ ) -> MiniMaxH3AudioVAE:
457
+ from musubi_tuner.minimax_h3.checkpoint import load_safetensors_module, resolve_safetensors_files
458
+
459
+ files = resolve_safetensors_files(path)
460
+ return load_safetensors_module(
461
+ MiniMaxH3AudioVAE,
462
+ files,
463
+ device=device,
464
+ dtype=dtype,
465
+ key_prefixes=("audio_vae.", "vae."),
466
+ disable_mmap=disable_mmap,
467
+ )
musubi_tuner/minimax_h3/checkpoint.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable
4
+ import json
5
+ from pathlib import Path
6
+ from typing import TypeVar
7
+
8
+ from accelerate import init_empty_weights
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+ from musubi_tuner.utils.safetensors_utils import MemoryEfficientSafeOpen
13
+
14
+
15
+ ModuleT = TypeVar("ModuleT", bound=nn.Module)
16
+
17
+
18
+ def resolve_safetensors_files(path: str | Path) -> list[Path]:
19
+ path = Path(path).resolve()
20
+ if path.is_file():
21
+ if path.suffix != ".safetensors":
22
+ raise ValueError(f"Expected a safetensors checkpoint, got {path}")
23
+ return [path]
24
+ if not path.is_dir():
25
+ raise ValueError(f"Checkpoint path does not exist: {path}")
26
+
27
+ index_files = sorted(path.glob("*.safetensors.index.json"))
28
+ if len(index_files) > 1:
29
+ raise ValueError(f"Multiple safetensors indexes found in {path}: {index_files}")
30
+ if index_files:
31
+ try:
32
+ index = json.loads(index_files[0].read_text(encoding="utf-8"))
33
+ weight_map = index["weight_map"]
34
+ except (json.JSONDecodeError, KeyError, TypeError) as error:
35
+ raise ValueError(f"Invalid safetensors index: {index_files[0]}") from error
36
+ if not isinstance(weight_map, dict) or not weight_map:
37
+ raise ValueError(f"Safetensors index has no weights: {index_files[0]}")
38
+
39
+ files = []
40
+ for filename in sorted(set(weight_map.values())):
41
+ if not isinstance(filename, str):
42
+ raise ValueError(f"Invalid shard filename in {index_files[0]}: {filename!r}")
43
+ shard = (path / filename).resolve()
44
+ if not shard.is_relative_to(path) or not shard.is_file():
45
+ raise ValueError(f"Missing or unsafe safetensors shard in {index_files[0]}: {filename}")
46
+ files.append(shard)
47
+ return files
48
+
49
+ files = sorted(path.glob("*.safetensors"))
50
+ if not files:
51
+ raise ValueError(f"No safetensors checkpoint found in {path}")
52
+ if len(files) > 1:
53
+ raise ValueError(f"Multiple safetensors files without an index found in {path}: {files}")
54
+ return [files[0].resolve()]
55
+
56
+
57
+ def _normalize_checkpoint_key(key: str, prefixes: tuple[str, ...]) -> str:
58
+ for prefix in prefixes:
59
+ if key.startswith(prefix):
60
+ return key[len(prefix) :]
61
+ return key
62
+
63
+
64
+ def load_safetensors_metadata(files: Iterable[str | Path]) -> dict[str, str]:
65
+ merged = {}
66
+ for file in files:
67
+ path = Path(file).resolve()
68
+ with MemoryEfficientSafeOpen(str(path)) as handle:
69
+ metadata = handle.metadata() or {}
70
+ for key, value in metadata.items():
71
+ if key in merged and merged[key] != value:
72
+ raise ValueError(f"Conflicting MiniMax-H3 checkpoint metadata {key!r} in {path}")
73
+ merged[key] = value
74
+ return merged
75
+
76
+
77
+ def _format_key_mismatch(
78
+ missing: Iterable[str],
79
+ unexpected: Iterable[str],
80
+ shape_mismatches: Iterable[str],
81
+ dtype_mismatches: Iterable[str],
82
+ ) -> str:
83
+ missing = sorted(missing)
84
+ unexpected = sorted(unexpected)
85
+ shape_mismatches = sorted(shape_mismatches)
86
+ dtype_mismatches = sorted(dtype_mismatches)
87
+ return (
88
+ "MiniMax-H3 checkpoint key mismatch: "
89
+ f"missing={missing[:20]}, unexpected={unexpected[:20]}, shape_mismatches={shape_mismatches[:20]}, "
90
+ f"dtype_mismatches={dtype_mismatches[:20]}"
91
+ )
92
+
93
+
94
+ def load_safetensors_module(
95
+ factory: Callable[[], ModuleT],
96
+ files: Iterable[str | Path],
97
+ *,
98
+ device: str | torch.device,
99
+ dtype: torch.dtype | None,
100
+ key_prefixes: tuple[str, ...] = (),
101
+ key_transform: Callable[[str], str] | None = None,
102
+ strict_dtype: bool = False,
103
+ disable_mmap: bool = False,
104
+ ) -> ModuleT:
105
+ files = [Path(path).resolve() for path in files]
106
+ if not files:
107
+ raise ValueError("MiniMax-H3 checkpoint file list is empty")
108
+
109
+ with init_empty_weights():
110
+ model = factory()
111
+
112
+ expected_state = model.state_dict()
113
+ expected_keys = set(expected_state)
114
+ seen = set()
115
+ unexpected = set()
116
+ shape_mismatches = []
117
+ dtype_mismatches = []
118
+
119
+ for path in files:
120
+ shard = {}
121
+ with MemoryEfficientSafeOpen(str(path), disable_numpy_memmap=disable_mmap) as handle:
122
+ raw_keys = handle.keys()
123
+ if any(key.endswith((".weight_scale", ".comfy_quant")) for key in raw_keys):
124
+ raise ValueError(f"Quantized MiniMax-H3 artifacts are deferred to R2: {path}")
125
+ for raw_key in raw_keys:
126
+ key = _normalize_checkpoint_key(raw_key, key_prefixes)
127
+ if key_transform is not None:
128
+ key = key_transform(key)
129
+ if key in seen:
130
+ raise ValueError(f"Duplicate MiniMax-H3 checkpoint key {key!r} in {path}")
131
+ seen.add(key)
132
+ if key not in expected_keys:
133
+ unexpected.add(key)
134
+ continue
135
+ tensor = handle.get_tensor(raw_key, device=torch.device("cpu"), dtype=None)
136
+ if tensor.shape != expected_state[key].shape:
137
+ shape_mismatches.append(f"{key}: expected {tuple(expected_state[key].shape)}, got {tuple(tensor.shape)}")
138
+ continue
139
+ if strict_dtype and tensor.dtype != expected_state[key].dtype:
140
+ dtype_mismatches.append(f"{key}: expected {expected_state[key].dtype}, got {tensor.dtype}")
141
+ continue
142
+ if dtype is not None and tensor.is_floating_point():
143
+ tensor = tensor.to(dtype=dtype)
144
+ shard[key] = tensor
145
+ if shard:
146
+ model.load_state_dict(shard, strict=False, assign=True)
147
+
148
+ missing = expected_keys - seen
149
+ if missing or unexpected or shape_mismatches or dtype_mismatches:
150
+ raise ValueError(_format_key_mismatch(missing, unexpected, shape_mismatches, dtype_mismatches))
151
+
152
+ model.to(device=torch.device(device))
153
+ model.eval()
154
+ return model
musubi_tuner/minimax_h3/media.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Callable, Literal, Optional
7
+
8
+ import av
9
+
10
+
11
+ H3Task = Literal["t2va", "fl2va", "ref2va"]
12
+ H3ReferenceType = Literal["image", "video", "audio"]
13
+ H3MediaProbe = Callable[[Path], "H3MediaInfo"]
14
+
15
+ AUDIO_SIDECAR_EXTENSIONS = frozenset({".aac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav"})
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class H3MediaInfo:
20
+ has_audio: bool
21
+ duration_seconds: Optional[float]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class H3AudioSource:
26
+ path: Path
27
+ embedded: bool
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class H3Reference:
32
+ type: H3ReferenceType
33
+ path: Path
34
+ audio: Optional[H3AudioSource] = None
35
+ duration_seconds: Optional[float] = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class H3Record:
40
+ video_path: Path
41
+ caption: str
42
+ target_audio: H3AudioSource
43
+ references: tuple[H3Reference, ...]
44
+ jsonl_line: int
45
+
46
+
47
+ def _validate_frame_count(frame_count: int) -> None:
48
+ if frame_count < 5 or (frame_count - 5) % 17 != 0:
49
+ raise ValueError(f"Invalid MiniMax-H3 frame count {frame_count}; expected 17*n+5")
50
+
51
+
52
+ def video_latent_frames(frame_count: int) -> int:
53
+ _validate_frame_count(frame_count)
54
+ return 5 * ((frame_count - 5) // 17) + 2
55
+
56
+
57
+ def audio_latent_frames(frame_count: int) -> int:
58
+ _validate_frame_count(frame_count)
59
+ return (10 * frame_count + 3) // 6
60
+
61
+
62
+ def waveform_samples(audio_frames: int) -> int:
63
+ if audio_frames <= 0:
64
+ raise ValueError(f"Audio latent frame count must be positive, got {audio_frames}")
65
+ return audio_frames * 800
66
+
67
+
68
+ def probe_h3_media(path: Path) -> H3MediaInfo:
69
+ with av.open(str(path)) as container:
70
+ has_audio = bool(container.streams.audio)
71
+ durations = []
72
+ for stream in (*container.streams.video, *container.streams.audio):
73
+ if stream.duration is not None and stream.time_base is not None:
74
+ durations.append(float(stream.duration * stream.time_base))
75
+ if durations:
76
+ duration_seconds = max(durations)
77
+ elif container.duration is not None:
78
+ duration_seconds = float(container.duration / av.time_base)
79
+ else:
80
+ duration_seconds = None
81
+ return H3MediaInfo(has_audio=has_audio, duration_seconds=duration_seconds)
82
+
83
+
84
+ def _resolve_existing_path(value: object, base_directory: Path, field: str, line_number: int) -> Path:
85
+ if not isinstance(value, str) or not value.strip():
86
+ raise ValueError(f"H3 JSONL line {line_number}: {field} must be a non-empty path")
87
+ path = Path(value)
88
+ if not path.is_absolute():
89
+ path = base_directory / path
90
+ path = path.resolve()
91
+ if not path.is_file():
92
+ raise ValueError(f"H3 JSONL line {line_number}: {field} does not exist: {path}")
93
+ return path
94
+
95
+
96
+ def _probe_required_audio(path: Path, probe: H3MediaProbe, label: str, line_number: int) -> H3MediaInfo:
97
+ try:
98
+ info = probe(path)
99
+ except Exception as error:
100
+ raise ValueError(f"H3 JSONL line {line_number}: {label} failed to decode: {error}") from error
101
+ if not info.has_audio:
102
+ raise ValueError(f"H3 JSONL line {line_number}: {label} contains no audio stream: {path}")
103
+ return info
104
+
105
+
106
+ def _same_stem_audio_sidecars(video_path: Path) -> list[Path]:
107
+ return sorted(
108
+ (
109
+ candidate.resolve()
110
+ for candidate in video_path.parent.iterdir()
111
+ if candidate.is_file() and candidate.stem == video_path.stem and candidate.suffix.lower() in AUDIO_SIDECAR_EXTENSIONS
112
+ ),
113
+ key=lambda path: path.name.lower(),
114
+ )
115
+
116
+
117
+ def _resolve_target_audio(
118
+ data: dict,
119
+ video_path: Path,
120
+ base_directory: Path,
121
+ line_number: int,
122
+ probe: H3MediaProbe,
123
+ ) -> H3AudioSource:
124
+ if data.get("audio_path") is not None:
125
+ path = _resolve_existing_path(data["audio_path"], base_directory, "audio_path", line_number)
126
+ _probe_required_audio(path, probe, "Explicit target audio", line_number)
127
+ return H3AudioSource(path=path, embedded=False)
128
+
129
+ sidecars = _same_stem_audio_sidecars(video_path)
130
+ if len(sidecars) > 1:
131
+ formatted = ", ".join(str(path) for path in sidecars)
132
+ raise ValueError(f"H3 JSONL line {line_number}: Multiple same-stem audio sidecars found: {formatted}")
133
+ if sidecars:
134
+ _probe_required_audio(sidecars[0], probe, "Target audio sidecar", line_number)
135
+ return H3AudioSource(path=sidecars[0], embedded=False)
136
+
137
+ try:
138
+ video_info = probe(video_path)
139
+ except Exception as error:
140
+ raise ValueError(f"H3 JSONL line {line_number}: Target video failed to decode: {error}") from error
141
+ if video_info.has_audio:
142
+ return H3AudioSource(path=video_path, embedded=True)
143
+ raise ValueError(f"H3 JSONL line {line_number}: Missing target audio for {video_path}")
144
+
145
+
146
+ def make_h3_directory_record(
147
+ video_path: str | Path,
148
+ caption: str,
149
+ probe: H3MediaProbe = probe_h3_media,
150
+ ) -> H3Record:
151
+ video_path = Path(video_path).resolve()
152
+ if not video_path.is_file():
153
+ raise ValueError(f"MiniMax-H3 target video does not exist: {video_path}")
154
+ if not isinstance(caption, str):
155
+ raise ValueError("MiniMax-H3 directory caption must be a string")
156
+ target_audio = _resolve_target_audio({}, video_path, video_path.parent, 0, probe)
157
+ return H3Record(
158
+ video_path=video_path,
159
+ caption=caption,
160
+ target_audio=target_audio,
161
+ references=(),
162
+ jsonl_line=0,
163
+ )
164
+
165
+
166
+ def _validate_reference_counts(references: list, line_number: int) -> None:
167
+ if len(references) > 12:
168
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA allows at most 12 reference items")
169
+
170
+ types = [reference.get("type") if isinstance(reference, dict) else None for reference in references]
171
+ unsupported = [reference_type for reference_type in types if reference_type not in {"image", "video", "audio"}]
172
+ if unsupported:
173
+ raise ValueError(f"H3 JSONL line {line_number}: Unsupported reference type: {unsupported[0]!r}")
174
+ if types.count("image") > 9:
175
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA allows at most 9 image references")
176
+ if types.count("video") > 3:
177
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA allows at most 3 video references")
178
+ if types.count("audio") > 3:
179
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA allows at most 3 audio-bearing references")
180
+ if not any(reference_type in {"image", "video"} for reference_type in types):
181
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA requires at least one visual reference")
182
+
183
+
184
+ def _parse_references(
185
+ raw_references: object,
186
+ base_directory: Path,
187
+ line_number: int,
188
+ probe: H3MediaProbe,
189
+ ) -> tuple[H3Reference, ...]:
190
+ if not isinstance(raw_references, list):
191
+ raise ValueError(f"H3 JSONL line {line_number}: references must be a list")
192
+ _validate_reference_counts(raw_references, line_number)
193
+
194
+ references = []
195
+ audio_bearing_count = 0
196
+ for index, raw_reference in enumerate(raw_references):
197
+ reference_type = raw_reference["type"]
198
+ field_prefix = f"references[{index}]"
199
+ path = _resolve_existing_path(raw_reference.get("path"), base_directory, f"{field_prefix}.path", line_number)
200
+
201
+ if reference_type == "image":
202
+ if raw_reference.get("audio_path") is not None:
203
+ raise ValueError(f"H3 JSONL line {line_number}: {field_prefix} image cannot have audio_path")
204
+ references.append(H3Reference(type="image", path=path))
205
+ continue
206
+
207
+ if reference_type == "audio":
208
+ if raw_reference.get("audio_path") is not None:
209
+ raise ValueError(f"H3 JSONL line {line_number}: {field_prefix} audio uses path, not audio_path")
210
+ info = _probe_required_audio(path, probe, f"{field_prefix} audio", line_number)
211
+ references.append(
212
+ H3Reference(
213
+ type="audio",
214
+ path=path,
215
+ audio=H3AudioSource(path=path, embedded=False),
216
+ duration_seconds=info.duration_seconds,
217
+ )
218
+ )
219
+ audio_bearing_count += 1
220
+ continue
221
+
222
+ try:
223
+ video_info = probe(path)
224
+ except Exception as error:
225
+ raise ValueError(f"H3 JSONL line {line_number}: {field_prefix} video failed to decode: {error}") from error
226
+ duration = video_info.duration_seconds
227
+ if duration is None or duration < 2.0 or duration > 15.0:
228
+ raise ValueError(f"H3 JSONL line {line_number}: {field_prefix} video must be between 2 and 15 seconds; got {duration}")
229
+
230
+ audio = None
231
+ if raw_reference.get("audio_path") is not None:
232
+ audio_path = _resolve_existing_path(
233
+ raw_reference["audio_path"], base_directory, f"{field_prefix}.audio_path", line_number
234
+ )
235
+ _probe_required_audio(audio_path, probe, f"Explicit {field_prefix} audio", line_number)
236
+ audio = H3AudioSource(path=audio_path, embedded=False)
237
+ elif video_info.has_audio:
238
+ audio = H3AudioSource(path=path, embedded=True)
239
+ if audio is not None:
240
+ audio_bearing_count += 1
241
+ references.append(H3Reference(type="video", path=path, audio=audio, duration_seconds=duration))
242
+
243
+ if audio_bearing_count > 3:
244
+ raise ValueError(f"H3 JSONL line {line_number}: Ref2VA allows at most 3 audio-bearing references")
245
+ return tuple(references)
246
+
247
+
248
+ def load_h3_jsonl_records(
249
+ jsonl_path: str | Path,
250
+ task: H3Task,
251
+ probe: H3MediaProbe = probe_h3_media,
252
+ ) -> list[H3Record]:
253
+ if task not in {"t2va", "fl2va", "ref2va"}:
254
+ raise ValueError(f"Unsupported MiniMax-H3 task: {task}")
255
+
256
+ jsonl_path = Path(jsonl_path).resolve()
257
+ if not jsonl_path.is_file():
258
+ raise ValueError(f"MiniMax-H3 JSONL does not exist: {jsonl_path}")
259
+ base_directory = jsonl_path.parent
260
+ records = []
261
+
262
+ with jsonl_path.open("r", encoding="utf-8") as handle:
263
+ for line_number, line in enumerate(handle, start=1):
264
+ if not line.strip():
265
+ continue
266
+ try:
267
+ data = json.loads(line)
268
+ except json.JSONDecodeError as error:
269
+ raise ValueError(f"H3 JSONL line {line_number}: invalid JSON: {error.msg}") from error
270
+ if not isinstance(data, dict):
271
+ raise ValueError(f"H3 JSONL line {line_number}: each record must be an object")
272
+
273
+ video_path = _resolve_existing_path(data.get("video_path"), base_directory, "video_path", line_number)
274
+ caption = data.get("caption")
275
+ if not isinstance(caption, str):
276
+ raise ValueError(f"H3 JSONL line {line_number}: caption must be a string")
277
+ target_audio = _resolve_target_audio(data, video_path, base_directory, line_number, probe)
278
+
279
+ raw_references = data.get("references", [])
280
+ if task == "ref2va":
281
+ references = _parse_references(raw_references, base_directory, line_number, probe)
282
+ else:
283
+ if raw_references:
284
+ raise ValueError(f"H3 JSONL line {line_number}: references require task ref2va")
285
+ references = ()
286
+
287
+ records.append(
288
+ H3Record(
289
+ video_path=video_path,
290
+ caption=caption,
291
+ target_audio=target_audio,
292
+ references=references,
293
+ jsonl_line=line_number,
294
+ )
295
+ )
296
+
297
+ if not records:
298
+ raise ValueError(f"MiniMax-H3 JSONL contains no records: {jsonl_path}")
299
+ return records
musubi_tuner/minimax_h3/model.py ADDED
@@ -0,0 +1,952 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 The MiniMax Team and The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Adapted for Musubi from Hugging Face Diffusers PR #14355 at commit
16
+ # abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc
17
+ # (models/transformers/transformer_minimax_h3.py).
18
+ # ComfyUI is used only as an independent numerical reference.
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import math
24
+ from collections import OrderedDict
25
+ from collections.abc import Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ import torch
30
+ import torch.nn.functional as F
31
+ from torch import nn
32
+
33
+ from musubi_tuner.minimax_h3.checkpoint import (
34
+ load_safetensors_metadata,
35
+ load_safetensors_module,
36
+ resolve_safetensors_files,
37
+ )
38
+ from musubi_tuner.minimax_h3.packing import (
39
+ AUDIO_CHANNELS,
40
+ VIDEO_CHANNELS,
41
+ VIDEO_PATCH_SIZE,
42
+ H3PackedLayout,
43
+ build_position_grid,
44
+ build_timestep_rows,
45
+ pack_audio_rows,
46
+ pack_video_rows,
47
+ unpack_targets,
48
+ )
49
+ from musubi_tuner.modules.attention import AttentionParams, attention
50
+ from musubi_tuner.modules.custom_offloading_utils import BlockSwapConfig, create_offloader
51
+ from musubi_tuner.utils.model_utils import create_cpu_offloading_wrapper
52
+
53
+ _ROTARY_CACHE_SIZE = 2
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class MiniMaxH3Config:
58
+ in_channels: int = 24
59
+ audio_in_channels: int = 32
60
+ hidden_size: int = 5376
61
+ num_layers: int = 50
62
+ token_refiner_num_layers: int = 2
63
+ num_attention_heads: int = 56
64
+ attention_head_dim: int = 128
65
+ ffn_hidden_size: int = 14336
66
+ patch_size: tuple[int, int, int] = (1, 2, 2)
67
+ text_dim: int = 5120
68
+ timestep_input_dim: int = 256
69
+ time_embed_hidden_size: int = 5376
70
+ time_embed_dim: int = 2688
71
+ rope_inv_freq_len: int = 16
72
+ norm_eps: float = 1e-5
73
+ qk_norm_eps: float = 1e-5
74
+ final_norm_eps: float = 1e-5
75
+
76
+ def __post_init__(self) -> None:
77
+ integer_fields = (
78
+ "in_channels",
79
+ "audio_in_channels",
80
+ "hidden_size",
81
+ "num_layers",
82
+ "token_refiner_num_layers",
83
+ "num_attention_heads",
84
+ "attention_head_dim",
85
+ "ffn_hidden_size",
86
+ "text_dim",
87
+ "timestep_input_dim",
88
+ "time_embed_hidden_size",
89
+ "time_embed_dim",
90
+ "rope_inv_freq_len",
91
+ )
92
+ for field in integer_fields:
93
+ if getattr(self, field) <= 0:
94
+ raise ValueError(f"MiniMax-H3 {field} must be positive")
95
+ if self.in_channels != VIDEO_CHANNELS or self.audio_in_channels != AUDIO_CHANNELS:
96
+ raise ValueError("MiniMax-H3 R1 requires 24 video channels and 32 audio channels")
97
+ if self.patch_size != VIDEO_PATCH_SIZE:
98
+ raise ValueError(f"MiniMax-H3 R1 requires video patch size {VIDEO_PATCH_SIZE}")
99
+ if self.timestep_input_dim % 2:
100
+ raise ValueError("MiniMax-H3 timestep input width must be even")
101
+ if self.rope_inv_freq_len * 6 > self.attention_head_dim:
102
+ raise ValueError("MiniMax-H3 rotary width exceeds the attention head width")
103
+
104
+ @property
105
+ def attention_inner_dim(self) -> int:
106
+ return self.num_attention_heads * self.attention_head_dim
107
+
108
+ @property
109
+ def video_patch_dim(self) -> int:
110
+ return self.in_channels * math.prod(self.patch_size)
111
+
112
+ @property
113
+ def block_adaln_out_features(self) -> int:
114
+ return 6 * self.hidden_size * 3
115
+
116
+ @property
117
+ def final_adaln_out_features(self) -> int:
118
+ return 2 * self.hidden_size
119
+
120
+
121
+ _PUBLISHED_CONFIG_FIELDS = {
122
+ "hidden_size": 5376,
123
+ "num_layers": 50,
124
+ "token_refiner_num_layers": 2,
125
+ "num_attention_heads": 56,
126
+ "attention_head_dim": 128,
127
+ "ffn_hidden_size": 14336,
128
+ "latents_dim": 24,
129
+ "audio_latents_dim": 32,
130
+ "patch_size": [1, 2, 2],
131
+ "text_dim": 5120,
132
+ "timestep_input_dim": 256,
133
+ "time_embed_hidden_size": 5376,
134
+ "time_embed_dim": 2688,
135
+ "adaln_out_features": 96768,
136
+ "final_adaln_out_features": 10752,
137
+ "rope_inv_freq_len": 16,
138
+ "norm_eps": 1e-5,
139
+ "qk_norm_eps": 1e-5,
140
+ "final_norm_eps": 1e-5,
141
+ "image_model": "minimax_h3",
142
+ }
143
+
144
+
145
+ def parse_h3_transformer_config(metadata: Mapping[str, str]) -> MiniMaxH3Config:
146
+ artifact_markers = " ".join(f"{key}={value}" for key, value in metadata.items() if key != "config").lower()
147
+ if any(marker in artifact_markers for marker in ("convrot", "int8", "fp8", "quantized")):
148
+ raise ValueError("Quantized or ConvRot MiniMax-H3 artifacts are deferred to R2")
149
+ raw_config = metadata.get("config")
150
+ if raw_config is None:
151
+ raise ValueError("MiniMax-H3 transformer checkpoint is missing config metadata")
152
+ try:
153
+ root = json.loads(raw_config)
154
+ transformer = root["transformer"]
155
+ except (json.JSONDecodeError, KeyError, TypeError) as error:
156
+ raise ValueError("MiniMax-H3 transformer checkpoint has invalid config metadata") from error
157
+ if not isinstance(transformer, dict):
158
+ raise TypeError("MiniMax-H3 transformer config must be an object")
159
+ for field, expected in _PUBLISHED_CONFIG_FIELDS.items():
160
+ if field not in transformer:
161
+ raise ValueError(f"MiniMax-H3 transformer config is missing {field}")
162
+ actual = transformer[field]
163
+ if actual != expected:
164
+ raise ValueError(f"MiniMax-H3 transformer config {field} expected {expected}, got {actual}")
165
+ if transformer.get("adaln_curve_grid") is not None:
166
+ raise ValueError("Pruned MiniMax-H3 AdaLN artifacts are deferred to R2")
167
+ return MiniMaxH3Config()
168
+
169
+
170
+ class TimeEmbedder(nn.Module):
171
+ def __init__(
172
+ self,
173
+ input_dim: int,
174
+ hidden_size: int,
175
+ output_dim: int,
176
+ *,
177
+ device: torch.device | str | None = None,
178
+ ) -> None:
179
+ super().__init__()
180
+ self.input_dim = input_dim
181
+ self.proj_in = nn.Linear(input_dim, hidden_size, dtype=torch.float32, device=device)
182
+ self.proj_out = nn.Linear(hidden_size, output_dim, dtype=torch.float32, device=device)
183
+
184
+ def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
185
+ half = self.input_dim // 2
186
+ frequencies = torch.exp(-math.log(10000.0) * torch.arange(half, dtype=torch.float32, device=timesteps.device) / half)
187
+ angles = timesteps.to(torch.float32)[:, None] * frequencies[None]
188
+ embedding = torch.cat((torch.cos(angles), torch.sin(angles)), dim=-1)
189
+ return self.proj_out(F.silu(self.proj_in(embedding)))
190
+
191
+
192
+ class AdalnProj(nn.Module):
193
+ def __init__(
194
+ self,
195
+ timestep_dim: int,
196
+ hidden_size: int,
197
+ expand: int,
198
+ modalities: int,
199
+ *,
200
+ dtype: torch.dtype,
201
+ device: torch.device | str | None = None,
202
+ ) -> None:
203
+ super().__init__()
204
+ self.expand = expand
205
+ self.modalities = modalities
206
+ self.hidden_size = hidden_size
207
+ self.linear = nn.Linear(
208
+ timestep_dim,
209
+ expand * hidden_size * modalities,
210
+ dtype=dtype,
211
+ device=device,
212
+ )
213
+
214
+ def forward(self, timestep_embeddings: torch.Tensor) -> tuple[torch.Tensor, ...]:
215
+ projected = self.linear(F.silu(timestep_embeddings))
216
+ projected = projected.reshape(projected.shape[0] * self.modalities, self.expand * self.hidden_size)
217
+ return projected.chunk(self.expand, dim=-1)
218
+
219
+
220
+ def _apply_rope_split_half(hidden_states: torch.Tensor, rotation_table: torch.Tensor) -> torch.Tensor:
221
+ pairs = rotation_table.shape[-3]
222
+ rotary = torch.stack((hidden_states[..., :pairs], hidden_states[..., pairs : 2 * pairs]), dim=-1)
223
+ rotary = torch.matmul(rotation_table, rotary.unsqueeze(-1)).squeeze(-1)
224
+ rotary = torch.cat((rotary[..., 0], rotary[..., 1]), dim=-1)
225
+ return torch.cat((rotary, hidden_states[..., 2 * pairs :]), dim=-1)
226
+
227
+
228
+ class Attention(nn.Module):
229
+ def __init__(
230
+ self,
231
+ hidden_size: int,
232
+ num_heads: int,
233
+ head_dim: int,
234
+ qk_norm_eps: float,
235
+ *,
236
+ attn_mode: str,
237
+ split_attn: bool,
238
+ dtype: torch.dtype,
239
+ device: torch.device | str | None = None,
240
+ ) -> None:
241
+ super().__init__()
242
+ self.num_heads = num_heads
243
+ self.head_dim = head_dim
244
+ self.inner_dim = num_heads * head_dim
245
+ self.attn_mode = "torch" if attn_mode == "sdpa" else attn_mode
246
+ self.split_attn = split_attn
247
+ self.qkv_proj = nn.Linear(hidden_size, self.inner_dim * 3, bias=False, dtype=dtype, device=device)
248
+ self.q_norm = nn.RMSNorm(head_dim, eps=qk_norm_eps, dtype=dtype, device=device)
249
+ self.k_norm = nn.RMSNorm(head_dim, eps=qk_norm_eps, dtype=dtype, device=device)
250
+ self.out_proj = nn.Linear(self.inner_dim, hidden_size, bias=False, dtype=dtype, device=device)
251
+
252
+ def forward(self, hidden_states: torch.Tensor, rotation_table: torch.Tensor | None = None) -> torch.Tensor:
253
+ batch_size, sequence_length, _ = hidden_states.shape
254
+ query, key, value = self.qkv_proj(hidden_states).split(self.inner_dim, dim=-1)
255
+ query = self.q_norm(query.reshape(batch_size, sequence_length, self.num_heads, self.head_dim))
256
+ key = self.k_norm(key.reshape(batch_size, sequence_length, self.num_heads, self.head_dim))
257
+ value = value.reshape(batch_size, sequence_length, self.num_heads, self.head_dim)
258
+ if rotation_table is not None:
259
+ query = _apply_rope_split_half(query, rotation_table)
260
+ key = _apply_rope_split_half(key, rotation_table)
261
+
262
+ if self.attn_mode == "flash3":
263
+ try:
264
+ from musubi_tuner.wan.modules.attention import flash_attention as wan_flash_attention
265
+ except ImportError as error:
266
+ raise RuntimeError("FlashAttention 3 was selected but its runtime is unavailable") from error
267
+ output = wan_flash_attention(
268
+ [query, key, value],
269
+ attn_mode="flash3",
270
+ split_attn=False,
271
+ dtype=hidden_states.dtype if hidden_states.dtype in {torch.float16, torch.bfloat16} else torch.bfloat16,
272
+ )
273
+ output = output.reshape(batch_size, sequence_length, self.inner_dim)
274
+ else:
275
+ params = AttentionParams.create_attention_params(self.attn_mode, self.split_attn)
276
+ output = attention([query, key, value], attn_params=params)
277
+ return self.out_proj(output)
278
+
279
+
280
+ class MLP(nn.Module):
281
+ def __init__(
282
+ self,
283
+ hidden_size: int,
284
+ ffn_hidden_size: int,
285
+ *,
286
+ dtype: torch.dtype,
287
+ device: torch.device | str | None = None,
288
+ ) -> None:
289
+ super().__init__()
290
+ self.fc1 = nn.Linear(hidden_size, ffn_hidden_size * 2, bias=False, dtype=dtype, device=device)
291
+ self.fc2 = nn.Linear(ffn_hidden_size, hidden_size, bias=False, dtype=dtype, device=device)
292
+
293
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
294
+ gate, values = self.fc1(hidden_states).chunk(2, dim=-1)
295
+ return self.fc2(F.silu(gate) * values)
296
+
297
+
298
+ class RefinerBlock(nn.Module):
299
+ def __init__(self, config: MiniMaxH3Config, *, attn_mode: str, split_attn: bool, dtype, device) -> None:
300
+ super().__init__()
301
+ self.norm1 = nn.RMSNorm(config.hidden_size, eps=config.norm_eps, dtype=dtype, device=device)
302
+ self.norm2 = nn.RMSNorm(config.hidden_size, eps=config.norm_eps, dtype=dtype, device=device)
303
+ self.attn = Attention(
304
+ config.hidden_size,
305
+ config.num_attention_heads,
306
+ config.attention_head_dim,
307
+ config.qk_norm_eps,
308
+ attn_mode=attn_mode,
309
+ split_attn=split_attn,
310
+ dtype=dtype,
311
+ device=device,
312
+ )
313
+ self.mlp = MLP(config.hidden_size, config.ffn_hidden_size, dtype=dtype, device=device)
314
+
315
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
316
+ hidden_states = hidden_states + self.attn(self.norm1(hidden_states))
317
+ return hidden_states + self.mlp(self.norm2(hidden_states))
318
+
319
+
320
+ class TokenRefiner(nn.Module):
321
+ def __init__(self, config: MiniMaxH3Config, *, attn_mode: str, split_attn: bool, dtype, device) -> None:
322
+ super().__init__()
323
+ self.blocks = nn.ModuleList(
324
+ [
325
+ RefinerBlock(config, attn_mode=attn_mode, split_attn=split_attn, dtype=dtype, device=device)
326
+ for _ in range(config.token_refiner_num_layers)
327
+ ]
328
+ )
329
+ self.final_norm = nn.RMSNorm(config.hidden_size, eps=config.final_norm_eps, dtype=dtype, device=device)
330
+
331
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
332
+ for block in self.blocks:
333
+ hidden_states = block(hidden_states)
334
+ return self.final_norm(hidden_states)
335
+
336
+
337
+ def _mod_scale_shift(
338
+ hidden_states: torch.Tensor,
339
+ shift: torch.Tensor,
340
+ scale: torch.Tensor,
341
+ batch_segments: tuple[tuple[tuple[int, int, int], ...], ...],
342
+ ) -> torch.Tensor:
343
+ # Callers pass a fresh norm output; these disjoint slice updates retain trainable AdaLN gradients.
344
+ if len(batch_segments) != hidden_states.shape[0]:
345
+ raise ValueError("MiniMax-H3 modulation plans must preserve the batch axis")
346
+ for batch_index, segments in enumerate(batch_segments):
347
+ for start, stop, row in segments:
348
+ hidden_states[batch_index, start:stop].mul_(1.0 + scale[row]).add_(shift[row])
349
+ return hidden_states
350
+
351
+
352
+ def _mod_gate(
353
+ residual: torch.Tensor,
354
+ update: torch.Tensor,
355
+ gate: torch.Tensor,
356
+ batch_segments: tuple[tuple[tuple[int, int, int], ...], ...],
357
+ ) -> torch.Tensor:
358
+ if len(batch_segments) != residual.shape[0]:
359
+ raise ValueError("MiniMax-H3 gate plans must preserve the batch axis")
360
+ output = residual.clone()
361
+ for batch_index, segments in enumerate(batch_segments):
362
+ for start, stop, row in segments:
363
+ output[batch_index, start:stop].add_(update[batch_index, start:stop] * gate[row])
364
+ return output
365
+
366
+
367
+ class DiTBlock(nn.Module):
368
+ def __init__(self, config: MiniMaxH3Config, *, attn_mode: str, split_attn: bool, dtype, device) -> None:
369
+ super().__init__()
370
+ self.norm1 = nn.RMSNorm(config.hidden_size, eps=config.norm_eps, dtype=dtype, device=device)
371
+ self.norm2 = nn.RMSNorm(config.hidden_size, eps=config.norm_eps, dtype=dtype, device=device)
372
+ self.attn = Attention(
373
+ config.hidden_size,
374
+ config.num_attention_heads,
375
+ config.attention_head_dim,
376
+ config.qk_norm_eps,
377
+ attn_mode=attn_mode,
378
+ split_attn=split_attn,
379
+ dtype=dtype,
380
+ device=device,
381
+ )
382
+ self.mlp = MLP(config.hidden_size, config.ffn_hidden_size, dtype=dtype, device=device)
383
+ self.adaln_proj = AdalnProj(
384
+ config.time_embed_dim,
385
+ config.hidden_size,
386
+ expand=6,
387
+ modalities=3,
388
+ dtype=dtype,
389
+ device=device,
390
+ )
391
+
392
+ def forward(
393
+ self,
394
+ hidden_states: torch.Tensor,
395
+ timestep_embeddings: torch.Tensor,
396
+ modulation_segments: tuple[tuple[tuple[int, int, int], ...], ...],
397
+ rotation_table: torch.Tensor,
398
+ ) -> torch.Tensor:
399
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(timestep_embeddings)
400
+ normalized = _mod_scale_shift(self.norm1(hidden_states), shift_msa, scale_msa, modulation_segments)
401
+ hidden_states = _mod_gate(
402
+ hidden_states,
403
+ self.attn(normalized, rotation_table),
404
+ gate_msa,
405
+ modulation_segments,
406
+ )
407
+ normalized = _mod_scale_shift(self.norm2(hidden_states), shift_mlp, scale_mlp, modulation_segments)
408
+ return _mod_gate(hidden_states, self.mlp(normalized), gate_mlp, modulation_segments)
409
+
410
+
411
+ class FinalLayer(nn.Module):
412
+ def __init__(
413
+ self,
414
+ hidden_size: int,
415
+ timestep_dim: int,
416
+ video_output_dim: int,
417
+ audio_output_dim: int,
418
+ *,
419
+ norm_eps: float = 1e-5,
420
+ dtype: torch.dtype,
421
+ device: torch.device | str | None = None,
422
+ ) -> None:
423
+ super().__init__()
424
+ self.norm = nn.RMSNorm(hidden_size, eps=norm_eps, dtype=dtype, device=device)
425
+ self.adaln_proj = AdalnProj(
426
+ timestep_dim,
427
+ hidden_size,
428
+ expand=2,
429
+ modalities=1,
430
+ dtype=dtype,
431
+ device=device,
432
+ )
433
+ self.video_out = nn.Linear(hidden_size, video_output_dim, dtype=torch.float32, device=device)
434
+ self.audio_out = nn.Linear(hidden_size, audio_output_dim, dtype=torch.float32, device=device)
435
+
436
+ def forward(
437
+ self,
438
+ hidden_states: torch.Tensor,
439
+ timestep_embeddings: torch.Tensor,
440
+ *,
441
+ video_slice: slice,
442
+ audio_slice: slice,
443
+ video_timestep_index: int,
444
+ audio_timestep_index: int,
445
+ video_timestep_indices: torch.Tensor | None = None,
446
+ audio_timestep_indices: torch.Tensor | None = None,
447
+ ) -> tuple[torch.Tensor, torch.Tensor]:
448
+ shift, scale = self.adaln_proj(timestep_embeddings)
449
+ video = self.norm(hidden_states[:, video_slice])
450
+ video = self._apply_timestep_modulation(
451
+ video,
452
+ shift,
453
+ scale,
454
+ timestep_index=video_timestep_index,
455
+ timestep_indices=video_timestep_indices,
456
+ label="video",
457
+ )
458
+ audio = self.norm(hidden_states[:, audio_slice])
459
+ audio = self._apply_timestep_modulation(
460
+ audio,
461
+ shift,
462
+ scale,
463
+ timestep_index=audio_timestep_index,
464
+ timestep_indices=audio_timestep_indices,
465
+ label="audio",
466
+ )
467
+ return self.video_out(video.to(torch.float32)), self.audio_out(audio.to(torch.float32))
468
+
469
+ @staticmethod
470
+ def _apply_timestep_modulation(
471
+ hidden_states: torch.Tensor,
472
+ shift: torch.Tensor,
473
+ scale: torch.Tensor,
474
+ *,
475
+ timestep_index: int,
476
+ timestep_indices: torch.Tensor | None,
477
+ label: str,
478
+ ) -> torch.Tensor:
479
+ if timestep_indices is None:
480
+ return hidden_states * (1.0 + scale[timestep_index]) + shift[timestep_index]
481
+ timestep_indices = torch.as_tensor(timestep_indices)
482
+ if timestep_indices.dtype != torch.int64 or timestep_indices.shape != hidden_states.shape[:2]:
483
+ raise ValueError(
484
+ f"MiniMax-H3 final {label} timestep indices must be int64 {tuple(hidden_states.shape[:2])}, "
485
+ f"got {timestep_indices.dtype} {tuple(timestep_indices.shape)}"
486
+ )
487
+ timestep_indices = timestep_indices.to(hidden_states.device)
488
+ if not torch.all((timestep_indices >= 0) & (timestep_indices < shift.shape[0])):
489
+ raise ValueError(f"MiniMax-H3 final {label} timestep indices are out of range")
490
+ row_shift = shift[timestep_indices]
491
+ row_scale = scale[timestep_indices]
492
+ return hidden_states * (1.0 + row_scale) + row_shift
493
+
494
+
495
+ @dataclass(frozen=True)
496
+ class MiniMaxH3Output:
497
+ video: torch.Tensor
498
+ audio: torch.Tensor
499
+
500
+
501
+ class MiniMaxH3Model(nn.Module):
502
+ def __init__(
503
+ self,
504
+ config: MiniMaxH3Config,
505
+ *,
506
+ attn_mode: str = "torch",
507
+ split_attn: bool = False,
508
+ dtype: torch.dtype = torch.bfloat16,
509
+ device: torch.device | str | None = None,
510
+ ) -> None:
511
+ super().__init__()
512
+ if attn_mode not in {"torch", "sdpa", "flash", "flash3", "sageattn", "xformers"}:
513
+ raise ValueError(f"Unsupported MiniMax-H3 attention mode: {attn_mode}")
514
+ self.config = config
515
+ self.attn_mode = attn_mode
516
+ self.split_attn = split_attn
517
+ self.video_patch_proj = nn.Linear(config.video_patch_dim, config.hidden_size, dtype=torch.float32, device=device)
518
+ self.audio_patch_proj = nn.Linear(config.audio_in_channels, config.hidden_size, dtype=torch.float32, device=device)
519
+ self.condition_proj = nn.Linear(config.text_dim, config.hidden_size, dtype=dtype, device=device)
520
+ self.time_embedder = TimeEmbedder(
521
+ config.timestep_input_dim,
522
+ config.time_embed_hidden_size,
523
+ config.time_embed_dim,
524
+ device=device,
525
+ )
526
+ self.rope = nn.Module()
527
+ inv_freq = torch.empty(config.rope_inv_freq_len, dtype=torch.float32, device=device)
528
+ self.rope.register_buffer("inv_freq", inv_freq)
529
+ self.token_refiner = TokenRefiner(
530
+ config,
531
+ attn_mode=attn_mode,
532
+ split_attn=split_attn,
533
+ dtype=dtype,
534
+ device=device,
535
+ )
536
+ self.blocks = nn.ModuleList(
537
+ [
538
+ DiTBlock(config, attn_mode=attn_mode, split_attn=split_attn, dtype=dtype, device=device)
539
+ for _ in range(config.num_layers)
540
+ ]
541
+ )
542
+ self.final_layer = FinalLayer(
543
+ config.hidden_size,
544
+ config.time_embed_dim,
545
+ config.video_patch_dim,
546
+ config.audio_in_channels,
547
+ norm_eps=config.final_norm_eps,
548
+ dtype=dtype,
549
+ device=device,
550
+ )
551
+
552
+ self.gradient_checkpointing = False
553
+ self.activation_cpu_offloading = False
554
+ self.blocks_to_swap = 0
555
+ self.offloader = None
556
+ self._execution_device = torch.device(device) if device is not None else None
557
+ self._rotary_cache: OrderedDict[tuple[H3PackedLayout, torch.device, torch.dtype], torch.Tensor] = OrderedDict()
558
+
559
+ def _apply(self, fn, recurse: bool = True):
560
+ result = super()._apply(fn, recurse=recurse)
561
+ if hasattr(self, "_rotary_cache"):
562
+ self._rotary_cache.clear()
563
+ return result
564
+
565
+ def load_state_dict(self, state_dict, strict: bool = True, assign: bool = False):
566
+ self._rotary_cache.clear()
567
+ return super().load_state_dict(state_dict, strict=strict, assign=assign)
568
+
569
+ @property
570
+ def device(self) -> torch.device:
571
+ return self.video_patch_proj.weight.device
572
+
573
+ @property
574
+ def dtype(self) -> torch.dtype:
575
+ return self.condition_proj.weight.dtype
576
+
577
+ def enable_gradient_checkpointing(self, activation_cpu_offloading: bool = False) -> None:
578
+ self.gradient_checkpointing = True
579
+ self.activation_cpu_offloading = activation_cpu_offloading
580
+
581
+ def disable_gradient_checkpointing(self) -> None:
582
+ self.gradient_checkpointing = False
583
+ self.activation_cpu_offloading = False
584
+
585
+ def enable_block_swap(self, num_blocks: int, config: BlockSwapConfig) -> None:
586
+ if num_blocks <= 0:
587
+ raise ValueError("MiniMax-H3 blocks to swap must be positive")
588
+ if num_blocks > len(self.blocks) - 2:
589
+ raise ValueError(f"MiniMax-H3 cannot swap more than {len(self.blocks) - 2} of {len(self.blocks)} blocks")
590
+ self.blocks_to_swap = num_blocks
591
+ self._execution_device = torch.device(config.device)
592
+ self.offloader = create_offloader(
593
+ "minimax-h3",
594
+ self.blocks,
595
+ len(self.blocks),
596
+ num_blocks,
597
+ config,
598
+ )
599
+
600
+ def move_to_device_except_swap_blocks(self, device: torch.device) -> None:
601
+ device = torch.device(device)
602
+ if self.blocks_to_swap:
603
+ saved_blocks = self.blocks
604
+ self.blocks = nn.ModuleList()
605
+ self.to(device)
606
+ if self.blocks_to_swap:
607
+ self.blocks = saved_blocks
608
+ self._execution_device = device
609
+
610
+ def prepare_block_swap_before_forward(self) -> None:
611
+ if not self.blocks_to_swap:
612
+ return
613
+ if self.offloader is None:
614
+ raise RuntimeError("MiniMax-H3 block swap has no offloader")
615
+ self.offloader.prepare_block_devices_before_forward(self.blocks)
616
+
617
+ def switch_block_swap_for_inference(self) -> None:
618
+ if self.blocks_to_swap:
619
+ self.offloader.set_forward_only(True)
620
+ self.prepare_block_swap_before_forward()
621
+
622
+ def switch_block_swap_for_training(self) -> None:
623
+ if self.blocks_to_swap:
624
+ self.offloader.set_forward_only(False)
625
+ self.prepare_block_swap_before_forward()
626
+
627
+ def _assert_block_device(self, block: nn.Module, index: int) -> None:
628
+ expected = self._execution_device
629
+ if expected is None:
630
+ return
631
+
632
+ def matches(actual: torch.device) -> bool:
633
+ return actual.type == expected.type and (expected.index is None or actual.index == expected.index)
634
+
635
+ for name, parameter in block.named_parameters():
636
+ if not matches(parameter.device):
637
+ raise RuntimeError(
638
+ f"MiniMax-H3 block {index} parameter {name} is on {parameter.device}, expected {expected} after wait"
639
+ )
640
+ for name, buffer in block.named_buffers():
641
+ if not matches(buffer.device):
642
+ raise RuntimeError(f"MiniMax-H3 block {index} buffer {name} is on {buffer.device}, expected {expected} after wait")
643
+
644
+ def _rotation_table(self, position_ids: torch.Tensor, dtype: torch.dtype) -> torch.Tensor:
645
+ positions = position_ids.to(device=self.rope.inv_freq.device, dtype=torch.float32)
646
+ if positions.ndim != 3 or positions.shape[-1] != 3:
647
+ raise ValueError("MiniMax-H3 position ids must be [B,S,3]")
648
+ per_axis = positions.unsqueeze(-1) * self.rope.inv_freq.reshape(1, 1, 1, -1)
649
+ temporal, height, width = per_axis.unbind(dim=2)
650
+ half = torch.cat((temporal, height, width), dim=-1)
651
+ angles = torch.cat((half, half), dim=-1)
652
+ pair_count = angles.shape[-1] // 2
653
+ angles = angles[..., :pair_count]
654
+ cosine, sine = torch.cos(angles), torch.sin(angles)
655
+ return (
656
+ torch.stack((cosine, -sine, sine, cosine), dim=-1)
657
+ .reshape(
658
+ angles.shape[0],
659
+ angles.shape[1],
660
+ 1,
661
+ pair_count,
662
+ 2,
663
+ 2,
664
+ )
665
+ .to(dtype)
666
+ )
667
+
668
+ def _cached_rotation_table(
669
+ self,
670
+ layout: H3PackedLayout,
671
+ *,
672
+ device: torch.device,
673
+ dtype: torch.dtype,
674
+ ) -> torch.Tensor:
675
+ key = (layout, torch.device(device), dtype)
676
+ cached = self._rotary_cache.get(key)
677
+ if cached is not None:
678
+ self._rotary_cache.move_to_end(key)
679
+ return cached
680
+ position_ids = build_position_grid(layout, device=device)
681
+ rotation_table = self._rotation_table(position_ids, dtype)
682
+ self._rotary_cache[key] = rotation_table
683
+ while len(self._rotary_cache) > _ROTARY_CACHE_SIZE:
684
+ self._rotary_cache.popitem(last=False)
685
+ return rotation_table
686
+
687
+ @staticmethod
688
+ def _validate_text_tags(tags: torch.Tensor, batch_size: int, text_length: int) -> torch.Tensor:
689
+ tags = torch.as_tensor(tags)
690
+ if tags.shape != (batch_size, text_length):
691
+ raise ValueError(f"MiniMax-H3 text token tags must be [{batch_size},{text_length}]")
692
+ return tags
693
+
694
+ @staticmethod
695
+ def _validate_condition_rows(
696
+ latents: Sequence[torch.Tensor],
697
+ segments,
698
+ pack,
699
+ *,
700
+ batch_size: int,
701
+ label: str,
702
+ ) -> list[torch.Tensor]:
703
+ if len(latents) != len(segments):
704
+ raise ValueError(f"MiniMax-H3 expected {len(segments)} {label} tensors, got {len(latents)}")
705
+ rows = []
706
+ for tensor, segment in zip(latents, segments):
707
+ if tensor.shape[0] != batch_size:
708
+ raise ValueError(f"MiniMax-H3 {segment.role} batch size does not match the targets")
709
+ packed = pack(tensor)
710
+ if packed.shape[1] != segment.row_count:
711
+ raise ValueError(f"MiniMax-H3 {segment.role} has {packed.shape[1]} packed rows, expected {segment.row_count}")
712
+ rows.append(packed)
713
+ return rows
714
+
715
+ @staticmethod
716
+ def _target_denoise_rows(
717
+ mask: torch.Tensor | None,
718
+ latents: torch.Tensor,
719
+ pack,
720
+ *,
721
+ label: str,
722
+ ) -> torch.Tensor | None:
723
+ if mask is None:
724
+ return None
725
+ mask = torch.as_tensor(mask, device=latents.device)
726
+ if mask.is_complex():
727
+ raise ValueError(f"MiniMax-H3 {label} must be real")
728
+ if mask.is_floating_point() and not torch.isfinite(mask).all():
729
+ raise ValueError(f"MiniMax-H3 {label} must be finite")
730
+ if not torch.all((mask == 0) | (mask == 1)):
731
+ raise ValueError(f"MiniMax-H3 {label} must contain only 0 (fixed) and 1 (regenerate)")
732
+ try:
733
+ mask = torch.broadcast_to(mask, latents.shape)
734
+ except RuntimeError as error:
735
+ raise ValueError(
736
+ f"MiniMax-H3 {label} shape {tuple(mask.shape)} is not broadcastable to {tuple(latents.shape)}"
737
+ ) from error
738
+ packed = pack(mask.to(torch.bool))
739
+ row_values = packed[..., :1]
740
+ if not torch.equal(packed, row_values.expand_as(packed)):
741
+ raise ValueError(f"MiniMax-H3 {label} must be constant within every packed target row")
742
+ return row_values[..., 0].detach().cpu()
743
+
744
+ def forward(
745
+ self,
746
+ *,
747
+ video_latents: torch.Tensor,
748
+ audio_latents: torch.Tensor,
749
+ text_hidden_states: torch.Tensor,
750
+ text_token_tags: torch.Tensor,
751
+ layout: H3PackedLayout,
752
+ model_t_video: float | torch.Tensor,
753
+ model_t_audio: float | torch.Tensor,
754
+ visual_condition_latents: Sequence[torch.Tensor] = (),
755
+ audio_condition_latents: Sequence[torch.Tensor] = (),
756
+ visual_condition_clean: float = 0.999,
757
+ audio_condition_clean: float = 1.0,
758
+ video_denoise_mask: torch.Tensor | None = None,
759
+ audio_denoise_mask: torch.Tensor | None = None,
760
+ ) -> MiniMaxH3Output:
761
+ if video_latents.ndim != 5 or tuple(video_latents.shape[2:]) != (
762
+ layout.target_video.frames,
763
+ layout.target_video.height,
764
+ layout.target_video.width,
765
+ ):
766
+ raise ValueError("MiniMax-H3 target video tensor does not match the packed layout")
767
+ if audio_latents.ndim != 4 or tuple(audio_latents.shape[1:]) != (
768
+ self.config.audio_in_channels,
769
+ 2,
770
+ layout.target_audio_frames,
771
+ ):
772
+ raise ValueError("MiniMax-H3 target audio tensor does not match the packed layout")
773
+ batch_size = video_latents.shape[0]
774
+ if batch_size != 1:
775
+ raise ValueError(f"MiniMax-H3 R1 requires batch_size=1, got {batch_size}; use gradient accumulation")
776
+ if audio_latents.shape[0] != batch_size:
777
+ raise ValueError("MiniMax-H3 target video and audio batch sizes differ")
778
+ if text_hidden_states.shape != (batch_size, layout.text_length, self.config.text_dim):
779
+ raise ValueError(f"MiniMax-H3 text hidden states must be [{batch_size},{layout.text_length},{self.config.text_dim}]")
780
+ text_token_tags = self._validate_text_tags(text_token_tags, batch_size, layout.text_length)
781
+
782
+ execution_device = self.video_patch_proj.weight.device
783
+ video_dtype = video_latents.dtype
784
+ audio_dtype = audio_latents.dtype
785
+ video_latents = video_latents.to(execution_device)
786
+ audio_latents = audio_latents.to(execution_device)
787
+ text_hidden_states = text_hidden_states.to(execution_device)
788
+ visual_condition_latents = tuple(tensor.to(execution_device) for tensor in visual_condition_latents)
789
+ audio_condition_latents = tuple(tensor.to(execution_device) for tensor in audio_condition_latents)
790
+ video_denoise_rows = self._target_denoise_rows(
791
+ video_denoise_mask,
792
+ video_latents,
793
+ pack_video_rows,
794
+ label="video denoise mask",
795
+ )
796
+ audio_denoise_rows = self._target_denoise_rows(
797
+ audio_denoise_mask,
798
+ audio_latents,
799
+ pack_audio_rows,
800
+ label="audio denoise mask",
801
+ )
802
+
803
+ visual_segments = tuple(segment for segment in layout.segments if segment.kind == "visual_condition")
804
+ audio_segments = tuple(segment for segment in layout.segments if segment.kind == "audio_condition")
805
+ if layout.task == "fl2va":
806
+ expected_visual_geometries = layout.visual_conditions
807
+ elif layout.task == "ref2va":
808
+ expected_visual_geometries = tuple(
809
+ reference.video for reference in layout.references if reference.kind in {"image", "video"}
810
+ )
811
+ else:
812
+ expected_visual_geometries = ()
813
+ expected_audio_frames = (
814
+ tuple(reference.audio_frames for reference in layout.references if reference.audio_frames)
815
+ if layout.task == "ref2va"
816
+ else ()
817
+ )
818
+ for tensor, segment, geometry in zip(
819
+ visual_condition_latents,
820
+ visual_segments,
821
+ expected_visual_geometries,
822
+ ):
823
+ actual = tuple(tensor.shape[2:]) if tensor.ndim == 5 else tuple(tensor.shape)
824
+ expected = (geometry.frames, geometry.height, geometry.width)
825
+ if actual != expected:
826
+ raise ValueError(
827
+ f"MiniMax-H3 {segment.role} geometry expected {'x'.join(map(str, expected))}, got {'x'.join(map(str, actual))}"
828
+ )
829
+ for tensor, segment, frames in zip(audio_condition_latents, audio_segments, expected_audio_frames):
830
+ actual = tuple(tensor.shape[1:]) if tensor.ndim == 4 else tuple(tensor.shape)
831
+ expected = (self.config.audio_in_channels, 2, frames)
832
+ if actual != expected:
833
+ raise ValueError(
834
+ f"MiniMax-H3 {segment.role} geometry expected {'x'.join(map(str, expected))}, got {'x'.join(map(str, actual))}"
835
+ )
836
+ visual_rows = self._validate_condition_rows(
837
+ visual_condition_latents,
838
+ visual_segments,
839
+ pack_video_rows,
840
+ batch_size=batch_size,
841
+ label="visual condition",
842
+ )
843
+ audio_rows = self._validate_condition_rows(
844
+ audio_condition_latents,
845
+ audio_segments,
846
+ pack_audio_rows,
847
+ batch_size=batch_size,
848
+ label="audio condition",
849
+ )
850
+ visual_rows.append(pack_video_rows(video_latents))
851
+ audio_rows.append(pack_audio_rows(audio_latents))
852
+ projected_video = self.video_patch_proj(torch.cat(visual_rows, dim=1).to(torch.float32)).to(self.dtype)
853
+ projected_audio = self.audio_patch_proj(torch.cat(audio_rows, dim=1).to(torch.float32)).to(self.dtype)
854
+
855
+ text = self.condition_proj(text_hidden_states.to(self.dtype))
856
+ text = self.token_refiner(text)
857
+ parts = []
858
+ visual_offset = 0
859
+ audio_offset = 0
860
+ for segment in layout.segments:
861
+ if segment.kind == "text":
862
+ parts.append(text)
863
+ elif segment.kind in {"visual_condition", "target_video"}:
864
+ parts.append(projected_video[:, visual_offset : visual_offset + segment.row_count])
865
+ visual_offset += segment.row_count
866
+ else:
867
+ parts.append(projected_audio[:, audio_offset : audio_offset + segment.row_count])
868
+ audio_offset += segment.row_count
869
+ hidden_states = torch.cat(parts, dim=1)
870
+ if hidden_states.shape[1] != layout.row_count:
871
+ raise RuntimeError("MiniMax-H3 packed residual stream length drifted from its layout")
872
+
873
+ timestep_rows = build_timestep_rows(
874
+ layout,
875
+ text_token_tags=text_token_tags,
876
+ model_t_video=model_t_video,
877
+ model_t_audio=model_t_audio,
878
+ visual_condition_clean=visual_condition_clean,
879
+ audio_condition_clean=audio_condition_clean,
880
+ target_video_denoise_rows=video_denoise_rows,
881
+ target_audio_denoise_rows=audio_denoise_rows,
882
+ )
883
+ timestep_embeddings = self.time_embedder(timestep_rows.unique_timesteps.to(execution_device)).to(self.dtype)
884
+ rotation_table = self._cached_rotation_table(
885
+ layout,
886
+ device=execution_device,
887
+ dtype=hidden_states.dtype,
888
+ )
889
+
890
+ for index, block in enumerate(self.blocks):
891
+ if self.blocks_to_swap:
892
+ self.offloader.wait_for_block(index)
893
+ self._assert_block_device(block, index)
894
+ if self.gradient_checkpointing and self.training:
895
+ forward_fn = block
896
+ if self.activation_cpu_offloading:
897
+ forward_fn = create_cpu_offloading_wrapper(forward_fn, execution_device)
898
+ hidden_states = torch.utils.checkpoint.checkpoint(
899
+ forward_fn,
900
+ hidden_states,
901
+ timestep_embeddings,
902
+ timestep_rows.block_segments,
903
+ rotation_table,
904
+ use_reentrant=False,
905
+ )
906
+ else:
907
+ hidden_states = block(
908
+ hidden_states,
909
+ timestep_embeddings,
910
+ timestep_rows.block_segments,
911
+ rotation_table,
912
+ )
913
+ if self.blocks_to_swap:
914
+ self.offloader.submit_move_blocks_forward(self.blocks, index)
915
+
916
+ final_layer_kwargs = {
917
+ "video_slice": layout.target_video_segment.row_slice,
918
+ "audio_slice": layout.target_audio_segment.row_slice,
919
+ "video_timestep_index": timestep_rows.video_timestep_index,
920
+ "audio_timestep_index": timestep_rows.audio_timestep_index,
921
+ }
922
+ if video_denoise_rows is not None or audio_denoise_rows is not None:
923
+ final_layer_kwargs.update(
924
+ video_timestep_indices=timestep_rows.video_timestep_indices,
925
+ audio_timestep_indices=timestep_rows.audio_timestep_indices,
926
+ )
927
+ video_rows, audio_rows = self.final_layer(hidden_states, timestep_embeddings, **final_layer_kwargs)
928
+ video, audio = unpack_targets(layout, video_rows, audio_rows)
929
+ return MiniMaxH3Output(video=video.to(video_dtype), audio=audio.to(audio_dtype))
930
+
931
+
932
+ def load_h3_transformer(
933
+ checkpoint_path: str | Path,
934
+ *,
935
+ device: torch.device | str,
936
+ dtype: torch.dtype = torch.bfloat16,
937
+ attn_mode: str = "torch",
938
+ split_attn: bool = False,
939
+ disable_mmap: bool = False,
940
+ ) -> MiniMaxH3Model:
941
+ if dtype != torch.bfloat16:
942
+ raise ValueError("MiniMax-H3 R1 accepts only BF16 transformer checkpoints")
943
+ files = resolve_safetensors_files(checkpoint_path)
944
+ config = parse_h3_transformer_config(load_safetensors_metadata(files))
945
+ return load_safetensors_module(
946
+ lambda: MiniMaxH3Model(config, attn_mode=attn_mode, split_attn=split_attn, dtype=dtype),
947
+ files,
948
+ device=device,
949
+ dtype=None,
950
+ strict_dtype=True,
951
+ disable_mmap=disable_mmap,
952
+ )
musubi_tuner/minimax_h3/packing.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 The MiniMax and HuggingFace Teams. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Adapted for Musubi from Hugging Face Diffusers PR #14355 at commit
16
+ # abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc
17
+ # (modular_pipelines/minimax_h3/packing.py and packing_ref2va.py).
18
+ # ComfyUI is used only as an independent numerical reference.
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+ from typing import Literal
26
+
27
+ import torch
28
+
29
+ from musubi_tuner.minimax_h3.media import H3Task
30
+
31
+ VIDEO_CHANNELS = 24
32
+ AUDIO_CHANNELS = 32
33
+ STEREO_CHANNELS = 2
34
+ VIDEO_PATCH_SIZE = (1, 2, 2)
35
+ FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
36
+ FRAME_RESCALE = 5.0 / 3.0
37
+
38
+ H3ReferenceKind = Literal["image", "audio", "video"]
39
+ H3SegmentKind = Literal["text", "visual_condition", "audio_condition", "target_audio", "target_video"]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class H3VideoGeometry:
44
+ frames: int
45
+ height: int
46
+ width: int
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.frames <= 0:
50
+ raise ValueError(f"MiniMax-H3 video geometry needs positive frames, got {self.frames}")
51
+ if self.height <= 0 or self.width <= 0 or self.height % 2 or self.width % 2:
52
+ raise ValueError(f"MiniMax-H3 latent video axes must be positive multiples of 2, got {self.height}x{self.width}")
53
+
54
+ @property
55
+ def frame_rows(self) -> int:
56
+ return (self.height // 2) * (self.width // 2)
57
+
58
+ @property
59
+ def row_count(self) -> int:
60
+ return self.frames * self.frame_rows
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class H3ReferenceGeometry:
65
+ kind: H3ReferenceKind
66
+ video: H3VideoGeometry | None = None
67
+ audio_frames: int = 0
68
+
69
+ def __post_init__(self) -> None:
70
+ if self.kind not in {"image", "audio", "video"}:
71
+ raise ValueError(f"Unsupported MiniMax-H3 reference kind: {self.kind}")
72
+ if self.audio_frames < 0:
73
+ raise ValueError(f"MiniMax-H3 reference audio length must be nonnegative, got {self.audio_frames}")
74
+ if self.kind == "image":
75
+ if self.video is None or self.video.frames != 1 or self.audio_frames:
76
+ raise ValueError("MiniMax-H3 image references require one visual frame and no audio rows")
77
+ elif self.kind == "audio":
78
+ if self.video is not None or self.audio_frames <= 0:
79
+ raise ValueError("MiniMax-H3 audio references require audio rows and no visual geometry")
80
+ else:
81
+ if self.video is None:
82
+ raise ValueError("MiniMax-H3 video references require visual geometry")
83
+ _validate_video_latent_frames(self.video.frames, "reference video")
84
+ if self.audio_frames:
85
+ expected = _expected_audio_frames(self.video.frames)
86
+ if self.audio_frames != expected:
87
+ raise ValueError(f"MiniMax-H3 reference video audio has {self.audio_frames} frames, expected {expected}")
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class H3RowSegment:
92
+ role: str
93
+ kind: H3SegmentKind
94
+ start: int
95
+ stop: int
96
+
97
+ def __post_init__(self) -> None:
98
+ if self.start < 0 or self.stop <= self.start:
99
+ raise ValueError(f"Invalid MiniMax-H3 row segment {self.role}: [{self.start}, {self.stop})")
100
+
101
+ @property
102
+ def row_count(self) -> int:
103
+ return self.stop - self.start
104
+
105
+ @property
106
+ def row_slice(self) -> slice:
107
+ return slice(self.start, self.stop)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class H3PackedLayout:
112
+ task: H3Task
113
+ text_length: int
114
+ target_video: H3VideoGeometry
115
+ target_audio_frames: int
116
+ visual_conditions: tuple[H3VideoGeometry, ...]
117
+ references: tuple[H3ReferenceGeometry, ...]
118
+ segments: tuple[H3RowSegment, ...]
119
+ row_count: int
120
+
121
+ def segment(self, role: str) -> H3RowSegment:
122
+ matches = [segment for segment in self.segments if segment.role == role]
123
+ if len(matches) != 1:
124
+ raise KeyError(f"MiniMax-H3 layout expected exactly one {role!r} segment, found {len(matches)}")
125
+ return matches[0]
126
+
127
+ @property
128
+ def target_video_segment(self) -> H3RowSegment:
129
+ return self.segment("target_video")
130
+
131
+ @property
132
+ def target_audio_segment(self) -> H3RowSegment:
133
+ return self.segment("target_audio")
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class H3TimestepRows:
138
+ unique_timesteps: torch.Tensor
139
+ row_timesteps: torch.Tensor
140
+ row_timestep_indices: torch.Tensor
141
+ token_tags: torch.Tensor
142
+ block_adaln_indices: torch.Tensor
143
+ block_segments: tuple[tuple[tuple[int, int, int], ...], ...]
144
+ video_timestep_index: int
145
+ audio_timestep_index: int
146
+ video_timestep_indices: torch.Tensor
147
+ audio_timestep_indices: torch.Tensor
148
+
149
+
150
+ def _coerce_video_geometry(value: H3VideoGeometry | Sequence[int], label: str) -> H3VideoGeometry:
151
+ if isinstance(value, H3VideoGeometry):
152
+ return value
153
+ if len(value) != 3:
154
+ raise ValueError(f"MiniMax-H3 {label} geometry must be (frames,height,width), got {tuple(value)}")
155
+ return H3VideoGeometry(*(int(part) for part in value))
156
+
157
+
158
+ def _validate_video_latent_frames(frames: int, label: str) -> None:
159
+ if frames < 2 or (frames - 2) % 5:
160
+ raise ValueError(f"MiniMax-H3 {label} latent frames must be 5*n+2, got {frames}")
161
+
162
+
163
+ def _expected_audio_frames(video_latent_frames: int) -> int:
164
+ _validate_video_latent_frames(video_latent_frames, "target video")
165
+ n = (video_latent_frames - 2) // 5
166
+ pixel_frames = 17 * n + 5
167
+ return (10 * pixel_frames + 3) // 6
168
+
169
+
170
+ def pack_video_rows(latents: torch.Tensor) -> torch.Tensor:
171
+ """Patch H3 video latents while preserving the leading batch axis."""
172
+ unbatched = latents.ndim == 4
173
+ if unbatched:
174
+ latents = latents.unsqueeze(0)
175
+ if latents.ndim != 5:
176
+ raise ValueError(f"MiniMax-H3 video latents must be [B,24,F,H,W], got {tuple(latents.shape)}")
177
+ batch, channels, frames, height, width = latents.shape
178
+ if channels != VIDEO_CHANNELS:
179
+ raise ValueError(f"MiniMax-H3 video latents need {VIDEO_CHANNELS} channels, got {channels}")
180
+ patch_t, patch_h, patch_w = VIDEO_PATCH_SIZE
181
+ if frames % patch_t or height % patch_h or width % patch_w:
182
+ raise ValueError(f"MiniMax-H3 video latent shape {frames}x{height}x{width} is not divisible by patch {VIDEO_PATCH_SIZE}")
183
+ rows = latents.reshape(
184
+ batch,
185
+ channels,
186
+ frames // patch_t,
187
+ patch_t,
188
+ height // patch_h,
189
+ patch_h,
190
+ width // patch_w,
191
+ patch_w,
192
+ )
193
+ rows = rows.permute(0, 2, 4, 6, 1, 3, 5, 7).reshape(
194
+ batch,
195
+ (frames // patch_t) * (height // patch_h) * (width // patch_w),
196
+ channels * patch_t * patch_h * patch_w,
197
+ )
198
+ return rows[0] if unbatched else rows
199
+
200
+
201
+ def pack_audio_rows(latents: torch.Tensor) -> torch.Tensor:
202
+ """Pack stereo H3 audio in channel-major order."""
203
+ unbatched = latents.ndim == 3
204
+ if unbatched:
205
+ latents = latents.unsqueeze(0)
206
+ if latents.ndim != 4 or latents.shape[1] != AUDIO_CHANNELS or latents.shape[2] != STEREO_CHANNELS:
207
+ raise ValueError(f"MiniMax-H3 audio latents must be [B,32,2,A], got {tuple(latents.shape)}")
208
+ batch, channels, stereo, frames = latents.shape
209
+ rows = latents.permute(0, 2, 3, 1).reshape(batch, stereo * frames, channels)
210
+ return rows[0] if unbatched else rows
211
+
212
+
213
+ def build_h3_layout(
214
+ *,
215
+ task: H3Task,
216
+ text_length: int,
217
+ target_video: H3VideoGeometry | Sequence[int],
218
+ target_audio_frames: int,
219
+ visual_conditions: Sequence[H3VideoGeometry | Sequence[int]] = (),
220
+ references: Sequence[H3ReferenceGeometry] = (),
221
+ ) -> H3PackedLayout:
222
+ if task not in {"t2va", "fl2va", "ref2va"}:
223
+ raise ValueError(f"Unsupported MiniMax-H3 task: {task}")
224
+ if text_length <= 0:
225
+ raise ValueError(f"MiniMax-H3 text length must be positive, got {text_length}")
226
+ target_video = _coerce_video_geometry(target_video, "target video")
227
+ _validate_video_latent_frames(target_video.frames, "target video")
228
+ expected_audio_frames = _expected_audio_frames(target_video.frames)
229
+ if target_audio_frames != expected_audio_frames:
230
+ raise ValueError(
231
+ f"MiniMax-H3 target audio has {target_audio_frames} frames, expected {expected_audio_frames} "
232
+ f"for {target_video.frames} video latent frames"
233
+ )
234
+
235
+ visual_conditions = tuple(
236
+ _coerce_video_geometry(condition, f"visual condition {index}") for index, condition in enumerate(visual_conditions)
237
+ )
238
+ references = tuple(references)
239
+ if task == "t2va" and (visual_conditions or references):
240
+ raise ValueError("MiniMax-H3 T2VA layout does not accept condition rows")
241
+ if task == "fl2va":
242
+ if references or len(visual_conditions) != 2:
243
+ raise ValueError("MiniMax-H3 FL2VA layout requires exactly first and last visual conditions")
244
+ for role, condition in zip(("first", "last"), visual_conditions):
245
+ if condition != H3VideoGeometry(1, target_video.height, target_video.width):
246
+ raise ValueError(f"MiniMax-H3 FL2VA {role} condition must be one target-sized latent frame, got {condition}")
247
+ if task == "ref2va":
248
+ if visual_conditions or not references:
249
+ raise ValueError("MiniMax-H3 Ref2VA layout requires ordered references and no FL2VA conditions")
250
+ if not any(reference.kind in {"image", "video"} for reference in references):
251
+ raise ValueError("MiniMax-H3 Ref2VA layout requires at least one visual reference")
252
+
253
+ segments = []
254
+ row = 0
255
+
256
+ def append(role: str, kind: H3SegmentKind, row_count: int) -> None:
257
+ nonlocal row
258
+ segments.append(H3RowSegment(role, kind, row, row + row_count))
259
+ row += row_count
260
+
261
+ append("text", "text", text_length)
262
+ if task == "fl2va":
263
+ append("first", "visual_condition", visual_conditions[0].row_count)
264
+ append("last", "visual_condition", visual_conditions[1].row_count)
265
+ elif task == "ref2va":
266
+ for index, reference in enumerate(references):
267
+ prefix = f"ref_{index:03d}"
268
+ if reference.kind == "image":
269
+ append(f"{prefix}_image", "visual_condition", reference.video.row_count)
270
+ elif reference.kind == "audio":
271
+ append(f"{prefix}_audio", "audio_condition", reference.audio_frames * STEREO_CHANNELS)
272
+ else:
273
+ if reference.audio_frames:
274
+ append(f"{prefix}_audio", "audio_condition", reference.audio_frames * STEREO_CHANNELS)
275
+ append(f"{prefix}_video", "visual_condition", reference.video.row_count)
276
+ append("target_audio", "target_audio", target_audio_frames * STEREO_CHANNELS)
277
+ append("target_video", "target_video", target_video.row_count)
278
+
279
+ return H3PackedLayout(
280
+ task=task,
281
+ text_length=text_length,
282
+ target_video=target_video,
283
+ target_audio_frames=target_audio_frames,
284
+ visual_conditions=visual_conditions,
285
+ references=references,
286
+ segments=tuple(segments),
287
+ row_count=row,
288
+ )
289
+
290
+
291
+ def _axis_from_sqrt_area(dimension: int, sqrt_area: float) -> torch.Tensor:
292
+ ratio = dimension / sqrt_area
293
+ count = dimension // 2
294
+ return (torch.arange(count, dtype=torch.float64) * (ratio / count) + (1.0 - ratio) / 2.0) * 32.0
295
+
296
+
297
+ def _frame_grid(video: H3VideoGeometry) -> tuple[torch.Tensor, torch.Tensor]:
298
+ sqrt_area = math.sqrt(video.height * video.width)
299
+ height_axis = _axis_from_sqrt_area(video.height, sqrt_area)
300
+ width_axis = _axis_from_sqrt_area(video.width, sqrt_area)
301
+ height, width = torch.meshgrid(height_axis, width_axis, indexing="ij")
302
+ return torch.stack((height.reshape(-1), width.reshape(-1)), dim=-1), width_axis
303
+
304
+
305
+ def _video_t_spans(frames: int) -> list[float]:
306
+ return [FRAME_RESCALE * FRAME_PER_TOKEN[index % len(FRAME_PER_TOKEN)] for index in range(frames)]
307
+
308
+
309
+ def _video_grid(video: H3VideoGeometry, cursor: float) -> torch.Tensor:
310
+ spans = torch.tensor(_video_t_spans(video.frames), dtype=torch.float64)
311
+ times = cursor + torch.cat((torch.zeros(1, dtype=torch.float64), spans[:-1].cumsum(0)))
312
+ frame, _ = _frame_grid(video)
313
+ grid = torch.empty(video.frames, frame.shape[0], 3, dtype=torch.float64)
314
+ grid[..., 0] = times[:, None]
315
+ grid[..., 1:] = frame[None]
316
+ return grid.reshape(-1, 3)
317
+
318
+
319
+ def _audio_grid(cursor: float, frames: int, width_low: float, width_high: float) -> torch.Tensor:
320
+ grid = torch.zeros(frames * STEREO_CHANNELS, 3, dtype=torch.float64)
321
+ grid[:, 0] = (cursor + torch.arange(frames, dtype=torch.float64)).repeat(STEREO_CHANNELS)
322
+ grid[:frames, 2] = width_low
323
+ grid[frames:, 2] = width_high
324
+ return grid
325
+
326
+
327
+ def build_position_grid(layout: H3PackedLayout, *, device: torch.device | str | None = None) -> torch.Tensor:
328
+ positions = torch.empty(layout.row_count, 3, dtype=torch.float64)
329
+ text = layout.segment("text")
330
+ positions[text.row_slice] = 0
331
+ positions[text.row_slice, 0] = torch.arange(layout.text_length, dtype=torch.float64)
332
+
333
+ target_frame, target_width = _frame_grid(layout.target_video)
334
+ target_width_endpoints = float(target_width[0]), float(target_width[-1])
335
+ cursor = float(layout.text_length)
336
+
337
+ if layout.task == "fl2va":
338
+ first = layout.segment("first")
339
+ positions[first.row_slice, 0] = cursor
340
+ positions[first.row_slice, 1:] = target_frame
341
+ last = layout.segment("last")
342
+ last_time = cursor + sum(_video_t_spans(layout.target_video.frames)) - FRAME_RESCALE
343
+ positions[last.row_slice, 0] = last_time
344
+ positions[last.row_slice, 1:] = target_frame
345
+ elif layout.task == "ref2va":
346
+ for index, reference in enumerate(layout.references):
347
+ prefix = f"ref_{index:03d}"
348
+ if reference.kind == "image":
349
+ segment = layout.segment(f"{prefix}_image")
350
+ frame, _ = _frame_grid(reference.video)
351
+ positions[segment.row_slice, 0] = cursor
352
+ positions[segment.row_slice, 1:] = frame
353
+ cursor += 1.0
354
+ elif reference.kind == "audio":
355
+ segment = layout.segment(f"{prefix}_audio")
356
+ positions[segment.row_slice] = _audio_grid(cursor, reference.audio_frames, *target_width_endpoints)
357
+ cursor += float(reference.audio_frames)
358
+ else:
359
+ frame, width = _frame_grid(reference.video)
360
+ if reference.audio_frames:
361
+ audio = layout.segment(f"{prefix}_audio")
362
+ positions[audio.row_slice] = _audio_grid(
363
+ cursor,
364
+ reference.audio_frames,
365
+ float(width[0]),
366
+ float(width[-1]),
367
+ )
368
+ video = layout.segment(f"{prefix}_video")
369
+ positions[video.row_slice] = _video_grid(reference.video, cursor)
370
+ cursor += max(float(reference.audio_frames), sum(_video_t_spans(reference.video.frames)))
371
+
372
+ target_audio = layout.target_audio_segment
373
+ positions[target_audio.row_slice] = _audio_grid(
374
+ cursor,
375
+ layout.target_audio_frames,
376
+ *target_width_endpoints,
377
+ )
378
+ target_video = layout.target_video_segment
379
+ positions[target_video.row_slice] = _video_grid(layout.target_video, cursor)
380
+ positions = positions.unsqueeze(0)
381
+ return positions.to(device=device) if device is not None else positions
382
+
383
+
384
+ def _single_model_time(value: float | torch.Tensor, label: str) -> float:
385
+ tensor = torch.as_tensor(value).detach().reshape(-1)
386
+ if tensor.numel() != 1:
387
+ raise ValueError(f"MiniMax-H3 R1 {label} must contain exactly one value")
388
+ scalar = float(tensor[0].item())
389
+ if not math.isfinite(scalar) or not 0.0 <= scalar <= 1.0:
390
+ raise ValueError(f"MiniMax-H3 {label} must be finite and in [0,1], got {scalar}")
391
+ return scalar
392
+
393
+
394
+ def _validate_clean_coefficient(value: float, label: str) -> float:
395
+ value = float(value)
396
+ if not math.isfinite(value) or not 0.0 <= value <= 1.0:
397
+ raise ValueError(f"MiniMax-H3 {label} must be finite and in [0,1], got {value}")
398
+ return value
399
+
400
+
401
+ def _build_modulation_segments(indices: torch.Tensor) -> tuple[tuple[int, int, int], ...]:
402
+ change_points = torch.nonzero(indices[1:] != indices[:-1], as_tuple=False).flatten() + 1
403
+ boundaries = torch.cat((indices.new_tensor([0]), change_points, indices.new_tensor([indices.numel()])))
404
+ return tuple(
405
+ tuple(row)
406
+ for row in torch.stack(
407
+ (boundaries[:-1], boundaries[1:], indices[boundaries[:-1]]),
408
+ dim=1,
409
+ ).tolist()
410
+ )
411
+
412
+
413
+ def _validate_target_denoise_rows(mask: torch.Tensor | None, row_count: int, label: str) -> torch.Tensor:
414
+ if mask is None:
415
+ return torch.ones((1, row_count), dtype=torch.bool)
416
+ mask = torch.as_tensor(mask).detach().cpu()
417
+ if mask.shape != (1, row_count):
418
+ raise ValueError(f"MiniMax-H3 {label} must be [1,{row_count}], got {tuple(mask.shape)}")
419
+ if mask.is_floating_point() and not torch.isfinite(mask).all():
420
+ raise ValueError(f"MiniMax-H3 {label} must be finite")
421
+ if not torch.all((mask == 0) | (mask == 1)):
422
+ raise ValueError(f"MiniMax-H3 {label} must contain only 0 (fixed) and 1 (regenerate)")
423
+ return mask.to(torch.bool)
424
+
425
+
426
+ def _timestep_index(unique_timesteps: torch.Tensor, value: float, label: str, *, fallback: int) -> int:
427
+ matches = torch.nonzero(unique_timesteps == torch.tensor(value, dtype=unique_timesteps.dtype), as_tuple=False)
428
+ if matches.numel() == 0:
429
+ return fallback
430
+ if matches.shape != (1, 1):
431
+ raise RuntimeError(f"MiniMax-H3 {label} timestep is missing from the packed plan")
432
+ return int(matches[0, 0])
433
+
434
+
435
+ def build_timestep_rows(
436
+ layout: H3PackedLayout,
437
+ *,
438
+ text_token_tags: torch.Tensor,
439
+ model_t_video: float | torch.Tensor,
440
+ model_t_audio: float | torch.Tensor,
441
+ visual_condition_clean: float = 0.999,
442
+ audio_condition_clean: float = 1.0,
443
+ target_video_denoise_rows: torch.Tensor | None = None,
444
+ target_audio_denoise_rows: torch.Tensor | None = None,
445
+ ) -> H3TimestepRows:
446
+ video_time = _single_model_time(model_t_video, "video model time")
447
+ audio_time = _single_model_time(model_t_audio, "audio model time")
448
+ visual_clean = _validate_clean_coefficient(visual_condition_clean, "visual condition clean coefficient")
449
+ audio_clean = _validate_clean_coefficient(audio_condition_clean, "audio condition clean coefficient")
450
+
451
+ text_token_tags = torch.as_tensor(text_token_tags)
452
+ if text_token_tags.dtype != torch.int64 or text_token_tags.shape != (1, layout.text_length):
453
+ raise ValueError(f"MiniMax-H3 text token tags must be int64 [1,{layout.text_length}]")
454
+ if not torch.all((text_token_tags == 0) | (text_token_tags == 1)):
455
+ raise ValueError("MiniMax-H3 text token tags may contain only 0 and 1")
456
+ text_token_tags = text_token_tags.detach().cpu()
457
+
458
+ segment_times = {}
459
+ for segment in layout.segments:
460
+ if segment.kind in {"text", "target_video"}:
461
+ segment_times[segment.role] = video_time
462
+ elif segment.kind == "target_audio":
463
+ segment_times[segment.role] = audio_time
464
+ elif segment.kind == "visual_condition":
465
+ segment_times[segment.role] = max(video_time, visual_clean)
466
+ else:
467
+ segment_times[segment.role] = max(audio_time, audio_clean)
468
+
469
+ row_timesteps = torch.empty((1, layout.row_count), dtype=torch.float32)
470
+ token_tags = torch.empty((1, layout.row_count), dtype=torch.int64)
471
+ for segment in layout.segments:
472
+ value = segment_times[segment.role]
473
+ row_timesteps[:, segment.row_slice] = value
474
+ if segment.kind == "text":
475
+ token_tags[:, segment.row_slice] = text_token_tags
476
+ elif segment.kind in {"visual_condition", "target_video"}:
477
+ token_tags[:, segment.row_slice] = 0
478
+ else:
479
+ token_tags[:, segment.row_slice] = 2
480
+
481
+ video_denoise_rows = _validate_target_denoise_rows(
482
+ target_video_denoise_rows,
483
+ layout.target_video_segment.row_count,
484
+ "target video denoise rows",
485
+ )
486
+ audio_denoise_rows = _validate_target_denoise_rows(
487
+ target_audio_denoise_rows,
488
+ layout.target_audio_segment.row_count,
489
+ "target audio denoise rows",
490
+ )
491
+ row_timesteps[:, layout.target_video_segment.row_slice] = torch.where(
492
+ video_denoise_rows,
493
+ torch.tensor(video_time, dtype=row_timesteps.dtype),
494
+ torch.tensor(1.0, dtype=row_timesteps.dtype),
495
+ )
496
+ row_timesteps[:, layout.target_audio_segment.row_slice] = torch.where(
497
+ audio_denoise_rows,
498
+ torch.tensor(audio_time, dtype=row_timesteps.dtype),
499
+ torch.tensor(1.0, dtype=row_timesteps.dtype),
500
+ )
501
+
502
+ unique_timesteps, row_timestep_indices = torch.unique(
503
+ row_timesteps,
504
+ sorted=True,
505
+ return_inverse=True,
506
+ )
507
+
508
+ block_adaln_indices = 3 * row_timestep_indices + token_tags
509
+ block_segments = tuple(_build_modulation_segments(indices) for indices in block_adaln_indices)
510
+
511
+ video_timestep_indices = row_timestep_indices[:, layout.target_video_segment.row_slice]
512
+ audio_timestep_indices = row_timestep_indices[:, layout.target_audio_segment.row_slice]
513
+
514
+ return H3TimestepRows(
515
+ unique_timesteps=unique_timesteps,
516
+ row_timesteps=row_timesteps,
517
+ row_timestep_indices=row_timestep_indices,
518
+ token_tags=token_tags,
519
+ block_adaln_indices=block_adaln_indices,
520
+ block_segments=block_segments,
521
+ video_timestep_index=_timestep_index(
522
+ unique_timesteps,
523
+ video_time,
524
+ "target video",
525
+ fallback=int(video_timestep_indices[0, 0]),
526
+ ),
527
+ audio_timestep_index=_timestep_index(
528
+ unique_timesteps,
529
+ audio_time,
530
+ "target audio",
531
+ fallback=int(audio_timestep_indices[0, 0]),
532
+ ),
533
+ video_timestep_indices=video_timestep_indices,
534
+ audio_timestep_indices=audio_timestep_indices,
535
+ )
536
+
537
+
538
+ def _unpack_video_rows(rows: torch.Tensor, video: H3VideoGeometry) -> torch.Tensor:
539
+ unbatched = rows.ndim == 2
540
+ if unbatched:
541
+ rows = rows.unsqueeze(0)
542
+ expected_shape = (video.row_count, VIDEO_CHANNELS * math.prod(VIDEO_PATCH_SIZE))
543
+ if rows.ndim != 3 or tuple(rows.shape[1:]) != expected_shape:
544
+ raise ValueError(f"MiniMax-H3 target video rows must be [B,{expected_shape[0]},{expected_shape[1]}]")
545
+ batch = rows.shape[0]
546
+ patch_t, patch_h, patch_w = VIDEO_PATCH_SIZE
547
+ unpacked = rows.reshape(
548
+ batch,
549
+ video.frames // patch_t,
550
+ video.height // patch_h,
551
+ video.width // patch_w,
552
+ VIDEO_CHANNELS,
553
+ patch_t,
554
+ patch_h,
555
+ patch_w,
556
+ )
557
+ unpacked = unpacked.permute(0, 4, 1, 5, 2, 6, 3, 7).reshape(
558
+ batch,
559
+ VIDEO_CHANNELS,
560
+ video.frames,
561
+ video.height,
562
+ video.width,
563
+ )
564
+ return unpacked[0] if unbatched else unpacked
565
+
566
+
567
+ def _unpack_audio_rows(rows: torch.Tensor, audio_frames: int) -> torch.Tensor:
568
+ unbatched = rows.ndim == 2
569
+ if unbatched:
570
+ rows = rows.unsqueeze(0)
571
+ expected_shape = (audio_frames * STEREO_CHANNELS, AUDIO_CHANNELS)
572
+ if rows.ndim != 3 or tuple(rows.shape[1:]) != expected_shape:
573
+ raise ValueError(f"MiniMax-H3 target audio rows must be [B,{expected_shape[0]},{expected_shape[1]}]")
574
+ batch = rows.shape[0]
575
+ unpacked = rows.reshape(batch, STEREO_CHANNELS, audio_frames, AUDIO_CHANNELS).permute(0, 3, 1, 2)
576
+ return unpacked[0] if unbatched else unpacked
577
+
578
+
579
+ def unpack_targets(
580
+ layout: H3PackedLayout,
581
+ target_video_rows: torch.Tensor,
582
+ target_audio_rows: torch.Tensor,
583
+ ) -> tuple[torch.Tensor, torch.Tensor]:
584
+ if target_video_rows.ndim != target_audio_rows.ndim or target_video_rows.ndim not in {2, 3}:
585
+ raise ValueError("MiniMax-H3 target video/audio rows must both be batched or both be unbatched")
586
+ if target_video_rows.ndim == 3 and target_video_rows.shape[0] != target_audio_rows.shape[0]:
587
+ raise ValueError("MiniMax-H3 target video/audio rows must have matching batch axes")
588
+ return (
589
+ _unpack_video_rows(target_video_rows, layout.target_video),
590
+ _unpack_audio_rows(target_audio_rows, layout.target_audio_frames),
591
+ )
musubi_tuner/minimax_h3/sampling.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 The MiniMax authors and The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Adapted for Musubi from Hugging Face Diffusers PR #14355 at commit
16
+ # abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc
17
+ # (schedulers/scheduling_minimax_h3.py and modular_pipelines/minimax_h3/denoise.py).
18
+ # ComfyUI is used only as an independent numerical reference.
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Sequence
23
+ from dataclasses import dataclass
24
+ from fractions import Fraction
25
+ from pathlib import Path
26
+
27
+ import av
28
+ import torch
29
+
30
+ from musubi_tuner.minimax_h3.packing import H3PackedLayout
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class H3SigmaSchedule:
35
+ base: torch.Tensor
36
+ video: torch.Tensor
37
+ audio: torch.Tensor
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class H3SampleResult:
42
+ video: torch.Tensor
43
+ audio: torch.Tensor
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class H3DecodedAV:
48
+ video: torch.Tensor
49
+ audio: torch.Tensor
50
+ fps: int = 24
51
+ sample_rate: int = 32000
52
+
53
+
54
+ def _validate_shift(value: float, label: str) -> float:
55
+ value = float(value)
56
+ if not 0.01 <= value <= 100.0:
57
+ raise ValueError(f"MiniMax-H3 {label} shift must be in [0.01,100.0], got {value}")
58
+ return value
59
+
60
+
61
+ def _shift(base: torch.Tensor, value: float) -> torch.Tensor:
62
+ return value * base / (1.0 + (value - 1.0) * base)
63
+
64
+
65
+ def build_shifted_schedule(
66
+ steps: int,
67
+ *,
68
+ video_shift: float = 12.0,
69
+ audio_shift: float = 3.0,
70
+ device: torch.device | str | None = None,
71
+ ) -> H3SigmaSchedule:
72
+ if not isinstance(steps, int) or steps <= 0:
73
+ raise ValueError(f"MiniMax-H3 sampling steps must be a positive integer, got {steps}")
74
+ video_shift = _validate_shift(video_shift, "video")
75
+ audio_shift = _validate_shift(audio_shift, "audio")
76
+ base = torch.linspace(1.0, 0.0, steps + 1, dtype=torch.float64, device=device)
77
+ return H3SigmaSchedule(base=base, video=_shift(base, video_shift), audio=_shift(base, audio_shift))
78
+
79
+
80
+ def initialize_target_latents(
81
+ *,
82
+ video_shape: Sequence[int],
83
+ audio_shape: Sequence[int],
84
+ seed: int,
85
+ device: torch.device | str,
86
+ video_dtype: torch.dtype = torch.float16,
87
+ audio_dtype: torch.dtype = torch.float32,
88
+ ) -> tuple[torch.Tensor, torch.Tensor]:
89
+ video_shape = tuple(int(value) for value in video_shape)
90
+ audio_shape = tuple(int(value) for value in audio_shape)
91
+ if len(video_shape) != 5 or video_shape[1] != 24:
92
+ raise ValueError(f"MiniMax-H3 target video noise shape must be [B,24,F,H,W], got {video_shape}")
93
+ if len(audio_shape) != 4 or audio_shape[1:3] != (32, 2) or audio_shape[0] != video_shape[0]:
94
+ raise ValueError(f"MiniMax-H3 target audio noise shape must be [B,32,2,A], got {audio_shape}")
95
+ generator = torch.Generator(device="cpu").manual_seed(int(seed))
96
+ video = torch.randn(video_shape, generator=generator, dtype=torch.float32, device="cpu").to(device=device, dtype=video_dtype)
97
+ audio = torch.randn(audio_shape, generator=generator, dtype=torch.float32, device="cpu").to(device=device, dtype=audio_dtype)
98
+ return video, audio
99
+
100
+
101
+ def _augment_condition_group(
102
+ tensors: Sequence[torch.Tensor],
103
+ *,
104
+ seed: int,
105
+ clean: float,
106
+ device: torch.device | str,
107
+ ) -> tuple[torch.Tensor, ...]:
108
+ moved = tuple(tensor.to(device) for tensor in tensors)
109
+ if clean == 1.0:
110
+ return moved
111
+ augmented = []
112
+ for tensor in moved:
113
+ generator = torch.Generator(device="cpu").manual_seed(int(seed))
114
+ noise = torch.randn(tuple(tensor.shape), generator=generator, dtype=torch.float32, device="cpu").to(
115
+ device=tensor.device, dtype=tensor.dtype
116
+ )
117
+ augmented.append(clean * tensor + (1.0 - clean) * noise)
118
+ return tuple(augmented)
119
+
120
+
121
+ def augment_condition_latents(
122
+ visual_conditions: Sequence[torch.Tensor],
123
+ audio_conditions: Sequence[torch.Tensor],
124
+ *,
125
+ seed: int,
126
+ visual_clean: float = 0.999,
127
+ audio_clean: float = 1.0,
128
+ device: torch.device | str,
129
+ ) -> tuple[tuple[torch.Tensor, ...], tuple[torch.Tensor, ...]]:
130
+ if not 0.0 <= visual_clean <= 1.0:
131
+ raise ValueError(f"MiniMax-H3 visual condition clean coefficient must be in [0,1], got {visual_clean}")
132
+ if not 0.0 <= audio_clean <= 1.0:
133
+ raise ValueError(f"MiniMax-H3 audio condition clean coefficient must be in [0,1], got {audio_clean}")
134
+ return (
135
+ _augment_condition_group(visual_conditions, seed=seed, clean=visual_clean, device=device),
136
+ _augment_condition_group(audio_conditions, seed=seed + 1, clean=audio_clean, device=device),
137
+ )
138
+
139
+
140
+ def _prepare_retake_latents(
141
+ input_latents: torch.Tensor | None,
142
+ denoise_mask: torch.Tensor | None,
143
+ target_latents: torch.Tensor,
144
+ *,
145
+ label: str,
146
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
147
+ if (input_latents is None) != (denoise_mask is None):
148
+ raise ValueError(f"MiniMax-H3 {label} retake requires both input latents and a denoise mask")
149
+ if input_latents is None:
150
+ return None, None
151
+ input_latents = torch.as_tensor(input_latents)
152
+ if input_latents.shape != target_latents.shape:
153
+ raise ValueError(
154
+ f"MiniMax-H3 {label} retake input shape {tuple(input_latents.shape)} "
155
+ f"does not match target {tuple(target_latents.shape)}"
156
+ )
157
+ input_latents = input_latents.to(device=target_latents.device, dtype=target_latents.dtype)
158
+ denoise_mask = torch.as_tensor(denoise_mask, device=target_latents.device)
159
+ if denoise_mask.is_complex():
160
+ raise ValueError(f"MiniMax-H3 {label} denoise mask must be real")
161
+ if denoise_mask.is_floating_point() and not torch.isfinite(denoise_mask).all():
162
+ raise ValueError(f"MiniMax-H3 {label} denoise mask must be finite")
163
+ if not torch.all((denoise_mask == 0) | (denoise_mask == 1)):
164
+ raise ValueError(f"MiniMax-H3 {label} denoise mask must contain only 0 (fixed) and 1 (regenerate)")
165
+ try:
166
+ denoise_mask = torch.broadcast_to(denoise_mask, target_latents.shape)
167
+ except RuntimeError as error:
168
+ raise ValueError(
169
+ f"MiniMax-H3 {label} denoise mask shape {tuple(denoise_mask.shape)} "
170
+ f"is not broadcastable to {tuple(target_latents.shape)}"
171
+ ) from error
172
+ return input_latents, denoise_mask.to(torch.bool)
173
+
174
+
175
+ @torch.no_grad()
176
+ def sample_joint_av(
177
+ transformer,
178
+ *,
179
+ layout: H3PackedLayout,
180
+ text_hidden_states: torch.Tensor,
181
+ text_token_tags: torch.Tensor,
182
+ initial_video: torch.Tensor,
183
+ initial_audio: torch.Tensor,
184
+ steps: int,
185
+ video_shift: float = 12.0,
186
+ audio_shift: float = 3.0,
187
+ visual_condition_latents: Sequence[torch.Tensor] = (),
188
+ audio_condition_latents: Sequence[torch.Tensor] = (),
189
+ visual_condition_clean: float = 0.999,
190
+ audio_condition_clean: float = 1.0,
191
+ input_video_latents: torch.Tensor | None = None,
192
+ video_denoise_mask: torch.Tensor | None = None,
193
+ input_audio_latents: torch.Tensor | None = None,
194
+ audio_denoise_mask: torch.Tensor | None = None,
195
+ step_callback: Callable[[int, int], None] | None = None,
196
+ ) -> H3SampleResult:
197
+ if initial_video.ndim != 5 or tuple(initial_video.shape[2:]) != (
198
+ layout.target_video.frames,
199
+ layout.target_video.height,
200
+ layout.target_video.width,
201
+ ):
202
+ raise ValueError("MiniMax-H3 initial video noise does not match the packed layout")
203
+ if initial_audio.ndim != 4 or tuple(initial_audio.shape[1:]) != (32, 2, layout.target_audio_frames):
204
+ raise ValueError("MiniMax-H3 initial audio noise does not match the packed layout")
205
+ if initial_video.shape[0] != initial_audio.shape[0]:
206
+ raise ValueError("MiniMax-H3 initial video and audio batch sizes differ")
207
+ if initial_video.shape[0] != 1:
208
+ raise ValueError(f"MiniMax-H3 R1 requires batch_size=1, got {initial_video.shape[0]}")
209
+ if text_hidden_states.shape[:2] != (initial_video.shape[0], layout.text_length):
210
+ raise ValueError("MiniMax-H3 text hidden states do not match the sampling layout")
211
+ if text_token_tags.shape != (initial_video.shape[0], layout.text_length):
212
+ raise ValueError("MiniMax-H3 text token tags must preserve the [B,L] axes")
213
+
214
+ input_video_latents, video_denoise_mask = _prepare_retake_latents(
215
+ input_video_latents,
216
+ video_denoise_mask,
217
+ initial_video,
218
+ label="video",
219
+ )
220
+ input_audio_latents, audio_denoise_mask = _prepare_retake_latents(
221
+ input_audio_latents,
222
+ audio_denoise_mask,
223
+ initial_audio,
224
+ label="audio",
225
+ )
226
+
227
+ schedule = build_shifted_schedule(
228
+ steps,
229
+ video_shift=video_shift,
230
+ audio_shift=audio_shift,
231
+ device=initial_video.device,
232
+ )
233
+ video = initial_video
234
+ audio = initial_audio
235
+ for index in range(steps):
236
+ sigma_video = schedule.video[index].to(dtype=torch.float32)
237
+ sigma_audio = schedule.audio[index].to(dtype=torch.float32)
238
+ model_video = (
239
+ video
240
+ if video_denoise_mask is None
241
+ else torch.where(video_denoise_mask, video, input_video_latents)
242
+ )
243
+ model_audio = (
244
+ audio
245
+ if audio_denoise_mask is None
246
+ else torch.where(audio_denoise_mask, audio, input_audio_latents)
247
+ )
248
+ retake_kwargs = {}
249
+ if video_denoise_mask is not None:
250
+ retake_kwargs["video_denoise_mask"] = video_denoise_mask
251
+ if audio_denoise_mask is not None:
252
+ retake_kwargs["audio_denoise_mask"] = audio_denoise_mask
253
+ prediction = transformer(
254
+ video_latents=model_video,
255
+ audio_latents=model_audio,
256
+ text_hidden_states=text_hidden_states,
257
+ text_token_tags=text_token_tags,
258
+ layout=layout,
259
+ model_t_video=1.0 - sigma_video,
260
+ model_t_audio=1.0 - sigma_audio,
261
+ visual_condition_latents=visual_condition_latents,
262
+ audio_condition_latents=audio_condition_latents,
263
+ visual_condition_clean=visual_condition_clean,
264
+ audio_condition_clean=audio_condition_clean,
265
+ **retake_kwargs,
266
+ )
267
+ if prediction.video.shape != video.shape or prediction.audio.shape != audio.shape:
268
+ raise ValueError("MiniMax-H3 transformer predictions do not match the target latent shapes")
269
+ prediction_video = prediction.video
270
+ prediction_audio = prediction.audio
271
+ if video_denoise_mask is not None:
272
+ fixed_prediction = (input_video_latents - video) / sigma_video.to(video)
273
+ prediction_video = torch.where(video_denoise_mask, prediction_video, fixed_prediction)
274
+ if audio_denoise_mask is not None:
275
+ fixed_prediction = (input_audio_latents - audio) / sigma_audio.to(audio)
276
+ prediction_audio = torch.where(audio_denoise_mask, prediction_audio, fixed_prediction)
277
+ video_delta = (schedule.video[index] - schedule.video[index + 1]).to(video)
278
+ audio_delta = (schedule.audio[index] - schedule.audio[index + 1]).to(audio)
279
+ video = video + video_delta * prediction_video
280
+ audio = audio + audio_delta * prediction_audio
281
+ if step_callback is not None:
282
+ step_callback(index + 1, steps)
283
+ return H3SampleResult(video=video, audio=audio)
284
+
285
+
286
+ @torch.no_grad()
287
+ def decode_joint_av(
288
+ video_vae,
289
+ audio_vae,
290
+ sample: H3SampleResult,
291
+ *,
292
+ frame_count: int,
293
+ fps: int = 24,
294
+ sample_rate: int = 32000,
295
+ ) -> H3DecodedAV:
296
+ decoded_video = video_vae.decode(sample.video)
297
+ decoded_audio = audio_vae.decode(sample.audio)
298
+ return synchronize_decoded_av(
299
+ decoded_video,
300
+ decoded_audio,
301
+ frame_count=frame_count,
302
+ fps=fps,
303
+ sample_rate=sample_rate,
304
+ )
305
+
306
+
307
+ def synchronize_decoded_av(
308
+ decoded_video: torch.Tensor,
309
+ decoded_audio: torch.Tensor,
310
+ *,
311
+ frame_count: int,
312
+ fps: int = 24,
313
+ sample_rate: int = 32000,
314
+ ) -> H3DecodedAV:
315
+ if frame_count <= 0 or fps <= 0 or sample_rate <= 0:
316
+ raise ValueError("MiniMax-H3 decode frame count, fps, and sample rate must be positive")
317
+ if decoded_video.ndim != 5 or decoded_video.shape[0] != 1 or decoded_video.shape[1] != 3:
318
+ raise ValueError(f"MiniMax-H3 video VAE must decode [1,3,F,H,W], got {tuple(decoded_video.shape)}")
319
+ if decoded_audio.ndim != 3 or decoded_audio.shape[:2] != (1, 2):
320
+ raise ValueError(f"MiniMax-H3 audio VAE must decode [1,2,L], got {tuple(decoded_audio.shape)}")
321
+
322
+ planned_audio_samples = round(Fraction(frame_count * sample_rate, fps))
323
+ video_audio_samples = round(Fraction(decoded_video.shape[2] * sample_rate, fps))
324
+ common_audio_samples = min(decoded_audio.shape[-1], planned_audio_samples, video_audio_samples)
325
+ if common_audio_samples <= 0:
326
+ raise ValueError("MiniMax-H3 decoded audio/video duration is empty")
327
+ common_video_frames = min(
328
+ frame_count,
329
+ decoded_video.shape[2],
330
+ max(1, round(Fraction(common_audio_samples * fps, sample_rate))),
331
+ )
332
+
333
+ decoded_video = decoded_video[0, :, :common_video_frames].detach().cpu()
334
+ video_chunks = []
335
+ for chunk in decoded_video.split(16, dim=1):
336
+ chunk = chunk.float().clamp(-1.0, 1.0)
337
+ video_chunks.append(((chunk + 1.0) * 127.5).round().to(torch.uint8).permute(1, 2, 3, 0))
338
+ video = torch.cat(video_chunks).contiguous()
339
+ audio = decoded_audio[0, :, :common_audio_samples].detach().cpu().float().clamp(-1.0, 1.0).contiguous()
340
+ return H3DecodedAV(video=video, audio=audio, fps=fps, sample_rate=sample_rate)
341
+
342
+
343
+ def mux_audio_video(
344
+ video: torch.Tensor,
345
+ audio: torch.Tensor,
346
+ output_path: str | Path,
347
+ *,
348
+ fps: int,
349
+ sample_rate: int,
350
+ ) -> None:
351
+ if video.ndim != 4 or video.shape[-1] != 3 or video.dtype != torch.uint8:
352
+ raise ValueError(f"MiniMax-H3 mux video must be uint8 [F,H,W,3], got {tuple(video.shape)} {video.dtype}")
353
+ if audio.ndim != 2 or audio.shape[0] != 2:
354
+ raise ValueError(f"MiniMax-H3 mux audio must be stereo [2,L], got {tuple(audio.shape)}")
355
+ output_path = Path(output_path)
356
+ output_path.parent.mkdir(parents=True, exist_ok=True)
357
+ with av.open(str(output_path), mode="w") as container:
358
+ video_stream = container.add_stream("libx264", rate=fps)
359
+ video_stream.width = video.shape[2]
360
+ video_stream.height = video.shape[1]
361
+ video_stream.pix_fmt = "yuv420p"
362
+ audio_stream = container.add_stream("aac", rate=sample_rate)
363
+ audio_stream.layout = "stereo"
364
+
365
+ for pixels in video:
366
+ frame = av.VideoFrame.from_ndarray(pixels.numpy(), format="rgb24")
367
+ for packet in video_stream.encode(frame):
368
+ container.mux(packet)
369
+ for packet in video_stream.encode():
370
+ container.mux(packet)
371
+
372
+ for start in range(0, audio.shape[1], 1024):
373
+ samples = audio[:, start : start + 1024].numpy()
374
+ frame = av.AudioFrame.from_ndarray(samples, format="fltp", layout="stereo")
375
+ frame.sample_rate = sample_rate
376
+ frame.pts = start
377
+ frame.time_base = Fraction(1, sample_rate)
378
+ for packet in audio_stream.encode(frame):
379
+ container.mux(packet)
380
+ for packet in audio_stream.encode():
381
+ container.mux(packet)
382
+
383
+
384
+ def write_joint_av(
385
+ decoded: H3DecodedAV,
386
+ output_path: str | Path,
387
+ *,
388
+ muxer: Callable[..., None] = mux_audio_video,
389
+ ) -> None:
390
+ muxer(
391
+ decoded.video,
392
+ decoded.audio,
393
+ Path(output_path),
394
+ fps=decoded.fps,
395
+ sample_rate=decoded.sample_rate,
396
+ )
musubi_tuner/minimax_h3/text_encoder.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 The MiniMax and HuggingFace Teams. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Adapted for Musubi from Hugging Face Diffusers PR #14355 at commit
16
+ # abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc
17
+ # (modular_pipelines/minimax_h3/encoders.py and packing_ref2va.py).
18
+ # ComfyUI is used only as an independent numerical reference.
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass
24
+ import hashlib
25
+ import json
26
+ from pathlib import Path
27
+ from types import SimpleNamespace
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ import torch
32
+
33
+ from musubi_tuner.minimax_h3.checkpoint import load_safetensors_module, resolve_safetensors_files
34
+ from musubi_tuner.minimax_h3.media import H3Record, H3Task
35
+
36
+
37
+ TEXT_WIDTH = 5120
38
+ MAX_TEXT_ROWS = 32768
39
+ LAYER_50_HIDDEN_STATE_INDEX = 50
40
+ VISION_START_TOKEN_ID = 151652
41
+ VISION_END_TOKEN_ID = 151653
42
+ IMAGE_TOKEN_ID = 151655
43
+ VIDEO_TOKEN_ID = 151656
44
+ IMAGE_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>"
45
+ VIDEO_PLACEHOLDER = "<|vision_start|><|video_pad|><|vision_end|>"
46
+ DEFAULT_PROCESSOR_ID = "Qwen/Qwen3-VL-32B-Instruct"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class H3TextVisual:
51
+ frames: torch.Tensor
52
+ timestamps: tuple[float, ...] | None = None
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.frames.ndim != 4 or self.frames.shape[-1] < 3 or self.frames.shape[0] == 0:
56
+ raise ValueError(f"MiniMax-H3 text visual must be [T,H,W,C], got {tuple(self.frames.shape)}")
57
+ if self.timestamps is not None and len(self.timestamps) != self.frames.shape[0]:
58
+ raise ValueError("MiniMax-H3 text visual timestamps must match its frame count")
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class H3Presentation:
63
+ text: str
64
+ images: tuple[torch.Tensor, ...] = ()
65
+ videos: tuple[torch.Tensor, ...] = ()
66
+ processor_text: str | None = None
67
+
68
+
69
+ def _require_visual(visuals: Mapping[object, H3TextVisual], key: object, label: str) -> H3TextVisual:
70
+ try:
71
+ return visuals[key]
72
+ except KeyError as error:
73
+ raise ValueError(f"MiniMax-H3 presentation is missing {label} visual data") from error
74
+
75
+
76
+ def build_presentation(
77
+ record: H3Record,
78
+ task: H3Task,
79
+ visuals: Mapping[object, H3TextVisual] | None = None,
80
+ ) -> H3Presentation:
81
+ if task not in {"t2va", "fl2va", "ref2va"}:
82
+ raise ValueError(f"Unsupported MiniMax-H3 task: {task}")
83
+ visuals = visuals or {}
84
+ if task != "ref2va" and record.references:
85
+ raise ValueError(f"MiniMax-H3 task {task} does not accept references")
86
+ if task == "t2va":
87
+ return H3Presentation(text=record.caption, processor_text=record.caption)
88
+
89
+ parts = []
90
+ processor_parts = []
91
+ images = []
92
+ videos = []
93
+ if task == "fl2va":
94
+ for index, key in enumerate(("first", "last"), start=1):
95
+ visual = _require_visual(visuals, key, f"FL2VA {key}")
96
+ if visual.frames.shape[0] != 1:
97
+ raise ValueError(f"MiniMax-H3 FL2VA {key} visual must contain exactly one frame")
98
+ part = f"<Picture {index}>: {IMAGE_PLACEHOLDER}"
99
+ parts.append(part)
100
+ processor_parts.append(part)
101
+ images.append(visual.frames[0])
102
+ parts.append(record.caption)
103
+ processor_parts.append(record.caption)
104
+ return H3Presentation(
105
+ text="".join(parts),
106
+ images=tuple(images),
107
+ processor_text="".join(processor_parts),
108
+ )
109
+
110
+ counters = {"image": 0, "audio": 0, "video": 0}
111
+ for reference in record.references:
112
+ if reference.type == "image":
113
+ counters["image"] += 1
114
+ visual = _require_visual(visuals, reference.path, f"reference image {reference.path}")
115
+ if visual.frames.shape[0] != 1:
116
+ raise ValueError(f"MiniMax-H3 reference image must contain one frame: {reference.path}")
117
+ part = f"<Picture {counters['image']}>: {IMAGE_PLACEHOLDER}"
118
+ parts.append(part)
119
+ processor_parts.append(part)
120
+ images.append(visual.frames[0])
121
+ continue
122
+
123
+ if reference.type == "audio":
124
+ counters["audio"] += 1
125
+ part = f"<Audio {counters['audio']}>: "
126
+ parts.append(part)
127
+ processor_parts.append(part)
128
+ continue
129
+
130
+ if reference.type != "video":
131
+ raise ValueError(f"Unsupported MiniMax-H3 reference type: {reference.type}")
132
+ if reference.audio is not None:
133
+ counters["audio"] += 1
134
+ part = f"<Audio {counters['audio']}>: "
135
+ parts.append(part)
136
+ processor_parts.append(part)
137
+ counters["video"] += 1
138
+ part = f"<Video {counters['video']}>: "
139
+ parts.append(part)
140
+ processor_parts.append(part)
141
+ processor_parts.append(VIDEO_PLACEHOLDER)
142
+
143
+ visual = _require_visual(visuals, reference.path, f"reference video {reference.path}")
144
+ frames = visual.frames
145
+ timestamps = list(visual.timestamps) if visual.timestamps is not None else [index / 2.0 for index in range(len(frames))]
146
+ if len(frames) % 2:
147
+ frames = torch.cat((frames, frames[-1:]), dim=0)
148
+ timestamps.append(timestamps[-1])
149
+ for index in range(0, len(frames), 2):
150
+ block_timestamp = (timestamps[index] + timestamps[index + 1]) / 2.0
151
+ parts.append(f"<{block_timestamp:.1f} seconds>{VIDEO_PLACEHOLDER}")
152
+ videos.append(visual.frames)
153
+
154
+ parts.append(record.caption)
155
+ processor_parts.append(record.caption)
156
+ return H3Presentation(
157
+ text="".join(parts),
158
+ images=tuple(images),
159
+ videos=tuple(videos),
160
+ processor_text="".join(processor_parts),
161
+ )
162
+
163
+
164
+ def build_token_tags(processed: Mapping[str, Any]) -> torch.Tensor:
165
+ if "input_ids" not in processed:
166
+ raise ValueError("MiniMax-H3 processor output has no input_ids")
167
+ input_ids = torch.as_tensor(processed["input_ids"])
168
+ if input_ids.ndim == 2:
169
+ if input_ids.shape[0] != 1:
170
+ raise ValueError("MiniMax-H3 text caching processes one presentation at a time")
171
+ input_ids = input_ids[0]
172
+ if input_ids.ndim != 1:
173
+ raise ValueError(f"MiniMax-H3 input_ids must be [L] or [1,L], got {tuple(input_ids.shape)}")
174
+
175
+ tags = torch.ones(input_ids.shape[0], dtype=torch.int64, device=input_ids.device)
176
+ open_start = None
177
+ for index, token_id in enumerate(input_ids.tolist()):
178
+ if token_id == VISION_START_TOKEN_ID:
179
+ if open_start is not None:
180
+ raise ValueError("MiniMax-H3 processor produced nested vision-start tokens")
181
+ open_start = index
182
+ elif token_id == VISION_END_TOKEN_ID:
183
+ if open_start is None:
184
+ raise ValueError("MiniMax-H3 processor produced an unmatched vision-end token")
185
+ interior = input_ids[open_start + 1 : index]
186
+ if not torch.any((interior == IMAGE_TOKEN_ID) | (interior == VIDEO_TOKEN_ID)):
187
+ raise ValueError("MiniMax-H3 vision span contains no expanded image or video rows")
188
+ tags[open_start : index + 1] = 0
189
+ open_start = None
190
+ if open_start is not None:
191
+ raise ValueError("MiniMax-H3 processor produced an unmatched vision-start token")
192
+ return tags.cpu()
193
+
194
+
195
+ def _text_size_error(row_count: int, token_tags: torch.Tensor) -> ValueError:
196
+ vision_rows = int((token_tags == 0).sum().item())
197
+ text_rows = row_count - vision_rows
198
+ payload_mib = row_count * TEXT_WIDTH * 2 / (1024**2)
199
+ return ValueError(
200
+ f"MiniMax-H3 text presentation has {row_count} rows, exceeds {MAX_TEXT_ROWS}; "
201
+ f"vision_rows={vision_rows}, text_rows={text_rows}, estimated BF16 payload={payload_mib:.1f} MiB"
202
+ )
203
+
204
+
205
+ def validate_text_rows(hidden_states: torch.Tensor, token_tags: torch.Tensor) -> None:
206
+ if hidden_states.ndim != 2 or hidden_states.shape[1] != TEXT_WIDTH:
207
+ raise ValueError(f"Expected MiniMax-H3 hidden states [L,{TEXT_WIDTH}], got {tuple(hidden_states.shape)}")
208
+ if token_tags.dtype != torch.int64 or token_tags.shape != (hidden_states.shape[0],):
209
+ raise ValueError("MiniMax-H3 token tags must be int64 [L]")
210
+ if hidden_states.shape[0] > MAX_TEXT_ROWS:
211
+ raise _text_size_error(hidden_states.shape[0], token_tags)
212
+ if not torch.all((token_tags == 0) | (token_tags == 1)):
213
+ raise ValueError("MiniMax-H3 token tags may contain only 0 and 1")
214
+
215
+
216
+ def _language_layers(model) -> list:
217
+ language_model = getattr(model, "language_model", None)
218
+ layers = getattr(language_model, "layers", None)
219
+ if layers is None:
220
+ raise ValueError("MiniMax-H3 Qwen3-VL model has no language_model.layers")
221
+ return layers
222
+
223
+
224
+ def extract_layer_50_pre_norm(output, model) -> torch.Tensor:
225
+ layers = _language_layers(model)
226
+ if len(layers) < LAYER_50_HIDDEN_STATE_INDEX:
227
+ raise ValueError(f"MiniMax-H3 Qwen3-VL needs at least 50 layers, got {len(layers)}")
228
+
229
+ captured = getattr(output, "h3_layer_50_pre_norm", None)
230
+ if captured is None:
231
+ captured = getattr(model, "_h3_layer_50_pre_norm", None)
232
+ if captured is not None:
233
+ return captured
234
+
235
+ hidden_states = getattr(output, "hidden_states", None)
236
+ if hidden_states is None or len(hidden_states) <= LAYER_50_HIDDEN_STATE_INDEX:
237
+ raise ValueError("MiniMax-H3 layer-50 pre-norm state was not captured")
238
+ return hidden_states[LAYER_50_HIDDEN_STATE_INDEX]
239
+
240
+
241
+ def normalize_h3_text_encoder_key(key: str) -> str:
242
+ if key.startswith("model."):
243
+ return "language_model." + key.removeprefix("model.")
244
+ return key
245
+
246
+
247
+ def load_h3_processor(path: str = DEFAULT_PROCESSOR_ID, *, revision: str | None = None):
248
+ from transformers import AutoProcessor
249
+
250
+ return AutoProcessor.from_pretrained(path, revision=revision)
251
+
252
+
253
+ def load_h3_text_encoder(
254
+ checkpoint_path: str | Path,
255
+ *,
256
+ processor_path: str = DEFAULT_PROCESSOR_ID,
257
+ revision: str | None = None,
258
+ device: str | torch.device,
259
+ dtype: torch.dtype = torch.bfloat16,
260
+ disable_mmap: bool = False,
261
+ ):
262
+ from transformers import Qwen3VLConfig, Qwen3VLModel
263
+
264
+ config = Qwen3VLConfig.from_pretrained(processor_path, revision=revision)
265
+ if config.text_config.hidden_size != TEXT_WIDTH:
266
+ raise ValueError(f"MiniMax-H3 Qwen3-VL hidden size must be {TEXT_WIDTH}, got {config.text_config.hidden_size}")
267
+ config.text_config.num_hidden_layers = LAYER_50_HIDDEN_STATE_INDEX
268
+ config.text_config.use_cache = False
269
+
270
+ def factory():
271
+ model = Qwen3VLModel(config)
272
+ del model.language_model.norm
273
+ return model
274
+
275
+ return load_safetensors_module(
276
+ factory,
277
+ resolve_safetensors_files(checkpoint_path),
278
+ device=device,
279
+ dtype=dtype,
280
+ key_transform=normalize_h3_text_encoder_key,
281
+ disable_mmap=disable_mmap,
282
+ )
283
+
284
+
285
+ class _Layer50Captured(Exception):
286
+ pass
287
+
288
+
289
+ def _processor_value(value):
290
+ if isinstance(value, torch.Tensor):
291
+ return value.detach().cpu().numpy()
292
+ return np.asarray(value)
293
+
294
+
295
+ @torch.no_grad()
296
+ def encode_h3_presentation(processor, model, presentation: H3Presentation) -> tuple[torch.Tensor, torch.Tensor]:
297
+ processor_args = {
298
+ "text": [presentation.processor_text or presentation.text],
299
+ "return_tensors": "pt",
300
+ "padding": False,
301
+ }
302
+ if presentation.images:
303
+ processor_args["images"] = [_processor_value(image) for image in presentation.images]
304
+ if presentation.videos:
305
+ processor_args["videos"] = [_processor_value(video) for video in presentation.videos]
306
+ processor_args["video_metadata"] = [
307
+ {
308
+ "total_num_frames": int(video.shape[0]),
309
+ "fps": 2.0,
310
+ "duration": float(video.shape[0]) / 2.0,
311
+ "frames_indices": list(range(video.shape[0])),
312
+ "height": int(video.shape[1]),
313
+ "width": int(video.shape[2]),
314
+ }
315
+ for video in presentation.videos
316
+ ]
317
+ processor_args["do_sample_frames"] = False
318
+ processed = processor(**processor_args)
319
+ token_tags = build_token_tags(processed)
320
+ row_count = token_tags.shape[0]
321
+ if row_count > MAX_TEXT_ROWS:
322
+ raise _text_size_error(row_count, token_tags)
323
+
324
+ layers = _language_layers(model)
325
+ if len(layers) != LAYER_50_HIDDEN_STATE_INDEX:
326
+ raise ValueError(f"Released MiniMax-H3 text encoder must contain exactly 50 layers, got {len(layers)}")
327
+ captured = {}
328
+
329
+ def capture_layer_50(module, args, output):
330
+ del module, args
331
+ captured["hidden"] = output[0] if isinstance(output, tuple) else output
332
+ raise _Layer50Captured
333
+
334
+ handle = layers[LAYER_50_HIDDEN_STATE_INDEX - 1].register_forward_hook(capture_layer_50)
335
+ device = next(model.parameters()).device
336
+ model_inputs = {
337
+ key: value.to(device) if isinstance(value, torch.Tensor) else value
338
+ for key, value in processed.items()
339
+ if key != "token_type_ids"
340
+ }
341
+ try:
342
+ model(**model_inputs, use_cache=False)
343
+ except _Layer50Captured:
344
+ pass
345
+ finally:
346
+ handle.remove()
347
+ if "hidden" not in captured:
348
+ raise RuntimeError("MiniMax-H3 text encoder did not reach layer 50")
349
+
350
+ output = SimpleNamespace(h3_layer_50_pre_norm=captured["hidden"], hidden_states=None, last_hidden_state=None)
351
+ hidden_states = extract_layer_50_pre_norm(output, model)
352
+ if hidden_states.ndim != 3 or hidden_states.shape[0] != 1:
353
+ raise ValueError(f"MiniMax-H3 layer-50 output must be [1,L,{TEXT_WIDTH}], got {tuple(hidden_states.shape)}")
354
+ hidden_states = hidden_states[0]
355
+ validate_text_rows(hidden_states, token_tags)
356
+ return hidden_states, token_tags
357
+
358
+
359
+ def processor_fingerprint(processor) -> str:
360
+ tokenizer = getattr(processor, "tokenizer", None)
361
+ payload = {
362
+ "processor_class": type(processor).__name__,
363
+ "processor_name": getattr(processor, "name_or_path", None),
364
+ "tokenizer_class": type(tokenizer).__name__ if tokenizer is not None else None,
365
+ "tokenizer_name": getattr(tokenizer, "name_or_path", None),
366
+ "tokenizer_size": len(tokenizer) if tokenizer is not None else None,
367
+ "commit": getattr(processor, "_commit_hash", None) or (getattr(tokenizer, "init_kwargs", {}) or {}).get("_commit_hash"),
368
+ "vision_tokens": [VISION_START_TOKEN_ID, VISION_END_TOKEN_ID, IMAGE_TOKEN_ID, VIDEO_TOKEN_ID],
369
+ }
370
+ encoded = json.dumps(payload, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
371
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
372
+
373
+
374
+ def presentation_fingerprint(presentation: H3Presentation, media_fingerprints: Mapping[Path, str]) -> str:
375
+ payload = {
376
+ "text": presentation.text,
377
+ "processor_text": presentation.processor_text,
378
+ "image_shapes": [list(image.shape) for image in presentation.images],
379
+ "video_shapes": [list(video.shape) for video in presentation.videos],
380
+ "media": dict(sorted((str(Path(path).resolve()), value) for path, value in media_fingerprints.items())),
381
+ "format": "minimax-h3-non-chat-v1",
382
+ }
383
+ encoded = json.dumps(payload, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
384
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
musubi_tuner/minimax_h3/video_vae.py ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 The MiniMax and HuggingFace Teams. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # Adapted for Musubi from Hugging Face Diffusers PR #14355 at commit
16
+ # abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc. Musubi keeps the published
17
+ # checkpoint names and adds cache-specific posterior sampling wrappers.
18
+ # ComfyUI is used only as an independent numerical reference.
19
+
20
+ import hashlib
21
+ import math
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+
27
+
28
+ IMAGENET_MEAN = (0.485, 0.456, 0.406)
29
+ IMAGENET_STD = (0.229, 0.224, 0.225)
30
+
31
+ LATENTS_MEAN = [
32
+ 0.858090341091156,
33
+ -0.9606591463088989,
34
+ 1.0661640167236328,
35
+ -0.5090325474739075,
36
+ -0.2727581858634949,
37
+ -1.3675414323806763,
38
+ -0.2553254961967468,
39
+ -0.26907554268836975,
40
+ -0.5376840829849243,
41
+ -0.0464097298681736,
42
+ 0.6657370328903198,
43
+ 0.19690127670764923,
44
+ -0.5460608005523682,
45
+ -0.4035342037677765,
46
+ -0.23683024942874908,
47
+ 0.25928452610969543,
48
+ -0.30133944749832153,
49
+ 0.211341992020607,
50
+ -1.1206848621368408,
51
+ 0.3581933379173279,
52
+ -0.04225143790245056,
53
+ 0.2604829967021942,
54
+ 0.22864092886447906,
55
+ 0.7056031823158264,
56
+ ]
57
+
58
+ LATENTS_STD = [
59
+ 1.2223774194717407,
60
+ 1.2767263650894165,
61
+ 1.68317747116088865,
62
+ 1.7549455165863037,
63
+ 1.5636216402053833,
64
+ 2.194143533706665,
65
+ 0.96531379222869875,
66
+ 1.05698859691619875,
67
+ 0.841948926448822,
68
+ 0.7729952931404114,
69
+ 1.8955937623977661,
70
+ 0.946841835975647,
71
+ 0.7996809482574463,
72
+ 0.44988900423049925,
73
+ 0.7197399735450745,
74
+ 0.69362932443618775,
75
+ 2.961095094680786,
76
+ 2.7694199085235595,
77
+ 3.0496184825897215,
78
+ 2.1088054180145265,
79
+ 3.276226282119751,
80
+ 3.1627357006073,
81
+ 2.28168129920959475,
82
+ 2.6127843856811525,
83
+ ]
84
+
85
+
86
+ class CausalConv3d(nn.Conv3d):
87
+ """Spatially reflected, temporally causal 3D convolution."""
88
+
89
+ def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
90
+ super().__init__(in_channels, out_channels, kernel_size=kernel_size, stride=stride, padding=0)
91
+ self.causal_padding = (padding,) * 3 if isinstance(padding, int) else tuple(padding)
92
+
93
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
94
+ temporal, height, width = self.causal_padding
95
+ if height or width:
96
+ hidden_states = F.pad(hidden_states, (width, width, height, height, 0, 0), mode="reflect")
97
+ if temporal:
98
+ hidden_states = F.pad(hidden_states, (0, 0, 0, 0, temporal * 2, 0), mode="constant")
99
+ return F.conv3d(
100
+ hidden_states,
101
+ self.weight,
102
+ self.bias,
103
+ stride=self.stride,
104
+ padding=0,
105
+ dilation=self.dilation,
106
+ groups=self.groups,
107
+ )
108
+
109
+
110
+ class TemporalIsolatedGroupNorm(nn.GroupNorm):
111
+ """Compute group-normalization statistics independently for each frame."""
112
+
113
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
114
+ if hidden_states.ndim != 5:
115
+ return super().forward(hidden_states)
116
+ batch_size, channels, frames, height, width = hidden_states.shape
117
+ hidden_states = hidden_states.permute(0, 2, 1, 3, 4).contiguous()
118
+ hidden_states = hidden_states.view(batch_size * frames, channels, 1, height, width)
119
+ hidden_states = super().forward(hidden_states)
120
+ hidden_states = hidden_states.view(batch_size, frames, channels, height, width)
121
+ return hidden_states.permute(0, 2, 1, 3, 4).contiguous()
122
+
123
+
124
+ def group_norm_3d(num_channels: int) -> TemporalIsolatedGroupNorm:
125
+ return TemporalIsolatedGroupNorm(32, num_channels, eps=1e-6, affine=True)
126
+
127
+
128
+ class Downsample3D(nn.Module):
129
+ def __init__(self, in_channels, out_channels, time_stride=1, space_stride=2):
130
+ super().__init__()
131
+ self.space_stride = space_stride
132
+ self.conv = CausalConv3d(
133
+ in_channels,
134
+ out_channels,
135
+ kernel_size=3,
136
+ stride=(time_stride, space_stride, space_stride),
137
+ padding=(1, 0, 0),
138
+ )
139
+
140
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
141
+ if self.space_stride == 2:
142
+ hidden_states = F.pad(hidden_states, (0, 1, 0, 1, 0, 0), mode="reflect")
143
+ return self.conv(hidden_states)
144
+
145
+
146
+ class ResnetBlock3D(nn.Module):
147
+ def __init__(self, in_channels, out_channels=None):
148
+ super().__init__()
149
+ self.in_channels = in_channels
150
+ self.out_channels = in_channels if out_channels is None else out_channels
151
+ self.norm1 = group_norm_3d(in_channels)
152
+ self.conv1 = CausalConv3d(in_channels, self.out_channels, kernel_size=3, padding=1)
153
+ self.norm2 = group_norm_3d(self.out_channels)
154
+ self.conv2 = CausalConv3d(self.out_channels, self.out_channels, kernel_size=3, padding=1)
155
+ self.nin_shortcut = (
156
+ CausalConv3d(in_channels, self.out_channels, kernel_size=1) if in_channels != self.out_channels else None
157
+ )
158
+
159
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
160
+ residual = hidden_states
161
+ hidden_states = self.conv1(F.silu(self.norm1(hidden_states)))
162
+ hidden_states = self.conv2(F.silu(self.norm2(hidden_states)))
163
+ if self.nin_shortcut is not None:
164
+ residual = self.nin_shortcut(residual)
165
+ return residual + hidden_states
166
+
167
+
168
+ class EncoderFCN3D(nn.Module):
169
+ def __init__(self, ch, ch_mult, space_down, time_down, num_res_blocks, in_channels, z_channels, double_z=True):
170
+ super().__init__()
171
+ num_levels = len(ch_mult)
172
+ layers_per_level = [num_res_blocks] * num_levels if isinstance(num_res_blocks, int) else list(num_res_blocks)
173
+ block_channels = [ch * multiplier for multiplier in ch_mult]
174
+ input_channels = [block_channels[0], *block_channels[:-1]]
175
+
176
+ self.num_levels = num_levels
177
+ self.num_res_blocks = layers_per_level
178
+ self.conv_in = CausalConv3d(in_channels, input_channels[0], kernel_size=3, padding=1)
179
+ self.down = nn.ModuleList()
180
+ for level in range(num_levels):
181
+ down = nn.Module()
182
+ down.block = nn.ModuleList(
183
+ [
184
+ ResnetBlock3D(
185
+ input_channels[level] if layer == 0 else block_channels[level],
186
+ block_channels[level],
187
+ )
188
+ for layer in range(layers_per_level[level])
189
+ ]
190
+ )
191
+ if space_down[level] * time_down[level] > 1:
192
+ down.downsample = Downsample3D(
193
+ block_channels[level],
194
+ block_channels[level],
195
+ time_stride=time_down[level],
196
+ space_stride=space_down[level],
197
+ )
198
+ self.down.append(down)
199
+
200
+ self.norm_out = group_norm_3d(block_channels[-1])
201
+ output_channels = 2 * z_channels if double_z else z_channels
202
+ self.conv_out = CausalConv3d(block_channels[-1], output_channels, kernel_size=3, padding=1)
203
+
204
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
205
+ hidden_states = self.conv_in(hidden_states)
206
+ for down in self.down:
207
+ for block in down.block:
208
+ hidden_states = block(hidden_states)
209
+ if hasattr(down, "downsample"):
210
+ hidden_states = down.downsample(hidden_states)
211
+ hidden_states = F.silu(self.norm_out(hidden_states))
212
+ return self.conv_out(hidden_states)
213
+
214
+
215
+ def create_token_ids(patch_dims, device, dtype=torch.float32):
216
+ grids = [2.0 * (torch.arange(0.5, size, dtype=dtype, device=device) / size) - 1.0 for size in patch_dims]
217
+ return torch.stack(torch.meshgrid(*grids, indexing="ij"), dim=-1).flatten(0, len(patch_dims) - 1).unsqueeze(0)
218
+
219
+
220
+ class RotaryEmbeddingND(nn.Module):
221
+ def __init__(self, dim, rotary_base=100.0, n_dim=3):
222
+ super().__init__()
223
+ if dim % (2 * n_dim):
224
+ raise ValueError(f"Rotary dimension {dim} must be divisible by {2 * n_dim}")
225
+ inv_freq = 1.0 / rotary_base ** torch.arange(0, 1, 2 * n_dim / dim, dtype=torch.float32)
226
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
227
+
228
+ def forward(self, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
229
+ angles = 2.0 * math.pi * position_ids[:, :, :, None].float() * self.inv_freq[None, None, None, :]
230
+ angles = angles.flatten(2, 3).tile(2).unsqueeze(2)
231
+ return angles.cos(), angles.sin()
232
+
233
+
234
+ def _apply_rotary_emb(hidden_states: torch.Tensor, rotary_emb: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor:
235
+ cosine, sine = (value.to(hidden_states.dtype) for value in rotary_emb)
236
+ rotary_dim = cosine.shape[-1]
237
+ rotary, passthrough = hidden_states[..., :rotary_dim], hidden_states[..., rotary_dim:]
238
+ first, second = rotary.chunk(2, dim=-1)
239
+ rotated = torch.cat((-second, first), dim=-1)
240
+ return torch.cat((rotary * cosine + rotated * sine, passthrough), dim=-1)
241
+
242
+
243
+ class FeedForward(nn.Module):
244
+ def __init__(self, dim, mult=4, bias=True):
245
+ super().__init__()
246
+ inner_dim = dim * mult
247
+ self.w1 = nn.Linear(dim, inner_dim * 2, bias=bias)
248
+ self.w2 = nn.Linear(inner_dim, dim, bias=bias)
249
+
250
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
251
+ gate, value = self.w1(hidden_states).chunk(2, dim=-1)
252
+ return self.w2(F.silu(gate) * value)
253
+
254
+
255
+ class Attention(nn.Module):
256
+ def __init__(self, heads, dim_head, bias=True, eps=1e-5):
257
+ super().__init__()
258
+ self.dim_head = dim_head
259
+ self.heads = heads
260
+ inner_dim = heads * dim_head
261
+ self.norm_q = nn.RMSNorm(dim_head, eps=eps, elementwise_affine=False)
262
+ self.norm_k = nn.RMSNorm(dim_head, eps=eps, elementwise_affine=False)
263
+ self.to_qkv = nn.Linear(inner_dim, inner_dim * 3, bias=bias)
264
+ self.to_out = nn.Linear(inner_dim, inner_dim, bias=bias)
265
+
266
+ def forward(
267
+ self,
268
+ hidden_states: torch.Tensor,
269
+ rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None,
270
+ ) -> torch.Tensor:
271
+ batch_size, sequence_length, _ = hidden_states.shape
272
+ qkv = self.to_qkv(hidden_states).view(batch_size, sequence_length, self.heads, 3 * self.dim_head)
273
+ query, key, value = qkv.chunk(3, dim=-1)
274
+ query = self.norm_q(query.float()).to(query.dtype)
275
+ key = self.norm_k(key.float()).to(key.dtype)
276
+ if rotary_emb is not None:
277
+ query = _apply_rotary_emb(query, rotary_emb)
278
+ key = _apply_rotary_emb(key, rotary_emb)
279
+ hidden_states = F.scaled_dot_product_attention(
280
+ query.transpose(1, 2),
281
+ key.transpose(1, 2),
282
+ value.transpose(1, 2),
283
+ )
284
+ hidden_states = hidden_states.transpose(1, 2).reshape(batch_size, sequence_length, -1)
285
+ return self.to_out(hidden_states)
286
+
287
+
288
+ class TransformerBlock(nn.Module):
289
+ def __init__(self, heads, dim_head, bias=True, eps=1e-5):
290
+ super().__init__()
291
+ dim = heads * dim_head
292
+ self.norm1 = nn.RMSNorm(dim, elementwise_affine=True, eps=eps)
293
+ self.attn = Attention(heads=heads, dim_head=dim_head, bias=bias, eps=eps)
294
+ self.scale1 = nn.Parameter(torch.empty(dim))
295
+ self.norm2 = nn.RMSNorm(dim, elementwise_affine=True, eps=eps)
296
+ self.ff = FeedForward(dim=dim, bias=bias)
297
+ self.scale2 = nn.Parameter(torch.empty(dim))
298
+
299
+ def forward(
300
+ self,
301
+ hidden_states: torch.Tensor,
302
+ rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None,
303
+ ) -> torch.Tensor:
304
+ normalized = F.rms_norm(
305
+ hidden_states.float(),
306
+ (hidden_states.shape[-1],),
307
+ self.norm1.weight.float(),
308
+ self.norm1.eps,
309
+ ).to(hidden_states.dtype)
310
+ hidden_states = hidden_states + self.attn(normalized, rotary_emb) * self.scale1
311
+ normalized = F.rms_norm(
312
+ hidden_states.float(),
313
+ (hidden_states.shape[-1],),
314
+ self.norm2.weight.float(),
315
+ self.norm2.eps,
316
+ ).to(hidden_states.dtype)
317
+ return hidden_states + self.ff(normalized) * self.scale2
318
+
319
+
320
+ class ViT3DDecoder(nn.Module):
321
+ def __init__(
322
+ self,
323
+ patch_size=16,
324
+ patch_size_t=4,
325
+ in_channels=24,
326
+ out_channels=3,
327
+ num_layers=36,
328
+ heads=32,
329
+ dim_head=64,
330
+ rope_theta=100.0,
331
+ rope_dim_ratio=0.75,
332
+ bias=True,
333
+ eps=1e-5,
334
+ num_register_tokens=4,
335
+ ):
336
+ super().__init__()
337
+ dim = heads * dim_head
338
+ self.patch_size = patch_size
339
+ self.patch_size_t = patch_size_t
340
+ self.out_channels = out_channels
341
+ self.num_register_tokens = num_register_tokens
342
+ self.pos_embed = RotaryEmbeddingND(int(dim_head * rope_dim_ratio), rope_theta, n_dim=3)
343
+ self.x_embedder = nn.Linear(in_channels, dim)
344
+ self.register_tokens = nn.Parameter(torch.empty(1, num_register_tokens, dim))
345
+ self.register_buffer("mask_token", torch.empty(1, 1, dim))
346
+ self.transformer_blocks = nn.ModuleList(
347
+ [TransformerBlock(heads=heads, dim_head=dim_head, bias=bias, eps=eps) for _ in range(num_layers)]
348
+ )
349
+ self.norm_out = nn.LayerNorm(dim, elementwise_affine=True, eps=eps)
350
+ self.proj_out = nn.Linear(dim, out_channels * patch_size_t * patch_size * patch_size)
351
+
352
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
353
+ batch_size, channels, frames, height, width = hidden_states.shape
354
+ hidden_states = hidden_states.permute(0, 2, 3, 4, 1).reshape(batch_size, frames * height * width, channels)
355
+ hidden_states = self.x_embedder(hidden_states)
356
+ num_patches = hidden_states.shape[1]
357
+ hidden_states = torch.cat(
358
+ (
359
+ hidden_states,
360
+ self.register_tokens.expand(batch_size, -1, -1),
361
+ torch.zeros_like(hidden_states[:, :1]),
362
+ ),
363
+ dim=1,
364
+ )
365
+
366
+ position_ids = create_token_ids((frames, height, width), hidden_states.device)
367
+ position_ids = position_ids.expand(batch_size, -1, -1)
368
+ suffix_ids = position_ids.new_zeros((batch_size, self.num_register_tokens + 1, 3))
369
+ rotary_emb = self.pos_embed(torch.cat((position_ids, suffix_ids), dim=1))
370
+ for block in self.transformer_blocks:
371
+ hidden_states = block(hidden_states, rotary_emb)
372
+
373
+ hidden_states = self.proj_out(self.norm_out(hidden_states))[:, :num_patches]
374
+ hidden_states = hidden_states.view(
375
+ batch_size,
376
+ frames,
377
+ height,
378
+ width,
379
+ self.out_channels,
380
+ self.patch_size_t,
381
+ self.patch_size,
382
+ self.patch_size,
383
+ )
384
+ hidden_states = hidden_states.permute(0, 4, 1, 5, 2, 6, 3, 7).contiguous()
385
+ return hidden_states.reshape(
386
+ batch_size,
387
+ self.out_channels,
388
+ frames * self.patch_size_t,
389
+ height * self.patch_size,
390
+ width * self.patch_size,
391
+ )
392
+
393
+
394
+ class MiniMaxH3VideoVAE(nn.Module):
395
+ def __init__(
396
+ self,
397
+ in_channels=3,
398
+ out_ch=3,
399
+ ch=128,
400
+ embed_dim=24,
401
+ z_channels=24,
402
+ ch_mult=(1, 2, 2, 4, 4, 8),
403
+ num_res_blocks=2,
404
+ space_down=(2, 2, 2, 2, 1, 1),
405
+ time_down=(1, 2, 2, 1, 1, 1),
406
+ clip_length=17,
407
+ token_drop=3,
408
+ tile_size=256,
409
+ tile_overlap_min=64,
410
+ tiling=True,
411
+ ):
412
+ super().__init__()
413
+ self.vae_ratio = math.prod(space_down)
414
+ self.vae_ratio_t = math.prod(time_down)
415
+ self.clip_length = clip_length
416
+ self.token_drop = token_drop
417
+ self.frame_pre_padding = (-clip_length) % self.vae_ratio_t
418
+ self.tokens_chunk_size = math.ceil(clip_length / self.vae_ratio_t)
419
+ self.token_overlap = (-token_drop) % self.tokens_chunk_size
420
+ self.frame_overlap = max(self.token_overlap * self.vae_ratio_t - self.frame_pre_padding, 0)
421
+ self.use_tiling = tiling
422
+ self.tile_size = tile_size
423
+ self.tile_overlap_min = tile_overlap_min
424
+
425
+ self.encoder = EncoderFCN3D(
426
+ ch=ch,
427
+ ch_mult=list(ch_mult),
428
+ space_down=list(space_down),
429
+ time_down=list(time_down),
430
+ num_res_blocks=num_res_blocks,
431
+ in_channels=in_channels,
432
+ z_channels=z_channels,
433
+ double_z=True,
434
+ )
435
+ self.quant_conv = nn.Conv3d(2 * z_channels, 2 * embed_dim, kernel_size=1)
436
+ self.post_quant_conv = nn.Conv3d(embed_dim, z_channels, kernel_size=1)
437
+ self.decoder = ViT3DDecoder(
438
+ patch_size=self.vae_ratio,
439
+ patch_size_t=self.vae_ratio_t,
440
+ in_channels=z_channels,
441
+ out_channels=out_ch,
442
+ )
443
+ self.register_buffer("latents_mean", torch.tensor(LATENTS_MEAN))
444
+ self.register_buffer("latents_std", torch.tensor(LATENTS_STD))
445
+ self.register_buffer("pixel_mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1, 1), persistent=False)
446
+ self.register_buffer("pixel_std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1, 1), persistent=False)
447
+
448
+ def _split_tiles(self, length: int) -> tuple[list[int], list[int], list[int]]:
449
+ if self.tile_size >= length:
450
+ return [0], [length], []
451
+ num_tiles = math.ceil(length / self.tile_size)
452
+ while self.tile_size * num_tiles - self.tile_overlap_min * (num_tiles - 1) < length:
453
+ num_tiles += 1
454
+ overlaps = [self.tile_overlap_min] * (num_tiles - 1)
455
+ remaining = self.tile_size * num_tiles - sum(overlaps) - length
456
+ for index in range(remaining // self.vae_ratio):
457
+ overlaps[index % (num_tiles - 1)] += self.vae_ratio
458
+ starts = [0]
459
+ for index in range(num_tiles - 1):
460
+ starts.append(starts[-1] + self.tile_size - overlaps[index])
461
+ return starts, [self.tile_size] * num_tiles, overlaps
462
+
463
+ @staticmethod
464
+ def _blend(first: torch.Tensor, second: torch.Tensor, extent: int, dim: int) -> torch.Tensor:
465
+ extent = min(first.shape[dim], second.shape[dim], extent)
466
+ positions = torch.arange(extent, device=second.device, dtype=second.dtype)
467
+ shape = [1] * first.ndim
468
+ shape[dim] = extent
469
+ first_weight = (1 - positions / extent).view(shape)
470
+ second_weight = (positions / extent).view(shape)
471
+ first_slice = [slice(None)] * first.ndim
472
+ first_slice[dim] = slice(-extent, None)
473
+ second_slice = [slice(None)] * second.ndim
474
+ second_slice[dim] = slice(0, extent)
475
+ blended = first[tuple(first_slice)] * first_weight + second[tuple(second_slice)] * second_weight
476
+ if extent == second.shape[dim]:
477
+ return blended
478
+ rest = [slice(None)] * second.ndim
479
+ rest[dim] = slice(extent, None)
480
+ return torch.cat((blended, second[tuple(rest)]), dim=dim)
481
+
482
+ def _stitch_tiles(
483
+ self,
484
+ tiles: list[list[torch.Tensor]],
485
+ height_overlaps: list[int],
486
+ width_overlaps: list[int],
487
+ ) -> torch.Tensor:
488
+ stitched_rows = []
489
+ for row_index, row in enumerate(tiles):
490
+ stitched_row = []
491
+ for column_index, tile in enumerate(row):
492
+ if row_index:
493
+ tile = self._blend(tiles[row_index - 1][column_index], tile, height_overlaps[row_index - 1], -2)
494
+ if column_index:
495
+ tile = self._blend(row[column_index - 1], tile, width_overlaps[column_index - 1], -1)
496
+ if row_index < len(tiles) - 1:
497
+ tile = tile[..., : -height_overlaps[row_index], :]
498
+ if column_index < len(row) - 1:
499
+ tile = tile[..., :, : -width_overlaps[column_index]]
500
+ stitched_row.append(tile)
501
+ stitched_rows.append(torch.cat(stitched_row, dim=-1))
502
+ return torch.cat(stitched_rows, dim=-2)
503
+
504
+ def _encode_clip(self, pixels: torch.Tensor) -> torch.Tensor:
505
+ if not self.use_tiling:
506
+ return self.quant_conv(self.encoder(pixels))
507
+ height_starts, height_lengths, height_overlaps = self._split_tiles(pixels.shape[-2])
508
+ width_starts, width_lengths, width_overlaps = self._split_tiles(pixels.shape[-1])
509
+ tiles = [
510
+ [
511
+ self.quant_conv(
512
+ self.encoder(
513
+ pixels[
514
+ ...,
515
+ top : top + tile_height,
516
+ left : left + tile_width,
517
+ ]
518
+ )
519
+ )
520
+ for left, tile_width in zip(width_starts, width_lengths)
521
+ ]
522
+ for top, tile_height in zip(height_starts, height_lengths)
523
+ ]
524
+ latent_height_overlaps = [value // self.vae_ratio for value in height_overlaps]
525
+ latent_width_overlaps = [value // self.vae_ratio for value in width_overlaps]
526
+ return self._stitch_tiles(tiles, latent_height_overlaps, latent_width_overlaps)
527
+
528
+ def _decode_clip(self, latents: torch.Tensor) -> torch.Tensor:
529
+ if not self.use_tiling:
530
+ return self.decoder(self.post_quant_conv(latents))
531
+ height_starts, height_lengths, height_overlaps = self._split_tiles(latents.shape[-2] * self.vae_ratio)
532
+ width_starts, width_lengths, width_overlaps = self._split_tiles(latents.shape[-1] * self.vae_ratio)
533
+ tiles = [
534
+ [
535
+ self.decoder(
536
+ self.post_quant_conv(
537
+ latents[
538
+ ...,
539
+ top // self.vae_ratio : (top + tile_height) // self.vae_ratio,
540
+ left // self.vae_ratio : (left + tile_width) // self.vae_ratio,
541
+ ]
542
+ )
543
+ )
544
+ for left, tile_width in zip(width_starts, width_lengths)
545
+ ]
546
+ for top, tile_height in zip(height_starts, height_lengths)
547
+ ]
548
+ return self._stitch_tiles(tiles, height_overlaps, width_overlaps)
549
+
550
+ def _encode_video(self, pixels: torch.Tensor) -> torch.Tensor:
551
+ frame_count = pixels.shape[2]
552
+ if frame_count % self.clip_length:
553
+ padding = pixels[:, :, -1:].repeat(1, 1, (-frame_count) % self.clip_length, 1, 1)
554
+ pixels = torch.cat((pixels, padding), dim=2)
555
+ moments = torch.cat(
556
+ [
557
+ self._encode_clip(pixels[:, :, start : start + self.clip_length])
558
+ for start in range(0, pixels.shape[2], self.clip_length)
559
+ ],
560
+ dim=2,
561
+ )
562
+ return moments[:, :, : -self.token_drop] if self.token_drop else moments
563
+
564
+ def _decode_video(self, latents: torch.Tensor) -> torch.Tensor:
565
+ chunk_tokens = self.tokens_chunk_size
566
+ chunk_frames = chunk_tokens * self.vae_ratio_t
567
+ padded_token_count = latents.shape[2] + self.token_drop
568
+ pad_tokens = (-padded_token_count) % chunk_tokens
569
+ num_chunks = (padded_token_count + pad_tokens) // chunk_tokens - int(self.token_drop > 0)
570
+ if num_chunks < 1:
571
+ pad_tokens += chunk_tokens
572
+ num_chunks = 1
573
+ if pad_tokens:
574
+ latents = torch.cat((latents, latents[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)), dim=2)
575
+
576
+ decoded = []
577
+ overlap = None
578
+ for index in range(num_chunks):
579
+ start = index * chunk_tokens
580
+ clip = self._decode_clip(latents[:, :, start : start + chunk_tokens + self.token_overlap])
581
+ for part_index in range(int(self.token_drop > 0) + 1):
582
+ frame_start = part_index * chunk_frames
583
+ part = clip[:, :, frame_start : frame_start + chunk_frames]
584
+ part = part[:, :, self.frame_pre_padding :]
585
+ if part_index == 0:
586
+ if overlap is not None:
587
+ part = self._blend(overlap, part, self.frame_overlap, dim=-3)
588
+ decoded.append(part)
589
+ else:
590
+ overlap = part
591
+ if overlap is not None:
592
+ decoded.append(overlap)
593
+ pixels = torch.cat(decoded, dim=2)
594
+
595
+ if pad_tokens:
596
+ intra_tail = self.clip_length % self.vae_ratio_t
597
+ tokens_before_padding = latents.shape[2] - pad_tokens
598
+ pad_frames = sum(
599
+ intra_tail if intra_tail and (tokens_before_padding + offset) % chunk_tokens == 0 else self.vae_ratio_t
600
+ for offset in range(pad_tokens)
601
+ )
602
+ pixels = pixels[:, :, :-pad_frames]
603
+ return pixels
604
+
605
+ def encode_moments(self, pixels: torch.Tensor) -> torch.Tensor:
606
+ if pixels.ndim == 4:
607
+ pixels = pixels.unsqueeze(2)
608
+ pixels = (pixels + 1.0) * 0.5
609
+ pixels = (pixels - self.pixel_mean.to(pixels)) / self.pixel_std.to(pixels)
610
+ if pixels.shape[2] == 1:
611
+ moments = self._encode_clip(pixels)[:, :, -1:]
612
+ else:
613
+ moments = self._encode_video(pixels)
614
+ return moments.float()
615
+
616
+ def encode(self, pixels: torch.Tensor) -> torch.Tensor:
617
+ mean = self.encode_moments(pixels).chunk(2, dim=1)[0]
618
+ latent_mean = self.latents_mean.view(1, -1, 1, 1, 1).to(mean)
619
+ latent_std = self.latents_std.view(1, -1, 1, 1, 1).to(mean)
620
+ return (mean - latent_mean) / latent_std
621
+
622
+ def encode_tiled(self, pixels: torch.Tensor, **kwargs) -> torch.Tensor:
623
+ del kwargs
624
+ return self.encode(pixels)
625
+
626
+ def decode_tiled(self, latents: torch.Tensor, **kwargs) -> torch.Tensor:
627
+ del kwargs
628
+ return self.decode(latents)
629
+
630
+ def decode(self, latents: torch.Tensor) -> torch.Tensor:
631
+ latent_mean = self.latents_mean.view(1, -1, 1, 1, 1).to(latents)
632
+ latent_std = self.latents_std.view(1, -1, 1, 1, 1).to(latents)
633
+ latents = latents * latent_std + latent_mean
634
+ if latents.shape[2] == 1:
635
+ pixels = self._decode_clip(latents)[:, :, -1:]
636
+ else:
637
+ pixels = self._decode_video(latents)
638
+ pixels = pixels.float() * self.pixel_std.to(pixels) + self.pixel_mean.to(pixels)
639
+ return pixels.clamp(0.0, 1.0) * 2.0 - 1.0
640
+
641
+
642
+ def _video_posterior_sample(vae, pixels: torch.Tensor, generator: torch.Generator, fp16_roundtrip: bool) -> torch.Tensor:
643
+ moments = vae.encode_moments(pixels)
644
+ if moments.ndim != 5 or moments.shape[1] != 48:
645
+ raise ValueError(f"Expected MiniMax-H3 video moments [B,48,T,H,W], got {tuple(moments.shape)}")
646
+ mean, logvar = moments.float().chunk(2, dim=1)
647
+ noise = torch.randn(mean.shape, generator=generator, dtype=torch.float32, device="cpu").to(mean.device)
648
+ sample = mean + torch.exp(0.5 * logvar.clamp(-30.0, 20.0)) * noise
649
+ if fp16_roundtrip:
650
+ sample = sample.to(torch.float16).to(torch.float32)
651
+ latents_mean = vae.latents_mean.view(1, -1, 1, 1, 1).to(sample)
652
+ latents_std = vae.latents_std.view(1, -1, 1, 1, 1).to(sample)
653
+ return (sample - latents_mean) / latents_std
654
+
655
+
656
+ @torch.no_grad()
657
+ def encode_video_target(vae, pixels: torch.Tensor, cache_seed: int, canonical_item_key: str) -> torch.Tensor:
658
+ digest = hashlib.sha256(f"{cache_seed}\0{canonical_item_key}".encode()).digest()
659
+ seed = int.from_bytes(digest[:8], "little") % (2**63)
660
+ generator = torch.Generator(device="cpu").manual_seed(seed)
661
+ return _video_posterior_sample(vae, pixels, generator, fp16_roundtrip=False)
662
+
663
+
664
+ @torch.no_grad()
665
+ def encode_video_condition(vae, pixels: torch.Tensor) -> torch.Tensor:
666
+ generator = torch.Generator(device="cpu").manual_seed(42)
667
+ return _video_posterior_sample(vae, pixels, generator, fp16_roundtrip=True)
668
+
669
+
670
+ def load_video_vae(
671
+ path,
672
+ device: str | torch.device = "cpu",
673
+ dtype: torch.dtype = torch.float16,
674
+ disable_mmap: bool = False,
675
+ ) -> MiniMaxH3VideoVAE:
676
+ from musubi_tuner.minimax_h3.checkpoint import load_safetensors_module, resolve_safetensors_files
677
+
678
+ files = resolve_safetensors_files(path)
679
+ return load_safetensors_module(
680
+ MiniMaxH3VideoVAE,
681
+ files,
682
+ device=device,
683
+ dtype=dtype,
684
+ key_prefixes=("first_stage_model.", "video_vae.", "vae."),
685
+ disable_mmap=disable_mmap,
686
+ )
musubi_tuner/modules/__init__.py ADDED
File without changes
musubi_tuner/modules/adafactor_fused.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # copy from sd-scripts
2
+
3
+ import math
4
+ import torch
5
+ from transformers import Adafactor
6
+
7
+ # stochastic rounding for bfloat16
8
+ # The implementation was provided by 2kpr. Thank you very much!
9
+
10
+
11
+ def copy_stochastic_(target: torch.Tensor, source: torch.Tensor):
12
+ """
13
+ copies source into target using stochastic rounding
14
+
15
+ Args:
16
+ target: the target tensor with dtype=bfloat16
17
+ source: the target tensor with dtype=float32
18
+ """
19
+ # create a random 16 bit integer
20
+ result = torch.randint_like(source, dtype=torch.int32, low=0, high=(1 << 16))
21
+
22
+ # add the random number to the lower 16 bit of the mantissa
23
+ result.add_(source.view(dtype=torch.int32))
24
+
25
+ # mask off the lower 16 bit of the mantissa
26
+ result.bitwise_and_(-65536) # -65536 = FFFF0000 as a signed int32
27
+
28
+ # copy the higher 16 bit into the target tensor
29
+ target.copy_(result.view(dtype=torch.float32))
30
+
31
+ del result
32
+
33
+
34
+ @torch.no_grad()
35
+ def adafactor_step_param(self, p, group):
36
+ if p.grad is None:
37
+ return
38
+ grad = p.grad
39
+ if grad.dtype in {torch.float16, torch.bfloat16}:
40
+ grad = grad.float()
41
+ if grad.is_sparse:
42
+ raise RuntimeError("Adafactor does not support sparse gradients.")
43
+
44
+ state = self.state[p]
45
+ grad_shape = grad.shape
46
+
47
+ factored, use_first_moment = Adafactor._get_options(group, grad_shape)
48
+ # State Initialization
49
+ if len(state) == 0:
50
+ state["step"] = 0
51
+
52
+ if use_first_moment:
53
+ # Exponential moving average of gradient values
54
+ state["exp_avg"] = torch.zeros_like(grad)
55
+ if factored:
56
+ state["exp_avg_sq_row"] = torch.zeros(grad_shape[:-1]).to(grad)
57
+ state["exp_avg_sq_col"] = torch.zeros(grad_shape[:-2] + grad_shape[-1:]).to(grad)
58
+ else:
59
+ state["exp_avg_sq"] = torch.zeros_like(grad)
60
+
61
+ state["RMS"] = 0
62
+ else:
63
+ if use_first_moment:
64
+ state["exp_avg"] = state["exp_avg"].to(grad)
65
+ if factored:
66
+ state["exp_avg_sq_row"] = state["exp_avg_sq_row"].to(grad)
67
+ state["exp_avg_sq_col"] = state["exp_avg_sq_col"].to(grad)
68
+ else:
69
+ state["exp_avg_sq"] = state["exp_avg_sq"].to(grad)
70
+
71
+ p_data_fp32 = p
72
+ if p.dtype in {torch.float16, torch.bfloat16}:
73
+ p_data_fp32 = p_data_fp32.float()
74
+
75
+ state["step"] += 1
76
+ state["RMS"] = Adafactor._rms(p_data_fp32)
77
+ lr = Adafactor._get_lr(group, state)
78
+
79
+ beta2t = 1.0 - math.pow(state["step"], group["decay_rate"])
80
+ update = (grad**2) + group["eps"][0]
81
+ if factored:
82
+ exp_avg_sq_row = state["exp_avg_sq_row"]
83
+ exp_avg_sq_col = state["exp_avg_sq_col"]
84
+
85
+ exp_avg_sq_row.mul_(beta2t).add_(update.mean(dim=-1), alpha=(1.0 - beta2t))
86
+ exp_avg_sq_col.mul_(beta2t).add_(update.mean(dim=-2), alpha=(1.0 - beta2t))
87
+
88
+ # Approximation of exponential moving average of square of gradient
89
+ update = Adafactor._approx_sq_grad(exp_avg_sq_row, exp_avg_sq_col)
90
+ update.mul_(grad)
91
+ else:
92
+ exp_avg_sq = state["exp_avg_sq"]
93
+
94
+ exp_avg_sq.mul_(beta2t).add_(update, alpha=(1.0 - beta2t))
95
+ update = exp_avg_sq.rsqrt().mul_(grad)
96
+
97
+ update.div_((Adafactor._rms(update) / group["clip_threshold"]).clamp_(min=1.0))
98
+ update.mul_(lr)
99
+
100
+ if use_first_moment:
101
+ exp_avg = state["exp_avg"]
102
+ exp_avg.mul_(group["beta1"]).add_(update, alpha=(1 - group["beta1"]))
103
+ update = exp_avg
104
+
105
+ if group["weight_decay"] != 0:
106
+ p_data_fp32.add_(p_data_fp32, alpha=(-group["weight_decay"] * lr))
107
+
108
+ p_data_fp32.add_(-update)
109
+
110
+ # if p.dtype in {torch.float16, torch.bfloat16}:
111
+ # p.copy_(p_data_fp32)
112
+
113
+ if p.dtype == torch.bfloat16:
114
+ copy_stochastic_(p, p_data_fp32)
115
+ elif p.dtype == torch.float16:
116
+ p.copy_(p_data_fp32)
117
+
118
+
119
+ @torch.no_grad()
120
+ def adafactor_step(self, closure=None):
121
+ """
122
+ Performs a single optimization step
123
+
124
+ Arguments:
125
+ closure (callable, optional): A closure that reevaluates the model
126
+ and returns the loss.
127
+ """
128
+ loss = None
129
+ if closure is not None:
130
+ loss = closure()
131
+
132
+ for group in self.param_groups:
133
+ for p in group["params"]:
134
+ adafactor_step_param(self, p, group)
135
+
136
+ return loss
137
+
138
+
139
+ def patch_adafactor_fused(optimizer: Adafactor):
140
+ optimizer.step_param = adafactor_step_param.__get__(optimizer)
141
+ optimizer.step = adafactor_step.__get__(optimizer)
musubi_tuner/modules/attention.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unified attention function supporting various implementations
2
+
3
+ from dataclasses import dataclass
4
+ import torch
5
+ from typing import Optional, Union
6
+
7
+ try:
8
+ import flash_attn
9
+ from flash_attn.flash_attn_interface import _flash_attn_forward
10
+ from flash_attn.flash_attn_interface import flash_attn_varlen_func
11
+ from flash_attn.flash_attn_interface import flash_attn_func
12
+ except ImportError:
13
+ flash_attn = None
14
+ flash_attn_varlen_func = None
15
+ _flash_attn_forward = None
16
+ flash_attn_func = None
17
+
18
+ try:
19
+ from sageattention import sageattn_varlen, sageattn
20
+ except ImportError:
21
+ sageattn_varlen = None
22
+ sageattn = None
23
+
24
+ try:
25
+ import xformers.ops as xops
26
+ except ImportError:
27
+ xops = None
28
+
29
+
30
+ @dataclass
31
+ class AttentionParams:
32
+ attn_mode: Optional[str] = None
33
+ split_attn: bool = False
34
+ img_len: Optional[int] = None
35
+ attention_mask: Optional[torch.Tensor] = None
36
+ seqlens: Optional[torch.Tensor] = None
37
+ cu_seqlens: Optional[torch.Tensor] = None
38
+ max_seqlen: Optional[int] = None
39
+
40
+ @staticmethod
41
+ def create_attention_params(attn_mode: Optional[str], split_attn: bool) -> "AttentionParams":
42
+ return AttentionParams(attn_mode, split_attn)
43
+
44
+ @staticmethod
45
+ def create_attention_params_from_mask(
46
+ attn_mode: Optional[str], split_attn: bool, img_len: Optional[int], attention_mask: Optional[torch.Tensor]
47
+ ) -> "AttentionParams":
48
+ if attention_mask is None:
49
+ # No attention mask provided: assume all tokens are valid
50
+ return AttentionParams(attn_mode, split_attn, None, None, None, None, None)
51
+ else:
52
+ # Note: attention_mask is only for text tokens, not including image tokens
53
+ seqlens = attention_mask.sum(dim=1).to(torch.int32) + img_len # [B]
54
+ max_seqlen = attention_mask.shape[1] + img_len
55
+
56
+ if split_attn:
57
+ # cu_seqlens is not needed for split attention
58
+ return AttentionParams(attn_mode, split_attn, img_len, attention_mask, seqlens, None, max_seqlen)
59
+
60
+ # Convert attention mask to cumulative sequence lengths for flash attention
61
+ batch_size = attention_mask.shape[0]
62
+ cu_seqlens = torch.zeros([2 * batch_size + 1], dtype=torch.int32, device=attention_mask.device)
63
+ for i in range(batch_size):
64
+ cu_seqlens[2 * i + 1] = i * max_seqlen + seqlens[i] # end of valid tokens for query
65
+ cu_seqlens[2 * i + 2] = (i + 1) * max_seqlen # end of all tokens for query
66
+
67
+ # Expand attention mask to include image tokens
68
+ attention_mask = torch.nn.functional.pad(attention_mask, (img_len, 0), value=1) # [B, img_len + L]
69
+
70
+ # attention bias for xformers
71
+ if attn_mode == "xformers":
72
+ seqlens_list = seqlens.cpu().tolist()
73
+ attention_mask = xops.fmha.attn_bias.BlockDiagonalMask.from_seqlens(
74
+ seqlens_list, seqlens_list, device=attention_mask.device
75
+ )
76
+ elif attn_mode == "torch":
77
+ attention_mask = attention_mask[:, None, None, :].to(torch.bool) # [B, 1, 1, img_len + L]
78
+
79
+ return AttentionParams(attn_mode, split_attn, img_len, attention_mask, seqlens, cu_seqlens, max_seqlen)
80
+
81
+
82
+ def attention(
83
+ qkv_or_q: Union[torch.Tensor, list],
84
+ k: Optional[torch.Tensor] = None,
85
+ v: Optional[torch.Tensor] = None,
86
+ attn_params: Optional[AttentionParams] = None,
87
+ drop_rate: float = 0.0,
88
+ ) -> torch.Tensor:
89
+ """
90
+ Compute scaled dot-product attention with variable sequence lengths.
91
+
92
+ Handles batches with different sequence lengths by splitting and
93
+ processing each sequence individually.
94
+
95
+ Args:
96
+ qkv_or_q: Query tensor [B, L, H, D]. or list of such tensors.
97
+ k: Key tensor [B, L, H, D].
98
+ v: Value tensor [B, L, H, D].
99
+ attn_param: Attention parameters including mask and sequence lengths.
100
+ drop_rate: Attention dropout rate.
101
+
102
+ Returns:
103
+ Attention output tensor [B, L, H*D].
104
+ """
105
+ if isinstance(qkv_or_q, list):
106
+ q, k, v = qkv_or_q
107
+ q: torch.Tensor = q
108
+ qkv_or_q.clear()
109
+ del qkv_or_q
110
+ else:
111
+ q: torch.Tensor = qkv_or_q
112
+ del qkv_or_q
113
+ assert k is not None and v is not None, "k and v must be provided if qkv_or_q is a tensor"
114
+ if attn_params is None:
115
+ attn_params = AttentionParams.create_attention_params("torch", False)
116
+
117
+ # GQA: q may carry more heads than k/v (e.g. Krea 2 = 48 query / 12 kv heads). flash and
118
+ # sageattn group heads natively inside the kernel (verified), so they ignore this. For the
119
+ # torch (SDPA) path we expand k/v to q's head count below instead of passing enable_gqa=True,
120
+ # because enable_gqa forces SDPA onto the slow math kernel (~7x slower than the fused kernels
121
+ # at K2 scale); the repeat is numerically identical. Revert to enable_gqa=True once PyTorch's
122
+ # fused (flash / mem-efficient) kernels gain native GQA support. (q/k/v here are [B, L, H, D].)
123
+ enable_gqa = q.shape[-2] != k.shape[-2]
124
+
125
+ # If split attn is False, attention mask is provided and all sequence lengths are same, we can trim the sequence
126
+ seqlen_trimmed = False
127
+ # Trim if all seqlens are the same, for attention modes other than flash or sageattn (which can handle masks efficiently)
128
+ if (
129
+ not attn_params.split_attn
130
+ and attn_params.attention_mask is not None
131
+ and attn_params.seqlens is not None
132
+ and (attn_params.attn_mode != "flash" and attn_params.attn_mode != "sageattn")
133
+ ):
134
+ if torch.all(attn_params.seqlens == attn_params.seqlens[0]):
135
+ seqlen = attn_params.seqlens[0].item()
136
+ q = q[:, :seqlen]
137
+ k = k[:, :seqlen]
138
+ v = v[:, :seqlen]
139
+ max_seqlen = attn_params.max_seqlen
140
+ attn_params = AttentionParams.create_attention_params(attn_params.attn_mode, False) # do not in-place modify
141
+ attn_params.max_seqlen = max_seqlen # keep max_seqlen for padding
142
+ seqlen_trimmed = True
143
+
144
+ # Determine tensor layout based on attention implementation
145
+ if attn_params.attn_mode == "torch" or (
146
+ attn_params.attn_mode == "sageattn" and (attn_params.split_attn or attn_params.cu_seqlens is None)
147
+ ):
148
+ transpose_fn = lambda x: x.transpose(1, 2) # [B, H, L, D] for SDPA and sageattn with fixed length
149
+ # pad on sequence length dimension
150
+ pad_fn = lambda x, pad_to: torch.nn.functional.pad(x, (0, 0, 0, pad_to - x.shape[-2]), value=0)
151
+ else:
152
+ transpose_fn = lambda x: x # [B, L, H, D] for other implementations
153
+ # pad on sequence length dimension
154
+ pad_fn = lambda x, pad_to: torch.nn.functional.pad(x, (0, 0, 0, 0, 0, pad_to - x.shape[-3]), value=0)
155
+
156
+ # Process each batch element with its valid sequence lengths
157
+ if attn_params.split_attn:
158
+ if attn_params.seqlens is None:
159
+ # If no seqlens provided, assume all tokens are valid
160
+ attn_params = AttentionParams.create_attention_params(attn_params.attn_mode, True) # do not in-place modify
161
+ attn_params.seqlens = torch.tensor([q.shape[1]] * q.shape[0], device=q.device)
162
+ attn_params.max_seqlen = q.shape[1]
163
+ q = [transpose_fn(q[i : i + 1, : attn_params.seqlens[i]]) for i in range(len(q))]
164
+ k = [transpose_fn(k[i : i + 1, : attn_params.seqlens[i]]) for i in range(len(k))]
165
+ v = [transpose_fn(v[i : i + 1, : attn_params.seqlens[i]]) for i in range(len(v))]
166
+ else:
167
+ q = transpose_fn(q)
168
+ k = transpose_fn(k)
169
+ v = transpose_fn(v)
170
+
171
+ if attn_params.attn_mode == "torch":
172
+ if attn_params.split_attn:
173
+ x = []
174
+ for i in range(len(q)):
175
+ qi, ki, vi = q[i], k[i], v[i]
176
+ if enable_gqa: # expand k/v heads to avoid SDPA's slow enable_gqa math path
177
+ g = qi.shape[1] // ki.shape[1] # [B, H, L, D] -> heads at dim 1
178
+ ki = ki.repeat_interleave(g, dim=1)
179
+ vi = vi.repeat_interleave(g, dim=1)
180
+ x_i = torch.nn.functional.scaled_dot_product_attention(qi, ki, vi, dropout_p=drop_rate)
181
+ q[i] = None
182
+ k[i] = None
183
+ v[i] = None
184
+ x.append(pad_fn(x_i, attn_params.max_seqlen)) # B, H, L, D
185
+ x = torch.cat(x, dim=0)
186
+ q, k, v = None, None, None
187
+
188
+ else:
189
+ if enable_gqa: # expand k/v heads to avoid SDPA's slow enable_gqa math path
190
+ g = q.shape[1] // k.shape[1] # [B, H, L, D] -> heads at dim 1
191
+ k = k.repeat_interleave(g, dim=1)
192
+ v = v.repeat_interleave(g, dim=1)
193
+ x = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=attn_params.attention_mask, dropout_p=drop_rate)
194
+ q, k, v = None, None, None
195
+
196
+ elif attn_params.attn_mode == "xformers":
197
+ if attn_params.split_attn:
198
+ x = []
199
+ for i in range(len(q)):
200
+ qi, ki, vi = q[i], k[i], v[i]
201
+ if enable_gqa: # expand k/v heads to q's count; xformers 4D has no native GQA ([B, L, H, D] -> dim 2)
202
+ g = qi.shape[2] // ki.shape[2]
203
+ ki = ki.repeat_interleave(g, dim=2)
204
+ vi = vi.repeat_interleave(g, dim=2)
205
+ x_i = xops.memory_efficient_attention(qi, ki, vi, p=drop_rate)
206
+ q[i] = None
207
+ k[i] = None
208
+ v[i] = None
209
+ x.append(pad_fn(x_i, attn_params.max_seqlen)) # B, L, H, D
210
+ x = torch.cat(x, dim=0)
211
+ q, k, v = None, None, None
212
+
213
+ else:
214
+ if enable_gqa: # expand k/v heads to q's count; xformers 4D has no native GQA ([B, L, H, D] -> dim 2)
215
+ g = q.shape[2] // k.shape[2]
216
+ k = k.repeat_interleave(g, dim=2)
217
+ v = v.repeat_interleave(g, dim=2)
218
+ x = xops.memory_efficient_attention(q, k, v, attn_bias=attn_params.attention_mask, p=drop_rate)
219
+ q, k, v = None, None, None
220
+
221
+ elif attn_params.attn_mode == "sageattn":
222
+ if attn_params.split_attn:
223
+ x = []
224
+ for i in range(len(q)):
225
+ # HND seems to cause an error
226
+ x_i = sageattn(q[i], k[i], v[i]) # B, H, L, D. No dropout support
227
+ q[i] = None
228
+ k[i] = None
229
+ v[i] = None
230
+ x.append(pad_fn(x_i, attn_params.max_seqlen)) # B, H, L, D
231
+ x = torch.cat(x, dim=0)
232
+ q, k, v = None, None, None
233
+ elif attn_params.cu_seqlens is None: # all tokens are valid
234
+ x = sageattn(q, k, v) # B, L, H, D. No dropout support
235
+ q, k, v = None, None, None
236
+ else:
237
+ # Reshape to [(bxs), a, d]
238
+ batch_size, seqlen = q.shape[0], q.shape[1]
239
+ # reshape (not view): callers may pass non-contiguous q/k/v (e.g. Krea 2 transposes
240
+ # [B,H,L,D]->[B,L,H,D] after RoPE); reshape copies only when needed, view-equivalent otherwise.
241
+ q = q.reshape(q.shape[0] * q.shape[1], *q.shape[2:]) # [B*L, H, D]
242
+ k = k.reshape(k.shape[0] * k.shape[1], *k.shape[2:]) # [B*L, H, D]
243
+ v = v.reshape(v.shape[0] * v.shape[1], *v.shape[2:]) # [B*L, H, D]
244
+
245
+ # Assume cu_seqlens_q == cu_seqlens_kv and max_seqlen_q == max_seqlen_kv. No dropout support
246
+ x = sageattn_varlen(
247
+ q, k, v, attn_params.cu_seqlens, attn_params.cu_seqlens, attn_params.max_seqlen, attn_params.max_seqlen
248
+ )
249
+ q, k, v = None, None, None
250
+
251
+ # Reshape x with shape [(bxs), a, d] to [b, s, a, d]
252
+ x = x.view(batch_size, seqlen, x.shape[-2], x.shape[-1]) # B, L, H, D
253
+
254
+ elif attn_params.attn_mode == "flash":
255
+ if attn_params.split_attn:
256
+ x = []
257
+ for i in range(len(q)):
258
+ # HND seems to cause an error
259
+ x_i = flash_attn_func(q[i], k[i], v[i], drop_rate) # B, L, H, D
260
+ q[i] = None
261
+ k[i] = None
262
+ v[i] = None
263
+ x.append(pad_fn(x_i, attn_params.max_seqlen)) # B, L, H, D
264
+ x = torch.cat(x, dim=0)
265
+ q, k, v = None, None, None
266
+ elif attn_params.cu_seqlens is None: # all tokens are valid
267
+ x = flash_attn_func(q, k, v, drop_rate) # B, L, H, D
268
+ q, k, v = None, None, None
269
+ else:
270
+ # Reshape to [(bxs), a, d]
271
+ batch_size, seqlen = q.shape[0], q.shape[1]
272
+ # reshape (not view): callers may pass non-contiguous q/k/v (e.g. Krea 2 transposes
273
+ # [B,H,L,D]->[B,L,H,D] after RoPE); reshape copies only when needed, view-equivalent otherwise.
274
+ q = q.reshape(q.shape[0] * q.shape[1], *q.shape[2:]) # [B*L, H, D]
275
+ k = k.reshape(k.shape[0] * k.shape[1], *k.shape[2:]) # [B*L, H, D]
276
+ v = v.reshape(v.shape[0] * v.shape[1], *v.shape[2:]) # [B*L, H, D]
277
+
278
+ # Assume cu_seqlens_q == cu_seqlens_kv and max_seqlen_q == max_seqlen_kv
279
+ x = flash_attn_varlen_func(
280
+ q, k, v, attn_params.cu_seqlens, attn_params.cu_seqlens, attn_params.max_seqlen, attn_params.max_seqlen, drop_rate
281
+ )
282
+ q, k, v = None, None, None
283
+
284
+ # Reshape x with shape [(bxs), a, d] to [b, s, a, d]
285
+ x = x.view(batch_size, seqlen, x.shape[-2], x.shape[-1]) # B, L, H, D
286
+
287
+ else:
288
+ # Currently only PyTorch SDPA and xformers are implemented
289
+ raise ValueError(f"Unsupported attention mode: {attn_params.attn_mode}")
290
+
291
+ x = transpose_fn(x) # [B, L, H, D]
292
+ x = x.reshape(x.shape[0], x.shape[1], -1) # [B, L, H*D]
293
+
294
+ if seqlen_trimmed:
295
+ x = torch.nn.functional.pad(x, (0, 0, 0, attn_params.max_seqlen - x.shape[1]), value=0) # pad back to max_seqlen
296
+
297
+ return x
musubi_tuner/modules/custom_offloading_utils.py ADDED
@@ -0,0 +1,1096 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass
5
+ import gc
6
+ import time
7
+ from typing import Optional
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+
12
+ # Keep these functions here for portability, and private to avoid confusion with the ones in device_utils.py
13
+ def _clean_memory_on_device(device: torch.device):
14
+ r"""
15
+ Clean memory on the specified device, will be called from training scripts.
16
+ """
17
+ gc.collect()
18
+
19
+ # device may "cuda" or "cuda:0", so we need to check the type of device
20
+ if device.type == "cuda":
21
+ torch.cuda.empty_cache()
22
+ if device.type == "xpu":
23
+ torch.xpu.empty_cache()
24
+ if device.type == "mps":
25
+ torch.mps.empty_cache()
26
+
27
+
28
+ def _synchronize_device(device: torch.device):
29
+ if device.type == "cuda":
30
+ torch.cuda.synchronize()
31
+ elif device.type == "xpu":
32
+ torch.xpu.synchronize()
33
+ elif device.type == "mps":
34
+ torch.mps.synchronize()
35
+
36
+
37
+ def swap_weight_devices_no_cuda(device: torch.device, layer_to_cpu: nn.Module, layer_to_cuda: nn.Module):
38
+ """
39
+ not tested
40
+ """
41
+ assert layer_to_cpu.__class__ == layer_to_cuda.__class__
42
+
43
+ weight_swap_jobs = []
44
+ for module_to_cpu, module_to_cuda in zip(layer_to_cpu.modules(), layer_to_cuda.modules()):
45
+ if hasattr(module_to_cpu, "weight") and module_to_cpu.weight is not None:
46
+ weight_swap_jobs.append((module_to_cpu, module_to_cuda, module_to_cpu.weight.data, module_to_cuda.weight.data))
47
+
48
+ # device to cpu
49
+ for module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view in weight_swap_jobs:
50
+ module_to_cpu.weight.data = cuda_data_view.data.to("cpu", non_blocking=True)
51
+
52
+ _synchronize_device(device)
53
+
54
+ # cpu to device
55
+ for module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view in weight_swap_jobs:
56
+ cuda_data_view.copy_(module_to_cuda.weight.data, non_blocking=True)
57
+ module_to_cuda.weight.data = cuda_data_view
58
+
59
+ _synchronize_device(device)
60
+
61
+
62
+ def weighs_to_device(layer: nn.Module, device: torch.device):
63
+ for module in layer.modules():
64
+ if hasattr(module, "weight") and module.weight is not None and module.__class__.__name__.endswith("Linear"):
65
+ module.weight.data = module.weight.data.to(device, non_blocking=device.type != "cpu")
66
+
67
+
68
+ @dataclass
69
+ class BlockSwapConfig:
70
+ """
71
+ Construction policy for a block-swap offloader, assembled once by the training/inference script and
72
+ passed through each architecture's ``enable_block_swap`` to ``create_offloader``.
73
+
74
+ It holds everything the offloader constructor needs that is *not* architecture-specific. The
75
+ architecture-specific arguments (the block-type label, the block list, and its counts) are supplied by
76
+ ``enable_block_swap`` per block list. Adding a new offloader knob (or a whole new offloader type) means
77
+ adding a field here and a branch in ``create_offloader`` -- the per-architecture ``enable_block_swap``
78
+ signatures stay ``(blocks_to_swap, config)`` and never change.
79
+ """
80
+
81
+ device: torch.device
82
+ supports_backward: bool
83
+ use_pinned_memory: bool = False
84
+ h2d_only: bool = False # frozen-base (LoRA / LoHa / LoKr) only: H2D-only streaming, no device->host copy
85
+ ring_size: int = 2 # (h2d_only) number of GPU ring buffers for streamed blocks; 2 = double buffering
86
+ debug: bool = False
87
+
88
+ @classmethod
89
+ def from_args(cls, args, device: torch.device, supports_backward: bool) -> "BlockSwapConfig":
90
+ """Build from a parsed-args namespace, tolerating scripts whose parser lacks the optional knobs."""
91
+ h2d_only = getattr(args, "block_swap_h2d_only", False)
92
+
93
+ # H2D-only streams frozen weights into a reused GPU ring buffer and loads them with an in-place copy_,
94
+ # which bumps the autograd version of the saved weight view. Gradient checkpointing re-reads the weight
95
+ # at recompute time (a short, safe window), so the version stays consistent; without it the backward
96
+ # pass fails with an opaque "modified by an inplace operation ... version N expected M" error that does
97
+ # not point at the offloader. Fail early with an actionable message instead. (Inference / forward-only
98
+ # has no backward, so it is unaffected.)
99
+ if h2d_only and supports_backward and not getattr(args, "gradient_checkpointing", False):
100
+ raise ValueError(
101
+ "--block_swap_h2d_only requires --gradient_checkpointing for training. H2D-only block swap streams"
102
+ " frozen weights through a reused GPU ring buffer, which advances the autograd version of weights"
103
+ " saved for backward; gradient checkpointing re-reads them at recompute time and avoids this."
104
+ " / --block_swap_h2d_only は学習時に --gradient_checkpointing が必須です(リングバッファの上書きで"
105
+ "backward 用に保存された重みの version が進むため。gradient checkpointing は再計算時に読み直すので回避できます)。"
106
+ )
107
+
108
+ ring_size = getattr(args, "block_swap_ring_size", 2)
109
+ if ring_size < 1:
110
+ raise ValueError("--block_swap_ring_size must be >= 1")
111
+
112
+ return cls(
113
+ device=device,
114
+ supports_backward=supports_backward,
115
+ use_pinned_memory=getattr(args, "use_pinned_memory_for_block_swap", False),
116
+ h2d_only=h2d_only,
117
+ ring_size=ring_size,
118
+ )
119
+
120
+
121
+ def create_offloader(block_type: str, blocks: list[nn.Module], num_blocks: int, blocks_to_swap: int, config: BlockSwapConfig):
122
+ """
123
+ Create the block-swap offloader for one block list. This is the single place that selects the offloader
124
+ implementation from ``config``; ``enable_block_swap`` only supplies the per-block-list arguments, so a new
125
+ offloader type plugs in here without touching any architecture.
126
+ """
127
+ if config.h2d_only:
128
+ # H2D-only streaming for frozen-base (LoRA) training: keep a CPU master, copy Host->Device only.
129
+ return LoRAStreamOffloader(
130
+ block_type,
131
+ blocks,
132
+ num_blocks,
133
+ blocks_to_swap,
134
+ config.supports_backward,
135
+ config.device,
136
+ ring_size=config.ring_size,
137
+ use_pinned_memory=config.use_pinned_memory,
138
+ debug=config.debug,
139
+ )
140
+ return ModelOffloader(
141
+ block_type,
142
+ blocks,
143
+ num_blocks,
144
+ blocks_to_swap,
145
+ config.supports_backward,
146
+ config.device,
147
+ config.use_pinned_memory,
148
+ debug=config.debug,
149
+ )
150
+
151
+
152
+ class Offloader:
153
+ """
154
+ common offloading class
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ block_type: str,
160
+ num_blocks: int,
161
+ blocks_to_swap: int,
162
+ device: torch.device,
163
+ use_pinned_memory: bool = False,
164
+ debug: bool = False,
165
+ ):
166
+ self.block_type = block_type
167
+ self.num_blocks = num_blocks
168
+ self.blocks_to_swap = blocks_to_swap
169
+ self.device = device
170
+ self.use_pinned_memory = use_pinned_memory
171
+
172
+ # check if debug is enabled from os environment variable
173
+ if not debug:
174
+ import os
175
+
176
+ debug = os.getenv("MUSUBI_TUNER_OFFLOADER_DEBUG", "0") == "1"
177
+
178
+ self.debug = debug
179
+ self.debug_block_count = 0
180
+
181
+ self.thread_pool = ThreadPoolExecutor(max_workers=1)
182
+ self.futures = {}
183
+ self.cuda_available = device.type == "cuda"
184
+ self.stream = torch.cuda.Stream(device=device) if self.cuda_available else None
185
+
186
+ # Staging buffers for cuda offloading without large pinned memory. These are pinned memory buffers to speed up the transfer between CPU and GPU
187
+ # We create one staging buffer per transfer direction (A: GPU to CPU, B: CPU to GPU)
188
+ self.staging_buffer_a = None
189
+ self.staging_buffer_b = None
190
+
191
+ # Pinned buffer for cuda offloading with pinned memory. We need only one pinned buffer per layer transfer
192
+ self.pinned_buffer = None
193
+
194
+ def swap_weight_devices_cuda(self, device: torch.device, layer_to_cpu: nn.Module, layer_to_cuda: nn.Module):
195
+ assert layer_to_cpu.__class__ == layer_to_cuda.__class__
196
+
197
+ debug_print = False
198
+ if self.debug:
199
+ debug_print = self.debug_block_count % 10 == 0
200
+ self.debug_block_count += 1
201
+
202
+ class Timer:
203
+ def __init__(self, enabled=False):
204
+ self.enabled = enabled
205
+ self.totals = defaultdict(float)
206
+ self.start_time = time.perf_counter()
207
+
208
+ @contextmanager
209
+ def section(self, name):
210
+ if not self.enabled:
211
+ yield
212
+ return
213
+ t0 = time.perf_counter()
214
+ try:
215
+ yield
216
+ finally:
217
+ self.totals[name] += time.perf_counter() - t0
218
+
219
+ T = Timer(enabled=debug_print)
220
+
221
+ weight_swap_jobs = []
222
+
223
+ # This is not working for all cases (e.g. SD3), so we need to find the corresponding modules. kept here for reference:
224
+ # for module_to_cpu, module_to_cuda in zip(layer_to_cpu.modules(), layer_to_cuda.modules()):
225
+ # print(module_to_cpu.__class__, module_to_cuda.__class__)
226
+ # if hasattr(module_to_cpu, "weight") and module_to_cpu.weight is not None:
227
+ # weight_swap_jobs.append((module_to_cpu, module_to_cuda, module_to_cpu.weight.data, module_to_cuda.weight.data))
228
+
229
+ with T.section("find modules"):
230
+ modules_to_cpu = {k: v for k, v in layer_to_cpu.named_modules()}
231
+ for module_to_cuda_name, module_to_cuda in layer_to_cuda.named_modules():
232
+ if (
233
+ hasattr(module_to_cuda, "weight")
234
+ and module_to_cuda.weight is not None
235
+ and module_to_cuda.__class__.__name__.endswith("Linear")
236
+ ):
237
+ module_to_cpu = modules_to_cpu.get(module_to_cuda_name, None)
238
+ if module_to_cpu is not None and module_to_cpu.weight.shape == module_to_cuda.weight.shape:
239
+ weight_swap_jobs.append(
240
+ (module_to_cpu, module_to_cuda, module_to_cpu.weight.data, module_to_cuda.weight.data)
241
+ )
242
+ else:
243
+ if module_to_cuda.weight.data.device.type != device.type:
244
+ module_to_cuda.weight.data = module_to_cuda.weight.data.to(device)
245
+
246
+ with T.section("synchronize before swap"):
247
+ torch.cuda.current_stream().synchronize() # this prevents the illegal loss value by ensuring offloading layer's calculation is done
248
+
249
+ if not self.use_pinned_memory:
250
+ # Minimize using pinned memory for lower shared GPU RAM usage
251
+ stream = self.stream
252
+ with torch.cuda.stream(stream):
253
+ if self.staging_buffer_a is None:
254
+ # Create staging buffer as pinned memory (as shared GPU ram). We specify device for correct pinning on multi-GPU systems
255
+ self.staging_buffer_a = [
256
+ torch.empty_like(cuda_data_view, device="cpu").pin_memory(device=device)
257
+ for _, _, cuda_data_view, _ in weight_swap_jobs
258
+ ]
259
+ self.staging_buffer_b = [
260
+ torch.empty_like(cuda_data_view, device="cpu").pin_memory(device=device)
261
+ for _, _, cuda_data_view, _ in weight_swap_jobs
262
+ ]
263
+
264
+ # Copy weights to staging buffers and record events
265
+ event_b = None
266
+ for sbuf_a, sbuf_b, (module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view) in zip(
267
+ self.staging_buffer_a, self.staging_buffer_b, weight_swap_jobs
268
+ ):
269
+ # CUDA to staging buffer A, non-blocking copy
270
+ event_a = torch.cuda.Event()
271
+ with T.section("cuda to staging A"):
272
+ sbuf_a.copy_(cuda_data_view.data, non_blocking=True)
273
+ event_a.record(stream)
274
+
275
+ # Wait for staging buffer B to be ready
276
+ if event_b is not None:
277
+ with T.section("wait staging B"):
278
+ event_b.synchronize() # synchronize is needed to wait CPU process. wait_event does not work here because it waits on GPU side only
279
+
280
+ # CPU to staging buffer B, CPU to pinned CPU, synchronous copy. Can overlap with CUDA to staging buffer A
281
+ with T.section("cpu to staging B"):
282
+ # Making this multithreaded does not help, and 'non_blocking=True' does not help either.
283
+ sbuf_b.copy_(module_to_cuda.weight.data) # BOTTLENECK
284
+
285
+ # Wait for staging buffer A to be ready, and CUDA data view can be reused
286
+ with T.section("wait staging A"):
287
+ event_a.synchronize()
288
+
289
+ # Staging buffer B to CUDA, non-blocking copy.
290
+ event_b = torch.cuda.Event()
291
+ with T.section("staging B to CUDA"):
292
+ cuda_data_view.copy_(sbuf_b, non_blocking=True)
293
+ event_b.record(stream)
294
+
295
+ # Staging buffer A to CPU, synchronous copy. Can overlap with staging buffer B to CUDA
296
+ with T.section("staging A to CPU"):
297
+ cpu_data_view.copy_(sbuf_a) # BOTTLENECK
298
+
299
+ for sbuf_a, sbuf_b, (module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view) in zip(
300
+ self.staging_buffer_a, self.staging_buffer_b, weight_swap_jobs
301
+ ):
302
+ # Update references
303
+ module_to_cuda.weight.data = cuda_data_view
304
+ module_to_cpu.weight.data = cpu_data_view
305
+
306
+ sync_event = event_b # final sync event for CPU to CUDA copy
307
+
308
+ else:
309
+ # Use pinned memory for faster transfer between CPU and GPU, but it requires more memory
310
+ if self.pinned_buffer is None:
311
+ with torch.cuda.stream(self.stream):
312
+ # Create pinned buffer as pinned memory (as shared GPU ram). We specify device for correct pinning on multi-GPU systems
313
+ self.pinned_buffer = [
314
+ torch.empty_like(cuda_data_view, device="cpu").pin_memory(device=device)
315
+ for _, _, cuda_data_view, _ in weight_swap_jobs
316
+ ]
317
+ self.stream.synchronize()
318
+ released_pinned_buffer = []
319
+
320
+ events = [torch.cuda.Event() for _ in weight_swap_jobs] # Waiting events for GPU to CPU non-blocking copy
321
+
322
+ # Copy weights to CPU
323
+ for event, module_pin_buf, (module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view) in zip(
324
+ events, self.pinned_buffer, weight_swap_jobs
325
+ ):
326
+ # CUDA to CPU, non-blocking copy
327
+ with torch.cuda.stream(self.stream):
328
+ with T.section("cuda to cpu"):
329
+ module_pin_buf.copy_(cuda_data_view, non_blocking=True)
330
+ event.record(self.stream)
331
+
332
+ # CPU to CUDA
333
+ for event, (module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view) in zip(events, weight_swap_jobs):
334
+ with torch.cuda.stream(self.stream):
335
+ # Wait for cuda_data_view to be ready
336
+ with T.section("wait cpu"):
337
+ self.stream.wait_event(event)
338
+
339
+ # CPU to CUDA, non-blocking copy
340
+ with T.section("cpu to cuda"):
341
+ cuda_data_view.copy_(cpu_data_view, non_blocking=True)
342
+
343
+ # Update references
344
+ for module_pin_buf, (module_to_cpu, module_to_cuda, cuda_data_view, cpu_data_view) in zip(
345
+ self.pinned_buffer, weight_swap_jobs
346
+ ):
347
+ module_to_cuda.weight.data = cuda_data_view
348
+ module_to_cpu.weight.data = module_pin_buf
349
+ released_pinned_buffer.append(cpu_data_view) # CPU data view can be reused as pinned buffer
350
+
351
+ # Reuse released pinned buffers
352
+ if not released_pinned_buffer[0].is_pinned():
353
+ # In first time, we need to create pinned buffers because offloaded weights are not pinned yet
354
+ with torch.cuda.stream(self.stream):
355
+ released_pinned_buffer = [
356
+ torch.empty_like(cuda_data_view, device="cpu").pin_memory(device=device)
357
+ for _, _, cuda_data_view, _ in weight_swap_jobs
358
+ ]
359
+ self.pinned_buffer = released_pinned_buffer
360
+
361
+ sync_event = self.stream.record_event()
362
+
363
+ if debug_print:
364
+ print(f"[{self.block_type}] Weight swap timing at {self.debug_block_count - 1}:")
365
+ for name, total in T.totals.items():
366
+ print(f" {name}: {total * 1000:.2f}ms")
367
+ print(
368
+ f"Overall time: {(time.perf_counter() - T.start_time) * 1000:.2f}ms, total time in sections: {sum(T.totals.values()) * 1000:.2f}ms"
369
+ )
370
+ # print(
371
+ # f"[{self.block_type}] Swapped weights in {time.perf_counter() - start_time:.2f}s. Count of modules swapped: {len(weight_swap_jobs)}"
372
+ # )
373
+
374
+ return sync_event
375
+
376
+ def swap_weight_devices(self, block_to_cpu: nn.Module, block_to_cuda: nn.Module):
377
+ if self.cuda_available:
378
+ sync_event = self.swap_weight_devices_cuda(self.device, block_to_cpu, block_to_cuda)
379
+ else:
380
+ swap_weight_devices_no_cuda(self.device, block_to_cpu, block_to_cuda)
381
+ sync_event = None
382
+ return sync_event
383
+
384
+ def _submit_move_blocks(self, blocks, block_idx_to_cpu, block_idx_to_cuda):
385
+ def move_blocks(bidx_to_cpu, block_to_cpu, bidx_to_cuda, block_to_cuda):
386
+ if self.debug:
387
+ start_time = time.perf_counter()
388
+ print(
389
+ f"[{self.block_type}] Move block {bidx_to_cpu} to CPU and block {bidx_to_cuda} to {'CUDA' if self.cuda_available else 'device'}"
390
+ )
391
+
392
+ dev = self.device.index if self.device.index is not None else torch.cuda.current_device()
393
+ torch.cuda.set_device(dev)
394
+
395
+ sync_event = self.swap_weight_devices(block_to_cpu, block_to_cuda)
396
+
397
+ if self.debug:
398
+ print(
399
+ f"[{self.block_type}] Moved blocks {bidx_to_cpu} to CPU and {bidx_to_cuda} to {'CUDA' if self.cuda_available else 'device'} in {time.perf_counter() - start_time:.2f}s"
400
+ )
401
+ return bidx_to_cpu, bidx_to_cuda, sync_event
402
+
403
+ block_to_cpu = blocks[block_idx_to_cpu]
404
+ block_to_cuda = blocks[block_idx_to_cuda]
405
+
406
+ self.futures[block_idx_to_cuda] = self.thread_pool.submit(
407
+ move_blocks, block_idx_to_cpu, block_to_cpu, block_idx_to_cuda, block_to_cuda
408
+ )
409
+
410
+ def _wait_blocks_move(self, block_idx):
411
+ if block_idx not in self.futures:
412
+ return
413
+
414
+ if self.debug:
415
+ print(f"[{self.block_type}] Wait for block {block_idx}")
416
+ start_time = time.perf_counter()
417
+
418
+ future = self.futures.pop(block_idx)
419
+ _, bidx_to_cuda, sync_event = future.result()
420
+
421
+ assert block_idx == bidx_to_cuda, f"Block index mismatch: {block_idx} != {bidx_to_cuda}"
422
+
423
+ if self.cuda_available and sync_event is not None:
424
+ # this does not wait CPU side, so the log below should be immediate when pinned memory is used
425
+ torch.cuda.current_stream().wait_event(sync_event)
426
+
427
+ if self.debug:
428
+ print(f"[{self.block_type}] Waited for block {block_idx}: {time.perf_counter() - start_time:.2f}s")
429
+
430
+
431
+ class ModelOffloader(Offloader):
432
+ """
433
+ supports forward offloading
434
+ """
435
+
436
+ def __init__(
437
+ self,
438
+ block_type: str,
439
+ blocks: list[nn.Module],
440
+ num_blocks: int,
441
+ blocks_to_swap: int,
442
+ supports_backward: bool,
443
+ device: torch.device,
444
+ use_pinned_memory: bool = False,
445
+ debug: bool = False,
446
+ ):
447
+ super().__init__(block_type, num_blocks, blocks_to_swap, device, use_pinned_memory, debug)
448
+
449
+ self.supports_backward = supports_backward
450
+ self.forward_only = not supports_backward # forward only offloading: can be changed to True for inference
451
+
452
+ if self.supports_backward:
453
+ # register backward hooks
454
+ self.remove_handles = []
455
+ for i, block in enumerate(blocks):
456
+ hook = self.create_backward_hook(blocks, i)
457
+ if hook is not None:
458
+ handle = block.register_full_backward_hook(hook)
459
+ self.remove_handles.append(handle)
460
+
461
+ def set_forward_only(self, forward_only: bool):
462
+ # switching must wait for all pending transfers
463
+ for block_idx in list(self.futures.keys()):
464
+ self._wait_blocks_move(block_idx)
465
+
466
+ self.forward_only = forward_only
467
+
468
+ def __del__(self):
469
+ if self.supports_backward:
470
+ for handle in self.remove_handles:
471
+ handle.remove()
472
+
473
+ def create_backward_hook(self, blocks: list[nn.Module], block_index: int) -> Optional[callable]:
474
+ # -1 for 0-based index
475
+ num_blocks_propagated = self.num_blocks - block_index - 1
476
+ swapping = num_blocks_propagated > 0 and num_blocks_propagated <= self.blocks_to_swap
477
+ waiting = block_index > 0 and block_index <= self.blocks_to_swap
478
+
479
+ if not swapping and not waiting:
480
+ return None
481
+
482
+ # create hook
483
+ block_idx_to_cpu = self.num_blocks - num_blocks_propagated
484
+ block_idx_to_cuda = self.blocks_to_swap - num_blocks_propagated
485
+ block_idx_to_wait = block_index - 1
486
+
487
+ def backward_hook(module, grad_input, grad_output):
488
+ if self.debug:
489
+ print(f"Backward hook for block {block_index}")
490
+
491
+ if swapping:
492
+ self._submit_move_blocks(blocks, block_idx_to_cpu, block_idx_to_cuda)
493
+ if waiting:
494
+ self._wait_blocks_move(block_idx_to_wait)
495
+ return None
496
+
497
+ return backward_hook
498
+
499
+ def prepare_block_devices_before_forward(self, blocks: list[nn.Module]):
500
+ if self.blocks_to_swap is None or self.blocks_to_swap == 0:
501
+ return
502
+
503
+ if self.debug:
504
+ print(f"[{self.block_type}] Prepare block devices before forward")
505
+
506
+ for b in blocks[0 : self.num_blocks - self.blocks_to_swap]:
507
+ b.to(self.device)
508
+ weighs_to_device(b, self.device) # make sure weights are on device
509
+
510
+ cpu_device = torch.device("cpu")
511
+ for b in blocks[self.num_blocks - self.blocks_to_swap :]:
512
+ b.to(self.device) # move block to device first. this makes sure that buffers (non weights) are on the device
513
+ weighs_to_device(b, cpu_device) # make sure weights are on cpu
514
+
515
+ _synchronize_device(self.device)
516
+ _clean_memory_on_device(self.device)
517
+
518
+ def wait_for_block(self, block_idx: int):
519
+ if self.blocks_to_swap is None or self.blocks_to_swap == 0:
520
+ return
521
+ self._wait_blocks_move(block_idx)
522
+
523
+ def submit_move_blocks_forward(self, blocks: list[nn.Module], block_idx: int):
524
+ # check if blocks_to_swap is enabled
525
+ if self.blocks_to_swap is None or self.blocks_to_swap == 0:
526
+ return
527
+
528
+ if not self.forward_only:
529
+ # if backward is enabled, we do not swap blocks in forward pass more than blocks_to_swap, because it should be on GPU
530
+ if block_idx >= self.blocks_to_swap:
531
+ return
532
+ block_idx_to_cpu = block_idx
533
+ block_idx_to_cuda = self.num_blocks - self.blocks_to_swap + block_idx
534
+ block_idx_to_cuda = block_idx_to_cuda % self.num_blocks # this does nothing for backward offloading
535
+ self._submit_move_blocks(blocks, block_idx_to_cpu, block_idx_to_cuda)
536
+ return
537
+
538
+ # We use two strategies here for forward-only offloading:
539
+ # 1. If blocks_to_swap is less than half of num_blocks, we swap the num_blocks blocks without wrapping around.
540
+ # This reduces the number of swaps, so it is especially useful for small blocks_to_swap or lightweight models like Qwen-Image
541
+ # 2. If blocks_to_swap is more than half of num_blocks, we swap the blocks with wrapping around.
542
+ # This is the common strategy used in most offloading implementations. It transfers all blocks in a wrapping manner.
543
+ # This is useful for large blocks_to_swap or heavyweight models like Wan/HunyuanVideo, where the transfer time is less significant compared to computation time.
544
+
545
+ # current block to swap out (to CPU)
546
+ block_idx_to_cpu = block_idx
547
+
548
+ if self.blocks_to_swap < (self.num_blocks // 2):
549
+ # strategy 1: no wrap around
550
+ # If the current block is in the middle blocks that are not swapped, do nothing
551
+ if self.blocks_to_swap <= block_idx < self.num_blocks - self.blocks_to_swap:
552
+ return
553
+ if block_idx < self.blocks_to_swap:
554
+ # move the next block to cuda
555
+ block_idx_to_cuda = (self.num_blocks - self.blocks_to_swap + block_idx) % self.num_blocks
556
+ else:
557
+ # move the previous block to cuda
558
+ block_idx_to_cuda = block_idx - (self.num_blocks - self.blocks_to_swap)
559
+ else:
560
+ # strategy 2: with wrap around
561
+ block_idx_to_cuda = self.num_blocks - self.blocks_to_swap + block_idx
562
+ block_idx_to_cuda = block_idx_to_cuda % self.num_blocks # this works for forward-only offloading
563
+
564
+ self._submit_move_blocks(blocks, block_idx_to_cpu, block_idx_to_cuda)
565
+
566
+
567
+ class _DirectCopier:
568
+ """
569
+ Transfer engine for ``LoRAStreamOffloader`` when the masters are *pinned*: copies a flat pinned host
570
+ buffer straight into a flat device buffer on a private copy stream (async Host->Device only).
571
+
572
+ This is the seam that isolates all CUDA stream/event primitives from the residency logic. The residency
573
+ side only ever calls ``submit`` / ``wait``; ``_StagedCopier`` (pageable master + worker thread) and a
574
+ future non-CUDA device shim plug in behind the same interface without the residency code changing. Keep
575
+ every ``torch.cuda.*`` transfer primitive inside copiers like this one.
576
+ """
577
+
578
+ def __init__(self, device: torch.device, debug: bool = False):
579
+ self.device = device
580
+ self.debug = debug
581
+ self.copy_stream = torch.cuda.Stream(device=device)
582
+ self._events = {} # key -> cuda.Event recording H2D completion
583
+ self._xfer = {} # key -> (ev_a, ev_b) transfer-timing events (debug only)
584
+
585
+ def submit(self, key, dst_flat: torch.Tensor, src_flat: torch.Tensor, gate_event=None):
586
+ """Begin the H2D copy of ``src_flat`` into ``dst_flat``, gated behind ``gate_event`` (compute done with dst)."""
587
+ ev_a = ev_b = None
588
+ with torch.cuda.stream(self.copy_stream):
589
+ if gate_event is not None:
590
+ self.copy_stream.wait_event(gate_event) # do not overwrite a buffer still used by compute
591
+ if self.debug:
592
+ ev_a = torch.cuda.Event(enable_timing=True)
593
+ ev_b = torch.cuda.Event(enable_timing=True)
594
+ ev_a.record(self.copy_stream)
595
+ dst_flat.copy_(src_flat, non_blocking=True) # src is pinned -> async H2D
596
+ if self.debug:
597
+ ev_b.record(self.copy_stream)
598
+ self._xfer[key] = (ev_a, ev_b)
599
+ self._events[key] = self.copy_stream.record_event()
600
+
601
+ def wait(self, key):
602
+ """Return the H2D-completion event for ``key`` (None if nothing is in flight for it)."""
603
+ return self._events.get(key)
604
+
605
+ def pop_xfer_timing(self, key):
606
+ """Pop the (start, end) transfer-timing events recorded for ``key`` (debug only)."""
607
+ return self._xfer.pop(key, None)
608
+
609
+ def sync(self):
610
+ self.copy_stream.synchronize()
611
+
612
+ def reset(self):
613
+ self._events.clear()
614
+ self._xfer.clear()
615
+
616
+
617
+ class _StagedCopier:
618
+ """
619
+ Transfer engine for ``LoRAStreamOffloader`` when the masters are kept in *pageable* host memory: each
620
+ H2D is staged through a small pool of pinned buffers filled by a background worker thread. Same
621
+ ``submit`` / ``wait`` interface as ``_DirectCopier``, so the residency code is identical.
622
+
623
+ Why: pinning every streaming master (what ``_DirectCopier`` needs) can cost tens of GB of non-pageable
624
+ "shared GPU memory" (notably on Windows). Here the masters stay pageable and only ``num_staging`` pinned
625
+ buffers exist. A pageable->device copy is, inside the CUDA driver, already a synchronous serial
626
+ pageable->pinned->device transfer; doing it ourselves on a worker thread lets the pageable->pinned
627
+ memcpy overlap the previous block's pinned->device DMA and -- crucially -- keeps it off the main thread
628
+ so compute kernel launches are never blocked. That is the whole speed difference versus the old
629
+ synchronous pageable path this replaces.
630
+
631
+ Hazards handled:
632
+ - a staging buffer is reused only after the H2D that consumed it has completed (one consume-event per
633
+ buffer; the worker waits on it before overwriting);
634
+ - ``wait`` blocks the caller until the worker has actually enqueued the H2D and recorded its
635
+ completion event, so the residency layer can ``current_stream().wait_event`` it as usual.
636
+ """
637
+
638
+ def __init__(self, device: torch.device, num_staging: int = 2, debug: bool = False):
639
+ self.device = device
640
+ self.num_staging = max(1, num_staging)
641
+ self.debug = debug
642
+ self.copy_stream = torch.cuda.Stream(device=device)
643
+ self._pool = ThreadPoolExecutor(max_workers=1)
644
+ self._futures = {} # key -> Future -> (load_event, (ev_a, ev_b))
645
+ self._xfer = {} # key -> (ev_a, ev_b) transfer-timing events (debug only)
646
+ self._staging = None # list[pinned uint8 tensor] (lazily sized to the flat block)
647
+ self._staging_free = None # list[cuda.Event | None]: the H2D that last consumed each buffer
648
+ self._rr = 0 # round-robin staging index (touched only on the worker thread)
649
+
650
+ def _ensure_staging(self, nbytes: int):
651
+ if self._staging is None:
652
+ self._staging = [torch.empty(nbytes, dtype=torch.uint8).pin_memory(device=self.device) for _ in range(self.num_staging)]
653
+ self._staging_free = [None] * self.num_staging
654
+
655
+ def _run(self, dst_flat: torch.Tensor, src_flat: torch.Tensor, gate_event):
656
+ # runs on the worker thread: pageable->pinned memcpy, then enqueue the async pinned->device H2D
657
+ idx = self._rr
658
+ self._rr = (idx + 1) % self.num_staging
659
+ staging = self._staging[idx]
660
+
661
+ free_ev = self._staging_free[idx]
662
+ if free_ev is not None:
663
+ free_ev.synchronize() # CPU(worker) waits until the prior H2D released this buffer -- off main thread
664
+
665
+ staging.copy_(src_flat) # pageable -> pinned, plain CPU memcpy
666
+
667
+ ev_a = ev_b = None
668
+ with torch.cuda.stream(self.copy_stream):
669
+ if gate_event is not None:
670
+ self.copy_stream.wait_event(gate_event) # do not overwrite a dst still used by compute
671
+ if self.debug:
672
+ ev_a = torch.cuda.Event(enable_timing=True)
673
+ ev_b = torch.cuda.Event(enable_timing=True)
674
+ ev_a.record(self.copy_stream)
675
+ dst_flat.copy_(staging, non_blocking=True) # pinned -> device, async
676
+ if self.debug:
677
+ ev_b.record(self.copy_stream)
678
+ done = self.copy_stream.record_event() # H2D complete == this staging buffer is free again
679
+ self._staging_free[idx] = done
680
+ return done, (ev_a, ev_b)
681
+
682
+ def submit(self, key, dst_flat: torch.Tensor, src_flat: torch.Tensor, gate_event=None):
683
+ self._ensure_staging(src_flat.numel()) # flat uint8 -> numel == byte size
684
+ self._futures[key] = self._pool.submit(self._run, dst_flat, src_flat, gate_event)
685
+
686
+ def wait(self, key):
687
+ fut = self._futures.get(key)
688
+ if fut is None:
689
+ return None
690
+ load_event, xfer = fut.result() # block until the worker enqueued the H2D (tiny if it ran ahead)
691
+ if self.debug and xfer[0] is not None:
692
+ self._xfer[key] = xfer
693
+ return load_event
694
+
695
+ def pop_xfer_timing(self, key):
696
+ return self._xfer.pop(key, None)
697
+
698
+ def _flush(self):
699
+ for fut in list(self._futures.values()):
700
+ fut.result()
701
+
702
+ def sync(self):
703
+ self._flush()
704
+ self.copy_stream.synchronize()
705
+
706
+ def reset(self):
707
+ self._flush()
708
+ self.copy_stream.synchronize() # ensure all in-flight H2D are done before clearing staging state
709
+ self._futures.clear()
710
+ self._xfer.clear()
711
+ if self._staging is not None:
712
+ self._staging_free = [None] * self.num_staging
713
+
714
+ def __del__(self):
715
+ pool = getattr(self, "_pool", None)
716
+ if pool is not None:
717
+ pool.shutdown(wait=False)
718
+
719
+
720
+ class LoRAStreamOffloader:
721
+ """
722
+ H2D-only offloader for training where the base weights are frozen (e.g. LoRA / LoHa / LoKr).
723
+
724
+ The classic block swap (``ModelOffloader``) *exchanges* a block: it copies one block back to the
725
+ CPU (Device->Host) while copying the next one in (Host->Device). When the base weights never change,
726
+ the CPU already holds an identical copy, so the D2H half is pure overhead. This offloader keeps a
727
+ permanent (pinned) master copy of every streamed weight on the CPU and only ever copies Host->Device,
728
+ removing the D2H transfer and the CPU-side staging memcpy entirely.
729
+
730
+ Layout (N = num_blocks, S = blocks_to_swap, B = ring_size):
731
+ - ``N - S`` blocks stay resident on the GPU permanently.
732
+ - ``S`` "streaming" blocks are placed at even intervals; their ``Linear`` weights live on the CPU
733
+ (pinned) as masters and are streamed into a small ring of ``B`` GPU buffers (default 2: one being
734
+ computed on, one being prefetched). Non-Linear params / buffers of streaming blocks stay resident.
735
+
736
+ Mechanics:
737
+ - Because the streamed weights are frozen (no ``.grad`` to preserve), we swap the whole ``module.weight``
738
+ *reference* between its CPU master Parameter and a preallocated GPU ring Parameter, instead of the
739
+ ``.data`` storage trick used by ``ModelOffloader``. No per-step ``cudaMalloc``.
740
+ - All swap weights of a block are packed into a single contiguous (byte) buffer -- one pinned CPU
741
+ buffer per streaming block and one GPU buffer per ring slot -- and the individual weights are
742
+ dtype/shape views into it. Loading a block is therefore a single H2D ``copy_`` instead of one
743
+ per Linear, which removes the per-tensor launch/driver overhead on PCIe/CPU-bound systems.
744
+ - Eviction is free: repoint ``module.weight`` back to the CPU master (no transfer). A ring slot is
745
+ overwritten only when its block is consumed, so the last ``B`` streamed blocks of a pass stay
746
+ resident. On the reverse pass they are exactly the first blocks needed, so the forward<->backward
747
+ turn-around needs no reload.
748
+ - Direction is implicit in the callback: ``submit_move_blocks_forward`` prefetches the block ``B``
749
+ streaming-slots *ahead* (larger index); the backward hook prefetches ``B`` slots *behind* (smaller
750
+ index). No explicit scheduler / no ``prepare_*_before_backward``.
751
+ - ``wait_for_block`` self-heals: if a needed streaming block is not resident in its slot (cold start,
752
+ or an inference pass boundary), it is loaded on demand.
753
+ """
754
+
755
+ def __init__(
756
+ self,
757
+ block_type: str,
758
+ blocks: list[nn.Module],
759
+ num_blocks: int,
760
+ blocks_to_swap: int,
761
+ supports_backward: bool,
762
+ device: torch.device,
763
+ ring_size: int = 2,
764
+ use_pinned_memory: bool = True,
765
+ debug: bool = False,
766
+ ):
767
+ self.block_type = block_type
768
+ self._blocks = blocks
769
+ self.num_blocks = num_blocks # N
770
+ self.blocks_to_swap = blocks_to_swap # requested S
771
+ self.device = device
772
+ self.use_pinned_memory = use_pinned_memory
773
+ self.supports_backward = supports_backward
774
+ self.forward_only = not supports_backward
775
+
776
+ import os
777
+
778
+ if not debug:
779
+ debug = os.getenv("MUSUBI_TUNER_OFFLOADER_DEBUG", "0") == "1"
780
+ self.debug = debug
781
+ self.debug_interval = int(os.getenv("MUSUBI_TUNER_OFFLOADER_DEBUG_INTERVAL", "10")) # steps between prints
782
+
783
+ assert device.type == "cuda", "LoRAStreamOffloader currently supports CUDA only"
784
+
785
+ # ---- streaming placement: S evenly spaced block indices (midpoint formula -> distinct for S <= N) ----
786
+ stream_idx = sorted({((2 * i + 1) * num_blocks) // (2 * blocks_to_swap) for i in range(blocks_to_swap)})
787
+ self.stream_idx = stream_idx
788
+ self.S = len(stream_idx) # actual streaming count (>=1; dedup is a no-op unless S is close to N)
789
+ self.rank = {b: k for k, b in enumerate(stream_idx)} # block_idx -> position in stream_idx
790
+ self.is_stream = [b in self.rank for b in range(num_blocks)]
791
+ self.B = min(ring_size, self.S) # clamp ring to number of streaming blocks
792
+
793
+ # ---- finetuning guard: H2D-only must never lose weight updates ----
794
+ for b in stream_idx:
795
+ for m in self._swap_modules(blocks[b]):
796
+ assert not m.weight.requires_grad, (
797
+ "LoRAStreamOffloader requires frozen base weights (LoRA / no full fine-tune). "
798
+ f"Found a trainable Linear weight in block {b}."
799
+ )
800
+
801
+ # ---- transfer engine: owns the copy stream and all H2D primitives ----
802
+ # pinned masters -> direct async H2D; pageable masters -> stage through a worker thread + pinned pool
803
+ # (low "shared GPU memory" footprint, e.g. on Windows). Both expose the same submit/wait interface.
804
+ if use_pinned_memory:
805
+ self.copier = _DirectCopier(device, debug=self.debug)
806
+ else:
807
+ self.copier = _StagedCopier(device, num_staging=self.B, debug=self.debug)
808
+
809
+ # ---- runtime state (GPU buffers allocated lazily in prepare_block_devices_before_forward) ----
810
+ self.cpu_master = {} # block_idx -> [CPU (pinned) Parameter per swap weight] (views into cpu_flat)
811
+ self.cpu_flat = {} # block_idx -> flat (pinned) uint8 CPU tensor backing the masters
812
+ self.ring_param = None # [slot] -> [GPU nn.Parameter per swap weight] (views into ring_flat)
813
+ self.ring_flat = None # [slot] -> flat uint8 GPU tensor backing the ring params
814
+ self._layout = None # ([byte offset per swap weight], total bytes) shared by all streaming blocks
815
+ self.in_slot = [None] * self.B # slot -> block_idx currently bound to this slot (or None)
816
+ self.free_event = [None] * self.B # slot -> cuda.Event recording when compute finished using the slot
817
+ self._module_cache = {} # block_idx -> [modules with swap weights]
818
+
819
+ self._wait_ctx = "fwd" # context tag for wait/load timing ("fwd" while in the forward loop, "bwd" in hooks)
820
+ if self.debug:
821
+ self._dbg_reset()
822
+
823
+ # ---- backward hooks: only where there is work (prefetch this block, or wait the previous one) ----
824
+ if supports_backward:
825
+ self.remove_handles = []
826
+ for i, block in enumerate(blocks):
827
+ hook = self._create_backward_hook(i)
828
+ if hook is not None:
829
+ self.remove_handles.append(block.register_full_backward_hook(hook))
830
+
831
+ print(
832
+ f"LoRAStreamOffloader[{block_type}]: H2D-only block swap. "
833
+ f"{self.S} streaming / {num_blocks} blocks, ring={self.B}, pinned={use_pinned_memory}. "
834
+ f"streaming indices: {stream_idx}"
835
+ )
836
+
837
+ # ------------------------------------------------------------------ helpers
838
+
839
+ def _swap_modules(self, block: nn.Module) -> list[nn.Module]:
840
+ # same selection rule as ModelOffloader.swap_weight_devices_cuda: Linear layers with a weight
841
+ mods = []
842
+ for _, m in block.named_modules():
843
+ if hasattr(m, "weight") and m.weight is not None and m.__class__.__name__.endswith("Linear"):
844
+ mods.append(m)
845
+ return mods
846
+
847
+ def _modules(self, block_idx: int) -> list[nn.Module]:
848
+ cached = self._module_cache.get(block_idx)
849
+ if cached is None:
850
+ cached = self._swap_modules(self._blocks[block_idx])
851
+ self._module_cache[block_idx] = cached
852
+ return cached
853
+
854
+ def _bind(self, block_idx: int, params: list[nn.Parameter]):
855
+ for m, p in zip(self._modules(block_idx), params):
856
+ m.weight = p
857
+
858
+ @staticmethod
859
+ def _compute_layout(weights: list[torch.Tensor]) -> tuple[list[int], int]:
860
+ """Byte offsets (aligned so ``view(dtype)`` is valid for any element size) and total size of the flat buffer."""
861
+ align = 256
862
+ offsets = []
863
+ total = 0
864
+ for w in weights:
865
+ total = (total + align - 1) // align * align
866
+ offsets.append(total)
867
+ total += w.numel() * w.element_size()
868
+ return offsets, total
869
+
870
+ def _flat_views(self, flat: torch.Tensor, weights: list[torch.Tensor]) -> list[torch.Tensor]:
871
+ """dtype/shape views into a flat uint8 buffer, one per swap weight, following ``self._layout``."""
872
+ offsets, _ = self._layout
873
+ return [flat[off : off + w.numel() * w.element_size()].view(w.dtype).view(w.shape) for off, w in zip(offsets, weights)]
874
+
875
+ def _load(self, rank: int, slot: int, ctx: str = "fwd"):
876
+ """Stream the streaming block at ``rank`` into ring ``slot`` (H2D only), repointing weights."""
877
+ blk = self.stream_idx[rank]
878
+
879
+ # already resident (e.g. ring survived a re-prepare): the slot content is still identical to the
880
+ # immutable CPU master, so just rebind the weights to the ring -- no transfer needed
881
+ if self.in_slot[slot] == blk:
882
+ self._bind(blk, self.ring_param[slot])
883
+ return
884
+
885
+ # evict the slot's current owner: repoint its weights back to the CPU master (no transfer)
886
+ prev = self.in_slot[slot]
887
+ if prev is not None:
888
+ self._bind(prev, self.cpu_master[prev])
889
+
890
+ # single coalesced H2D copy of the whole block, gated behind the slot's free_event (compute done
891
+ # with it). The ring params are views into ring_flat, so the copy bumps their autograd version
892
+ # counter: safe under gradient checkpointing (weights are re-read at recompute time, and a slot is
893
+ # only overwritten after its free_event); without checkpointing autograd raises a version error
894
+ # instead of silently using stale-saved weights -- which the old per-tensor `.data.copy_` hid.
895
+ self.copier.submit(blk, self.ring_flat[slot], self.cpu_flat[blk], self.free_event[slot])
896
+
897
+ self._bind(blk, self.ring_param[slot]) # reference swap (no .data trick, no cudaMalloc)
898
+ self.in_slot[slot] = blk
899
+
900
+ if self.debug:
901
+ c = self._cur[ctx]
902
+ c["loads"] += 1
903
+ xfer = self.copier.pop_xfer_timing(blk)
904
+ if xfer is not None:
905
+ c["xfer_ev"].append(xfer)
906
+
907
+ # ------------------------------------------------------------------ public interface (mirrors ModelOffloader)
908
+
909
+ def set_forward_only(self, forward_only: bool):
910
+ self.copier.sync()
911
+ self.forward_only = forward_only
912
+
913
+ def __del__(self):
914
+ if getattr(self, "supports_backward", False):
915
+ for handle in getattr(self, "remove_handles", []):
916
+ handle.remove()
917
+
918
+ def prepare_block_devices_before_forward(self, blocks: list[nn.Module]):
919
+ if self.S == 0:
920
+ return
921
+
922
+ if self.debug:
923
+ print(f"[{self.block_type}] Prepare block devices before forward (H2D-only)")
924
+
925
+ first_time = not self.cpu_master # device placement is only needed on the very first call
926
+ cpu_device = torch.device("cpu")
927
+ for i, b in enumerate(blocks):
928
+ if not self.is_stream[i]:
929
+ if first_time:
930
+ b.to(self.device) # resident block: buffers + Linear weights on GPU permanently
931
+ weighs_to_device(b, self.device)
932
+ continue
933
+
934
+ if first_time:
935
+ # move the whole block to device (buffers, norms, bias), then pull the swap weights back to
936
+ # the CPU into one flat (pinned) buffer per block as the persistent masters
937
+ b.to(self.device)
938
+ mods = self._modules(i)
939
+ weights = [m.weight.data for m in mods]
940
+ if self._layout is None:
941
+ self._layout = self._compute_layout(weights) # first streaming block defines the shared layout
942
+ flat = torch.empty(self._layout[1], dtype=torch.uint8, device=cpu_device)
943
+ if self.use_pinned_memory:
944
+ flat = flat.pin_memory(device=self.device)
945
+ master = []
946
+ for m, view in zip(mods, self._flat_views(flat, weights)):
947
+ view.copy_(m.weight.data) # one-time D2H into the flat master
948
+ m.weight.data = view
949
+ master.append(m.weight) # keep the original Parameter object as the persistent master
950
+ self.cpu_flat[i] = flat
951
+ self.cpu_master[i] = master
952
+ else:
953
+ # re-prepare (e.g. around in-training sampling): the non-swap parts are already on the device
954
+ # and the CPU masters have stayed on the CPU throughout, so just repoint weights to the masters.
955
+ # IMPORTANT: do NOT call b.to(device) here -- it would drag the CPU masters onto the GPU and
956
+ # they would never be released (every swap block would end up resident).
957
+ self._bind(i, self.cpu_master[i])
958
+
959
+ # validate homogeneous streaming blocks (shared ring template)
960
+ template = self.cpu_master[self.stream_idx[0]]
961
+ for b in self.stream_idx[1:]:
962
+ assert len(self.cpu_master[b]) == len(template), f"block {b} has a different number of swap weights"
963
+ for p, t in zip(self.cpu_master[b], template):
964
+ assert p.data.shape == t.data.shape and p.data.dtype == t.data.dtype, (
965
+ f"block {b} swap-weight shape/dtype differs from the streaming template"
966
+ )
967
+
968
+ # preallocate the GPU ring (once); copies happen into these flat buffers, never reallocated
969
+ if self.ring_param is None:
970
+ template_weights = [p.data for p in template]
971
+ self.ring_flat = [torch.empty(self._layout[1], dtype=torch.uint8, device=self.device) for _ in range(self.B)]
972
+ self.ring_param = [
973
+ [nn.Parameter(view, requires_grad=False) for view in self._flat_views(flat, template_weights)]
974
+ for flat in self.ring_flat
975
+ ]
976
+
977
+ # reset transfer state and preload the first B streaming blocks. in_slot deliberately survives:
978
+ # after a completed backward pass the ring already holds ranks 0..B-1, so these loads become
979
+ # no-op rebinds (see _load) instead of redundant cold-start transfers.
980
+ self.free_event = [None] * self.B
981
+ self.copier.reset()
982
+ for k in range(self.B):
983
+ self._load(k, k)
984
+
985
+ _synchronize_device(self.device)
986
+ _clean_memory_on_device(self.device)
987
+
988
+ def wait_for_block(self, block_idx: int):
989
+ if self.S == 0 or not self.is_stream[block_idx]:
990
+ return
991
+ ctx = self._wait_ctx
992
+ if self.debug and ctx == "fwd" and block_idx == self.stream_idx[0]:
993
+ self._dbg_boundary() # a forward pass is starting -> finalize the previous step's stats
994
+ j = self.rank[block_idx]
995
+ slot = j % self.B
996
+ if self.in_slot[slot] != block_idx:
997
+ # self-healing: not resident (cold start / inference pass boundary / same-direction restart)
998
+ if self.debug:
999
+ self._cur[ctx]["self_heal"] += 1
1000
+ self._load(j, slot, ctx)
1001
+ ev = self.copier.wait(block_idx)
1002
+ if ev is None:
1003
+ return
1004
+ if not self.debug:
1005
+ torch.cuda.current_stream().wait_event(ev) # compute waits for H2D (GPU-side, does not block CPU)
1006
+ return
1007
+ # debug: measure the actual GPU stall (idle time the compute stream spends on wait_event)
1008
+ s = torch.cuda.current_stream()
1009
+ c = self._cur[ctx]
1010
+ c["waits"] += 1
1011
+ if not ev.query():
1012
+ c["not_ready"] += 1 # H2D not finished when compute reached it -> a real stall (cheap, no sync)
1013
+ ev_a = torch.cuda.Event(enable_timing=True)
1014
+ ev_b = torch.cuda.Event(enable_timing=True)
1015
+ ev_a.record(s)
1016
+ s.wait_event(ev)
1017
+ ev_b.record(s)
1018
+ c["stall_ev"].append((ev_a, ev_b))
1019
+ # staged copier records its transfer-timing events on the worker thread, so they only become
1020
+ # available once wait() has joined it; the direct copier already popped them in _load (returns None)
1021
+ xfer = self.copier.pop_xfer_timing(block_idx)
1022
+ if xfer is not None:
1023
+ c["xfer_ev"].append(xfer)
1024
+
1025
+ def submit_move_blocks_forward(self, blocks: list[nn.Module], block_idx: int):
1026
+ # consumed block_idx in the forward pass: free its slot and prefetch the block B streaming-slots ahead
1027
+ if self.S == 0 or not self.is_stream[block_idx]:
1028
+ return
1029
+ j = self.rank[block_idx]
1030
+ self.free_event[j % self.B] = torch.cuda.current_stream().record_event()
1031
+ if j + self.B < self.S:
1032
+ self._load(j + self.B, (j + self.B) % self.B, "fwd") # same slot as rank j; evicts rank j
1033
+ elif self.forward_only and j == self.S - 1 and self.S > self.B:
1034
+ # inference: no backward pass will consume the retained tail, and the next forward pass starts
1035
+ # from rank 0 again. Wrap around: all tail slots are free once the last streaming block is
1036
+ # consumed, so preload the first B ranks now. The H2D overlaps with the remaining (resident)
1037
+ # blocks and the output layers, instead of stalling at the start of the next pass (self-heal).
1038
+ for k in range(self.B):
1039
+ self._load(k, k, "fwd")
1040
+
1041
+ def _create_backward_hook(self, block_index: int):
1042
+ prefetch = self.is_stream[block_index]
1043
+ wait_prev = block_index - 1 >= 0 and self.is_stream[block_index - 1]
1044
+ if not prefetch and not wait_prev:
1045
+ return None
1046
+
1047
+ def backward_hook(module, grad_input, grad_output):
1048
+ if prefetch:
1049
+ # consumed block_index in backward: free its slot and prefetch B streaming-slots behind
1050
+ j = self.rank[block_index]
1051
+ self.free_event[j % self.B] = torch.cuda.current_stream().record_event()
1052
+ if j - self.B >= 0:
1053
+ self._load(j - self.B, (j - self.B) % self.B, "bwd") # same slot as rank j; evicts rank j
1054
+ if wait_prev:
1055
+ self._wait_ctx = "bwd"
1056
+ self.wait_for_block(block_index - 1) # ensure the next block to run is ready
1057
+ self._wait_ctx = "fwd"
1058
+ return None
1059
+
1060
+ return backward_hook
1061
+
1062
+ # ------------------------------------------------------------------ debug timing
1063
+
1064
+ def _dbg_reset(self):
1065
+ self._dbg_empty = lambda: {"stall_ev": [], "xfer_ev": [], "waits": 0, "not_ready": 0, "self_heal": 0, "loads": 0}
1066
+ self._cur = {"fwd": self._dbg_empty(), "bwd": self._dbg_empty()}
1067
+ self._roll = {"fwd": defaultdict(float), "bwd": defaultdict(float)}
1068
+ self._step = 0
1069
+
1070
+ def _dbg_boundary(self):
1071
+ # finalize the events accumulated for the step that just ended, then print every debug_interval steps
1072
+ if not any(self._cur[c]["waits"] or self._cur[c]["loads"] for c in ("fwd", "bwd")):
1073
+ return # very first forward: nothing finished yet
1074
+ torch.cuda.synchronize() # ensure timing events are complete (debug only)
1075
+ for ctx in ("fwd", "bwd"):
1076
+ c, r = self._cur[ctx], self._roll[ctx]
1077
+ r["stall_ms"] += sum(a.elapsed_time(b) for a, b in c["stall_ev"])
1078
+ r["xfer_ms"] += sum(a.elapsed_time(b) for a, b in c["xfer_ev"])
1079
+ for k in ("waits", "not_ready", "self_heal", "loads"):
1080
+ r[k] += c[k]
1081
+ self._cur[ctx] = self._dbg_empty()
1082
+ self._step += 1
1083
+ if self._step % self.debug_interval == 0:
1084
+ self._dbg_print()
1085
+
1086
+ def _dbg_print(self):
1087
+ n = self.debug_interval
1088
+ print(f"[{self.block_type}] H2D-only timing (avg over {n} steps):")
1089
+ for ctx in ("fwd", "bwd"):
1090
+ r = self._roll[ctx]
1091
+ print(
1092
+ f" {ctx}: stall {r['stall_ms'] / n:6.2f} ms/step, H2D {r['xfer_ms'] / n:6.2f} ms/step"
1093
+ f" | waits {r['waits'] / n:.1f}, not-ready {r['not_ready'] / n:.1f},"
1094
+ f" self-heal {r['self_heal'] / n:.1f}, loads {r['loads'] / n:.1f}"
1095
+ )
1096
+ self._roll[ctx] = defaultdict(float)
musubi_tuner/modules/fp8_optimization_utils.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Optional, Union
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+ import logging
8
+
9
+ from tqdm import tqdm
10
+
11
+ from musubi_tuner.utils.safetensors_utils import MemoryEfficientSafeOpen, TensorWeightAdapter, WeightTransformHooks
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logging.basicConfig(level=logging.INFO)
15
+
16
+ from musubi_tuner.utils.device_utils import clean_memory_on_device
17
+
18
+
19
+ def calculate_fp8_maxval(exp_bits=4, mantissa_bits=3, sign_bits=1):
20
+ """
21
+ Calculate the maximum representable value in FP8 format.
22
+ Default is E4M3 format (4-bit exponent, 3-bit mantissa, 1-bit sign). Only supports E4M3 and E5M2 with sign bit.
23
+
24
+ Args:
25
+ exp_bits (int): Number of exponent bits
26
+ mantissa_bits (int): Number of mantissa bits
27
+ sign_bits (int): Number of sign bits (0 or 1)
28
+
29
+ Returns:
30
+ float: Maximum value representable in FP8 format
31
+ """
32
+ assert exp_bits + mantissa_bits + sign_bits == 8, "Total bits must be 8"
33
+ if exp_bits == 4 and mantissa_bits == 3 and sign_bits == 1:
34
+ return torch.finfo(torch.float8_e4m3fn).max
35
+ elif exp_bits == 5 and mantissa_bits == 2 and sign_bits == 1:
36
+ return torch.finfo(torch.float8_e5m2).max
37
+ else:
38
+ raise ValueError(f"Unsupported FP8 format: E{exp_bits}M{mantissa_bits} with sign_bits={sign_bits}")
39
+
40
+
41
+ # The following is a manual calculation method (wrong implementation for E5M2), kept for reference.
42
+ """
43
+ # Calculate exponent bias
44
+ bias = 2 ** (exp_bits - 1) - 1
45
+
46
+ # Calculate maximum mantissa value
47
+ mantissa_max = 1.0
48
+ for i in range(mantissa_bits - 1):
49
+ mantissa_max += 2 ** -(i + 1)
50
+
51
+ # Calculate maximum value
52
+ max_value = mantissa_max * (2 ** (2**exp_bits - 1 - bias))
53
+
54
+ return max_value
55
+ """
56
+
57
+
58
+ def quantize_fp8(tensor, scale, fp8_dtype, max_value, min_value):
59
+ """
60
+ Quantize a tensor to FP8 format using PyTorch's native FP8 dtype support.
61
+
62
+ Args:
63
+ tensor (torch.Tensor): Tensor to quantize
64
+ scale (float or torch.Tensor): Scale factor
65
+ fp8_dtype (torch.dtype): Target FP8 dtype (torch.float8_e4m3fn or torch.float8_e5m2)
66
+ max_value (float): Maximum representable value in FP8
67
+ min_value (float): Minimum representable value in FP8
68
+
69
+ Returns:
70
+ torch.Tensor: Quantized tensor in FP8 format
71
+ """
72
+ tensor = tensor.to(torch.float32) # ensure tensor is in float32 for division
73
+
74
+ # Create scaled tensor
75
+ tensor = torch.div(tensor, scale).nan_to_num_(0.0) # handle NaN values, equivalent to nonzero_mask in previous function
76
+
77
+ # Clamp tensor to range
78
+ tensor = tensor.clamp_(min=min_value, max=max_value)
79
+
80
+ # Convert to FP8 dtype
81
+ tensor = tensor.to(fp8_dtype)
82
+
83
+ return tensor
84
+
85
+
86
+ def optimize_state_dict_with_fp8(
87
+ state_dict: dict,
88
+ calc_device: Union[str, torch.device],
89
+ target_layer_keys: Optional[list[str]] = None,
90
+ exclude_layer_keys: Optional[list[str]] = None,
91
+ exp_bits: int = 4,
92
+ mantissa_bits: int = 3,
93
+ move_to_device: bool = False,
94
+ quantization_mode: str = "block",
95
+ block_size: Optional[int] = 64,
96
+ ):
97
+ """
98
+ Optimize Linear layer weights in a model's state dict to FP8 format. The state dict is modified in-place.
99
+ This function is a static version of load_safetensors_with_fp8_optimization without loading from files.
100
+
101
+ Args:
102
+ state_dict (dict): State dict to optimize, replaced in-place
103
+ calc_device (str): Device to quantize tensors on
104
+ target_layer_keys (list, optional): Layer key patterns to target (None for all Linear layers)
105
+ exclude_layer_keys (list, optional): Layer key patterns to exclude
106
+ exp_bits (int): Number of exponent bits
107
+ mantissa_bits (int): Number of mantissa bits
108
+ move_to_device (bool): Move optimized tensors to the calculating device
109
+
110
+ Returns:
111
+ dict: FP8 optimized state dict
112
+ """
113
+ if exp_bits == 4 and mantissa_bits == 3:
114
+ fp8_dtype = torch.float8_e4m3fn
115
+ elif exp_bits == 5 and mantissa_bits == 2:
116
+ fp8_dtype = torch.float8_e5m2
117
+ else:
118
+ raise ValueError(f"Unsupported FP8 format: E{exp_bits}M{mantissa_bits}")
119
+
120
+ # Calculate FP8 max value
121
+ max_value = calculate_fp8_maxval(exp_bits, mantissa_bits)
122
+ min_value = -max_value # this function supports only signed FP8
123
+
124
+ # Create optimized state dict
125
+ optimized_count = 0
126
+
127
+ # Enumerate tarket keys
128
+ target_state_dict_keys = []
129
+ for key in state_dict.keys():
130
+ # Check if it's a weight key and matches target patterns
131
+ is_target = (target_layer_keys is None or any(pattern in key for pattern in target_layer_keys)) and key.endswith(".weight")
132
+ is_excluded = exclude_layer_keys is not None and any(pattern in key for pattern in exclude_layer_keys)
133
+ is_target = is_target and not is_excluded
134
+
135
+ if is_target and isinstance(state_dict[key], torch.Tensor):
136
+ target_state_dict_keys.append(key)
137
+
138
+ # Process each key
139
+ for key in tqdm(target_state_dict_keys):
140
+ value = state_dict[key]
141
+
142
+ # Save original device and dtype
143
+ original_device = value.device
144
+ original_dtype = value.dtype
145
+
146
+ # Move to calculation device
147
+ if calc_device is not None:
148
+ value = value.to(calc_device)
149
+
150
+ quantized_weight, scale_tensor = quantize_weight(key, value, fp8_dtype, max_value, min_value, quantization_mode, block_size)
151
+
152
+ # Add to state dict using original key for weight and new key for scale
153
+ fp8_key = key # Maintain original key
154
+ scale_key = key.replace(".weight", ".scale_weight")
155
+
156
+ if not move_to_device:
157
+ quantized_weight = quantized_weight.to(original_device)
158
+
159
+ # keep scale shape: [1] or [out,1] or [out, num_blocks, 1]. We can determine the quantization mode from the shape of scale_weight in the patched model.
160
+ scale_tensor = scale_tensor.to(dtype=original_dtype, device=quantized_weight.device)
161
+
162
+ state_dict[fp8_key] = quantized_weight
163
+ state_dict[scale_key] = scale_tensor
164
+
165
+ optimized_count += 1
166
+
167
+ if calc_device is not None: # optimized_count % 10 == 0 and
168
+ # free memory on calculation device
169
+ clean_memory_on_device(calc_device)
170
+
171
+ logger.info(f"Number of optimized Linear layers: {optimized_count}")
172
+ return state_dict
173
+
174
+
175
+ def quantize_weight(
176
+ key: str,
177
+ tensor: torch.Tensor,
178
+ fp8_dtype: torch.dtype,
179
+ max_value: float,
180
+ min_value: float,
181
+ quantization_mode: str = "block",
182
+ block_size: int = 64,
183
+ ):
184
+ original_shape = tensor.shape
185
+
186
+ # Determine quantization mode
187
+ if quantization_mode == "block":
188
+ if tensor.ndim != 2:
189
+ quantization_mode = "tensor" # fallback to per-tensor
190
+ else:
191
+ out_features, in_features = tensor.shape
192
+ if in_features % block_size != 0:
193
+ quantization_mode = "channel" # fallback to per-channel
194
+ logger.warning(
195
+ f"Layer {key} with shape {tensor.shape} is not divisible by block_size {block_size}, fallback to per-channel quantization."
196
+ )
197
+ else:
198
+ num_blocks = in_features // block_size
199
+ tensor = tensor.contiguous().view(out_features, num_blocks, block_size) # [out, num_blocks, block_size]
200
+ elif quantization_mode == "channel":
201
+ if tensor.ndim != 2:
202
+ quantization_mode = "tensor" # fallback to per-tensor
203
+
204
+ # Calculate scale factor (per-tensor or per-output-channel with percentile or max)
205
+ # value shape is expected to be [out_features, in_features] for Linear weights
206
+ if quantization_mode == "channel" or quantization_mode == "block":
207
+ # row-wise percentile to avoid being dominated by outliers
208
+ # result shape: [out_features, 1] or [out_features, num_blocks, 1]
209
+ scale_dim = 1 if quantization_mode == "channel" else 2
210
+ abs_w = torch.abs(tensor)
211
+
212
+ # shape: [out_features, 1] or [out_features, num_blocks, 1]
213
+ row_max = torch.max(abs_w, dim=scale_dim, keepdim=True).values
214
+ scale = row_max / max_value
215
+
216
+ else:
217
+ # per-tensor
218
+ tensor_max = torch.max(torch.abs(tensor).view(-1))
219
+ scale = tensor_max / max_value
220
+
221
+ # print(f"Optimizing {key} with scale: {scale}")
222
+
223
+ # numerical safety
224
+ scale = torch.clamp(scale, min=1e-8)
225
+ scale = scale.to(torch.float32) # ensure scale is in float32 for division
226
+
227
+ # Quantize weight to FP8 (scale can be scalar or [out,1], broadcasting works)
228
+ quantized_weight = quantize_fp8(tensor, scale, fp8_dtype, max_value, min_value)
229
+
230
+ # If block-wise, restore original shape
231
+ if quantization_mode == "block":
232
+ quantized_weight = quantized_weight.view(original_shape) # restore to original shape [out, in]
233
+
234
+ return quantized_weight, scale
235
+
236
+
237
+ def load_safetensors_with_fp8_optimization(
238
+ model_files: List[str],
239
+ calc_device: Union[str, torch.device],
240
+ target_layer_keys=None,
241
+ exclude_layer_keys=None,
242
+ exp_bits=4,
243
+ mantissa_bits=3,
244
+ move_to_device=False,
245
+ weight_hook=None,
246
+ quantization_mode: str = "block",
247
+ block_size: Optional[int] = 64,
248
+ disable_numpy_memmap: bool = False,
249
+ weight_transform_hooks: Optional[WeightTransformHooks] = None,
250
+ allow_prequantized_fp8: bool = False,
251
+ ) -> dict:
252
+ """
253
+ Load weight tensors from safetensors files and merge LoRA weights into the state dict with explicit FP8 optimization.
254
+
255
+ Args:
256
+ model_files (list[str]): List of model files to load
257
+ calc_device (str or torch.device): Device to quantize tensors on
258
+ target_layer_keys (list, optional): Layer key patterns to target for optimization (None for all Linear layers)
259
+ exclude_layer_keys (list, optional): Layer key patterns to exclude from optimization
260
+ exp_bits (int): Number of exponent bits
261
+ mantissa_bits (int): Number of mantissa bits
262
+ move_to_device (bool): Move optimized tensors to the calculating device
263
+ weight_hook (callable, optional): Function to apply to each weight tensor before optimization
264
+ quantization_mode (str): Quantization mode, "tensor", "channel", or "block"
265
+ block_size (int, optional): Block size for block-wise quantization (used if quantization_mode is "block")
266
+ disable_numpy_memmap (bool): Disable numpy memmap when loading safetensors
267
+ weight_transform_hooks (WeightTransformHooks, optional): Hooks for weight transformation during loading
268
+ allow_prequantized_fp8 (bool): If True, target weights that are already FP8 are kept as-is instead of
269
+ raising; their scale is expected to be supplied separately (e.g. via weight_transform_hooks).
270
+
271
+ Returns:
272
+ dict: FP8 optimized state dict
273
+ """
274
+ if exp_bits == 4 and mantissa_bits == 3:
275
+ fp8_dtype = torch.float8_e4m3fn
276
+ elif exp_bits == 5 and mantissa_bits == 2:
277
+ fp8_dtype = torch.float8_e5m2
278
+ else:
279
+ raise ValueError(f"Unsupported FP8 format: E{exp_bits}M{mantissa_bits}")
280
+
281
+ # Calculate FP8 max value
282
+ max_value = calculate_fp8_maxval(exp_bits, mantissa_bits)
283
+ min_value = -max_value # this function supports only signed FP8
284
+
285
+ # Define function to determine if a key is a target key. target means fp8 optimization, not for weight hook.
286
+ def is_target_key(key):
287
+ # Check if weight key matches target patterns and does not match exclude patterns
288
+ is_target = (target_layer_keys is None or any(pattern in key for pattern in target_layer_keys)) and key.endswith(".weight")
289
+ is_excluded = exclude_layer_keys is not None and any(pattern in key for pattern in exclude_layer_keys)
290
+ return is_target and not is_excluded
291
+
292
+ # Create optimized state dict
293
+ optimized_count = 0
294
+
295
+ # Process each file
296
+ state_dict = {}
297
+ for model_file in model_files:
298
+ with MemoryEfficientSafeOpen(model_file, disable_numpy_memmap=disable_numpy_memmap) as original_f:
299
+ f = TensorWeightAdapter(weight_transform_hooks, original_f) if weight_transform_hooks is not None else original_f
300
+
301
+ keys = f.keys()
302
+ for key in tqdm(keys, desc=f"Loading {os.path.basename(model_file)}", unit="key"):
303
+ value = f.get_tensor(key)
304
+
305
+ # Save original device
306
+ original_device = value.device # usually cpu
307
+
308
+ if weight_hook is not None:
309
+ # Apply weight hook if provided
310
+ value = weight_hook(key, value, keep_on_calc_device=(calc_device is not None))
311
+
312
+ if not is_target_key(key):
313
+ target_device = calc_device if (calc_device is not None and move_to_device) else original_device
314
+ value = value.to(target_device)
315
+ state_dict[key] = value
316
+ continue
317
+
318
+ # Move to calculation device
319
+ if calc_device is not None:
320
+ value = value.to(calc_device)
321
+
322
+ original_dtype = value.dtype
323
+ if original_dtype.itemsize == 1:
324
+ if allow_prequantized_fp8 and original_dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
325
+ # Weight is already FP8-quantized (e.g. a pre-quantized ComfyUI checkpoint).
326
+ # Keep it as-is without re-quantizing; the matching scale is supplied separately
327
+ # (e.g. renamed to `.scale_weight` via weight_transform_hooks).
328
+ if not move_to_device:
329
+ value = value.to(original_device)
330
+ state_dict[key] = value
331
+ optimized_count += 1
332
+ continue
333
+ raise ValueError(
334
+ f"Layer {key} is already in {original_dtype} format. `--fp8_scaled` optimization should not be applied. Please use fp16/bf16/float32 model weights."
335
+ + f" / レイヤー {key} は既に{original_dtype}形式です。`--fp8_scaled` 最適化は適用できません。FP16/BF16/Float32のモデル重みを使用してください。"
336
+ )
337
+ quantized_weight, scale_tensor = quantize_weight(
338
+ key, value, fp8_dtype, max_value, min_value, quantization_mode, block_size
339
+ )
340
+
341
+ # Add to state dict using original key for weight and new key for scale
342
+ fp8_key = key # Maintain original key
343
+ scale_key = key.replace(".weight", ".scale_weight")
344
+ assert fp8_key != scale_key, "FP8 key and scale key must be different"
345
+
346
+ if not move_to_device:
347
+ quantized_weight = quantized_weight.to(original_device)
348
+
349
+ # keep scale shape: [1] or [out,1] or [out, num_blocks, 1]. We can determine the quantization mode from the shape of scale_weight in the patched model.
350
+ scale_tensor = scale_tensor.to(dtype=original_dtype, device=quantized_weight.device)
351
+
352
+ state_dict[fp8_key] = quantized_weight
353
+ state_dict[scale_key] = scale_tensor
354
+
355
+ optimized_count += 1
356
+
357
+ if calc_device is not None and optimized_count % 10 == 0:
358
+ # free memory on calculation device
359
+ clean_memory_on_device(calc_device)
360
+
361
+ logger.info(f"Number of optimized Linear layers: {optimized_count}")
362
+ return state_dict
363
+
364
+
365
+ def fp8_linear_forward_patch(self: nn.Linear, x, use_scaled_mm=False, max_value=None):
366
+ """
367
+ Patched forward method for Linear layers with FP8 weights.
368
+
369
+ Args:
370
+ self: Linear layer instance
371
+ x (torch.Tensor): Input tensor
372
+ use_scaled_mm (bool): Use scaled_mm for FP8 Linear layers, requires SM 8.9+ (RTX 40 series)
373
+ max_value (float): Maximum value for FP8 quantization. If None, no quantization is applied for input tensor.
374
+
375
+ Returns:
376
+ torch.Tensor: Result of linear transformation
377
+ """
378
+ if use_scaled_mm:
379
+ # **not tested**
380
+ # _scaled_mm only works for per-tensor scale for now (per-channel scale does not work in certain cases)
381
+ if self.scale_weight.ndim != 1:
382
+ raise ValueError("scaled_mm only supports per-tensor scale_weight for now.")
383
+
384
+ input_dtype = x.dtype
385
+ original_weight_dtype = self.scale_weight.dtype
386
+ target_dtype = self.weight.dtype
387
+ # assert x.ndim == 3, "Input tensor must be 3D (batch_size, seq_len, hidden_dim)"
388
+
389
+ if max_value is None:
390
+ # no input quantization
391
+ scale_x = torch.tensor(1.0, dtype=torch.float32, device=x.device)
392
+ else:
393
+ # calculate scale factor for input tensor
394
+ scale_x = (torch.max(torch.abs(x.flatten())) / max_value).to(torch.float32)
395
+
396
+ # quantize input tensor to FP8: this seems to consume a lot of memory
397
+ fp8_max_value = torch.finfo(target_dtype).max
398
+ fp8_min_value = torch.finfo(target_dtype).min
399
+ x = quantize_fp8(x, scale_x, target_dtype, fp8_max_value, fp8_min_value)
400
+
401
+ original_shape = x.shape
402
+ x = x.reshape(-1, x.shape[-1]).to(target_dtype)
403
+
404
+ weight = self.weight.t()
405
+ scale_weight = self.scale_weight.to(torch.float32)
406
+
407
+ if self.bias is not None:
408
+ # float32 is not supported with bias in scaled_mm
409
+ o = torch._scaled_mm(x, weight, out_dtype=original_weight_dtype, bias=self.bias, scale_a=scale_x, scale_b=scale_weight)
410
+ else:
411
+ o = torch._scaled_mm(x, weight, out_dtype=input_dtype, scale_a=scale_x, scale_b=scale_weight)
412
+
413
+ o = o.reshape(original_shape[0], original_shape[1], -1) if len(original_shape) == 3 else o.reshape(original_shape[0], -1)
414
+ return o.to(input_dtype)
415
+
416
+ else:
417
+ # Dequantize the weight
418
+ original_dtype = self.scale_weight.dtype
419
+ if self.scale_weight.ndim < 3:
420
+ # per-tensor or per-channel quantization, we can broadcast
421
+ dequantized_weight = self.weight.to(original_dtype) * self.scale_weight
422
+ else:
423
+ # block-wise quantization, need to reshape weight to match scale shape for broadcasting
424
+ out_features, num_blocks, _ = self.scale_weight.shape
425
+ dequantized_weight = self.weight.to(original_dtype).contiguous().view(out_features, num_blocks, -1)
426
+ dequantized_weight = dequantized_weight * self.scale_weight
427
+ dequantized_weight = dequantized_weight.view(self.weight.shape)
428
+
429
+ # Perform linear transformation
430
+ if self.bias is not None:
431
+ output = F.linear(x, dequantized_weight, self.bias)
432
+ else:
433
+ output = F.linear(x, dequantized_weight)
434
+
435
+ return output
436
+
437
+
438
+ def apply_fp8_monkey_patch(model, optimized_state_dict, use_scaled_mm=False):
439
+ """
440
+ Apply monkey patching to a model using FP8 optimized state dict.
441
+
442
+ Args:
443
+ model (nn.Module): Model instance to patch
444
+ optimized_state_dict (dict): FP8 optimized state dict
445
+ use_scaled_mm (bool): Use scaled_mm for FP8 Linear layers, requires SM 8.9+ (RTX 40 series)
446
+
447
+ Returns:
448
+ nn.Module: The patched model (same instance, modified in-place)
449
+ """
450
+ # # Calculate FP8 float8_e5m2 max value
451
+ # max_value = calculate_fp8_maxval(5, 2)
452
+ max_value = None # do not quantize input tensor
453
+
454
+ # Find all scale keys to identify FP8-optimized layers
455
+ scale_keys = [k for k in optimized_state_dict.keys() if k.endswith(".scale_weight")]
456
+
457
+ # Enumerate patched layers
458
+ patched_module_paths = set()
459
+ scale_shape_info = {}
460
+ for scale_key in scale_keys:
461
+ # Extract module path from scale key (remove .scale_weight)
462
+ module_path = scale_key.rsplit(".scale_weight", 1)[0]
463
+ patched_module_paths.add(module_path)
464
+
465
+ # Store scale shape information
466
+ scale_shape_info[module_path] = optimized_state_dict[scale_key].shape
467
+
468
+ patched_count = 0
469
+
470
+ # Apply monkey patch to each layer with FP8 weights
471
+ for name, module in model.named_modules():
472
+ # Check if this module has a corresponding scale_weight
473
+ has_scale = name in patched_module_paths
474
+
475
+ # Apply patch if it's a Linear layer with FP8 scale
476
+ if isinstance(module, nn.Linear) and has_scale:
477
+ # register the scale_weight as a buffer to load the state_dict
478
+ # module.register_buffer("scale_weight", torch.tensor(1.0, dtype=module.weight.dtype))
479
+ scale_shape = scale_shape_info[name]
480
+ module.register_buffer("scale_weight", torch.ones(scale_shape, dtype=module.weight.dtype))
481
+
482
+ # Create a new forward method with the patched version.
483
+ def new_forward(self, x):
484
+ return fp8_linear_forward_patch(self, x, use_scaled_mm, max_value)
485
+
486
+ # Bind method to module
487
+ module.forward = new_forward.__get__(module, type(module))
488
+
489
+ patched_count += 1
490
+
491
+ logger.info(f"Number of monkey-patched Linear layers: {patched_count}")
492
+ return model
493
+
494
+
495
+ # Example usage
496
+ def example_usage():
497
+ # Small test model
498
+ class TestModel(nn.Module):
499
+ def __init__(self):
500
+ super().__init__()
501
+ fc1 = nn.Linear(768, 3072)
502
+ act1 = nn.GELU()
503
+ fc2 = nn.Linear(3072, 768)
504
+ act2 = nn.GELU()
505
+ fc3 = nn.Linear(768, 768)
506
+
507
+ # Set layer names for testing
508
+ self.single_blocks = nn.ModuleList([fc1, act1, fc2, act2, fc3])
509
+
510
+ self.fc4 = nn.Linear(768, 128)
511
+
512
+ def forward(self, x):
513
+ for layer in self.single_blocks:
514
+ x = layer(x)
515
+ x = self.fc4(x)
516
+ return x
517
+
518
+ # Instantiate model
519
+ test_model = TestModel()
520
+ test_model.to(torch.float16) # convert to FP16 for testing
521
+
522
+ # Test input tensor
523
+ test_input = torch.randn(1, 768, dtype=torch.float16)
524
+
525
+ # Calculate output before optimization
526
+ with torch.no_grad():
527
+ original_output = test_model(test_input)
528
+ print("original output", original_output[0, :5])
529
+
530
+ # Get state dict
531
+ state_dict = test_model.state_dict()
532
+
533
+ # Apply FP8 optimization to state dict
534
+ cuda_device = torch.device("cuda")
535
+ optimized_state_dict = optimize_state_dict_with_fp8(state_dict, cuda_device, ["single_blocks"], ["2"])
536
+
537
+ # Apply monkey patching to the model
538
+ optimized_model = TestModel() # re-instantiate model
539
+ optimized_model.to(torch.float16) # convert to FP16 for testing
540
+ apply_fp8_monkey_patch(optimized_model, optimized_state_dict)
541
+
542
+ # Load optimized state dict
543
+ optimized_model.load_state_dict(optimized_state_dict, strict=True, assign=True) # assign=True to load buffer
544
+
545
+ # Calculate output after optimization
546
+ with torch.no_grad():
547
+ optimized_output = optimized_model(test_input)
548
+ print("optimized output", optimized_output[0, :5])
549
+
550
+ # Compare accuracy
551
+ error = torch.mean(torch.abs(original_output - optimized_output))
552
+ print(f"Mean absolute error: {error.item()}")
553
+
554
+ # Check memory usage
555
+ original_params = sum(p.nelement() * p.element_size() for p in test_model.parameters()) / (1024 * 1024)
556
+ print(f"Model parameter memory: {original_params:.2f} MB")
557
+ optimized_params = sum(p.nelement() * p.element_size() for p in optimized_model.parameters()) / (1024 * 1024)
558
+ print(f"Optimized model parameter memory: {optimized_params:.2f} MB")
559
+
560
+ return test_model
561
+
562
+
563
+ if __name__ == "__main__":
564
+ example_usage()
musubi_tuner/modules/lr_schedulers.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+
4
+ class RexLR(torch.optim.lr_scheduler.LRScheduler):
5
+ """
6
+ Reflected Exponential (REX) learning rate scheduler (https://arxiv.org/abs/2107.04197)
7
+ Modified from: https://github.com/IvanVassi/REX_LR (Apache-2.0 License)
8
+
9
+ Args:
10
+ optimizer (torch.optim.Optimizer): The optimizer to schedule the learning rate for
11
+ max_lr (float): The maximum learning rate
12
+ min_lr (float): The minimum learning rate
13
+ num_steps (int): The total number of training steps
14
+ num_warmup_steps (int): The number of warmup steps
15
+ rex_alpha (float): Constant added to the denominator of the REX factor;
16
+ prevents division-by-zero and softens the initial decay (default: 0.1).
17
+ rex_beta (float): Multiplier of z in the denominator of the REX factor;
18
+ controls how quickly the decay flattens as z increases (default: 0.9).
19
+ last_epoch (int): The index of the last step
20
+ """
21
+
22
+ def __init__(self, optimizer, max_lr, min_lr=0.0, num_steps=0, num_warmup_steps=0, rex_alpha=0.1, rex_beta=0.9, last_epoch=-1):
23
+ if min_lr > max_lr:
24
+ raise ValueError(f'Value of "min_lr" should be less than value of "max_lr". Got min_lr={min_lr} and max_lr={max_lr}')
25
+ if num_warmup_steps > num_steps:
26
+ raise ValueError(f"num_warmup_steps ({num_warmup_steps}) must be less than or equal to num_steps ({num_steps})")
27
+
28
+ self.min_lr = min_lr
29
+ self.max_lr = max_lr
30
+ self.num_steps = num_steps
31
+ self.num_warmup_steps = num_warmup_steps
32
+ self.rex_alpha = rex_alpha
33
+ self.rex_beta = rex_beta
34
+ self.last_epoch = last_epoch
35
+
36
+ # Ensure each parameter group has an "initial_lr" key to avoid issues when resuming
37
+ for group in optimizer.param_groups:
38
+ group.setdefault("initial_lr", group["lr"])
39
+
40
+ super().__init__(optimizer, last_epoch)
41
+
42
+ def get_lr(self):
43
+ # Single warmup step
44
+ if self.num_warmup_steps == 1 and self.last_epoch == 1:
45
+ return [self.min_lr for _ in self.base_lrs]
46
+ # Multiple warmup steps; increase lr linearly from min_lr to max_lr
47
+ elif self.num_warmup_steps > 1 and self.last_epoch >= 1 and self.last_epoch <= (self.num_warmup_steps - 1):
48
+ return [
49
+ self.min_lr + (self.max_lr - self.min_lr) * (self.last_epoch - 1) / (self.num_warmup_steps - 1)
50
+ for _ in self.base_lrs
51
+ ]
52
+
53
+ # Post-warmup phase: adjust step relative to the end of warmup
54
+ step_after = self.last_epoch - self.num_warmup_steps
55
+ remaining_steps = self.num_steps - self.num_warmup_steps
56
+
57
+ # Avoid LR spiking
58
+ if step_after >= remaining_steps or step_after == -1 or remaining_steps <= 0:
59
+ return [self.min_lr for _ in self.base_lrs]
60
+
61
+ # Calculate REX curve for current step
62
+ rex_z = (remaining_steps - (step_after % remaining_steps)) / remaining_steps
63
+ rex_factor = self.min_lr / self.max_lr + (1.0 - self.min_lr / self.max_lr) * (
64
+ rex_z / (self.rex_alpha + self.rex_beta * rex_z)
65
+ )
66
+
67
+ return [base_lr * rex_factor for base_lr in self.base_lrs]
musubi_tuner/modules/scheduling_flow_match_discrete.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 Stability AI, Katherine Crowson and The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ #
16
+ # Modified from diffusers==0.29.2
17
+ #
18
+ # ==============================================================================
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Optional, Tuple, Union
22
+
23
+ import numpy as np
24
+ import torch
25
+
26
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
27
+ from diffusers.utils import BaseOutput, logging
28
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
29
+
30
+
31
+ logger = logging.get_logger(__name__) # pylint: disable=invalid-name
32
+
33
+
34
+ @dataclass
35
+ class FlowMatchDiscreteSchedulerOutput(BaseOutput):
36
+ """
37
+ Output class for the scheduler's `step` function output.
38
+
39
+ Args:
40
+ prev_sample (`torch.FloatTensor` of shape `(batch_size, num_channels, height, width)` for images):
41
+ Computed sample `(x_{t-1})` of previous timestep. `prev_sample` should be used as next model input in the
42
+ denoising loop.
43
+ """
44
+
45
+ prev_sample: torch.FloatTensor
46
+
47
+
48
+ class FlowMatchDiscreteScheduler(SchedulerMixin, ConfigMixin):
49
+ """
50
+ Euler scheduler.
51
+
52
+ This model inherits from [`SchedulerMixin`] and [`ConfigMixin`]. Check the superclass documentation for the generic
53
+ methods the library implements for all schedulers such as loading and saving.
54
+
55
+ Args:
56
+ num_train_timesteps (`int`, defaults to 1000):
57
+ The number of diffusion steps to train the model.
58
+ timestep_spacing (`str`, defaults to `"linspace"`):
59
+ The way the timesteps should be scaled. Refer to Table 2 of the [Common Diffusion Noise Schedules and
60
+ Sample Steps are Flawed](https://huggingface.co/papers/2305.08891) for more information.
61
+ shift (`float`, defaults to 1.0):
62
+ The shift value for the timestep schedule.
63
+ reverse (`bool`, defaults to `True`):
64
+ Whether to reverse the timestep schedule.
65
+ """
66
+
67
+ _compatibles = []
68
+ order = 1
69
+
70
+ @register_to_config
71
+ def __init__(
72
+ self,
73
+ num_train_timesteps: int = 1000,
74
+ shift: float = 1.0,
75
+ reverse: bool = True,
76
+ solver: str = "euler",
77
+ n_tokens: Optional[int] = None,
78
+ ):
79
+ sigmas = torch.linspace(1, 0, num_train_timesteps + 1)
80
+
81
+ if not reverse:
82
+ sigmas = sigmas.flip(0)
83
+
84
+ self.sigmas = sigmas
85
+ # the value fed to model
86
+ self.timesteps = (sigmas[:-1] * num_train_timesteps).to(dtype=torch.float32)
87
+
88
+ self._step_index = None
89
+ self._begin_index = None
90
+
91
+ self.supported_solver = ["euler"]
92
+ if solver not in self.supported_solver:
93
+ raise ValueError(
94
+ f"Solver {solver} not supported. Supported solvers: {self.supported_solver}"
95
+ )
96
+
97
+ @property
98
+ def step_index(self):
99
+ """
100
+ The index counter for current timestep. It will increase 1 after each scheduler step.
101
+ """
102
+ return self._step_index
103
+
104
+ @property
105
+ def begin_index(self):
106
+ """
107
+ The index for the first timestep. It should be set from pipeline with `set_begin_index` method.
108
+ """
109
+ return self._begin_index
110
+
111
+ # Copied from diffusers.schedulers.scheduling_dpmsolver_multistep.DPMSolverMultistepScheduler.set_begin_index
112
+ def set_begin_index(self, begin_index: int = 0):
113
+ """
114
+ Sets the begin index for the scheduler. This function should be run from pipeline before the inference.
115
+
116
+ Args:
117
+ begin_index (`int`):
118
+ The begin index for the scheduler.
119
+ """
120
+ self._begin_index = begin_index
121
+
122
+ def _sigma_to_t(self, sigma):
123
+ return sigma * self.config.num_train_timesteps
124
+
125
+ def set_timesteps(
126
+ self,
127
+ num_inference_steps: int,
128
+ device: Union[str, torch.device] = None,
129
+ n_tokens: int = None,
130
+ ):
131
+ """
132
+ Sets the discrete timesteps used for the diffusion chain (to be run before inference).
133
+
134
+ Args:
135
+ num_inference_steps (`int`):
136
+ The number of diffusion steps used when generating samples with a pre-trained model.
137
+ device (`str` or `torch.device`, *optional*):
138
+ The device to which the timesteps should be moved to. If `None`, the timesteps are not moved.
139
+ n_tokens (`int`, *optional*):
140
+ Number of tokens in the input sequence.
141
+ """
142
+ self.num_inference_steps = num_inference_steps
143
+
144
+ sigmas = torch.linspace(1, 0, num_inference_steps + 1)
145
+ sigmas = self.sd3_time_shift(sigmas)
146
+
147
+ if not self.config.reverse:
148
+ sigmas = 1 - sigmas
149
+
150
+ self.sigmas = sigmas
151
+ self.timesteps = (sigmas[:-1] * self.config.num_train_timesteps).to(
152
+ dtype=torch.float32, device=device
153
+ )
154
+
155
+ # Reset step index
156
+ self._step_index = None
157
+
158
+ def index_for_timestep(self, timestep, schedule_timesteps=None):
159
+ if schedule_timesteps is None:
160
+ schedule_timesteps = self.timesteps
161
+
162
+ indices = (schedule_timesteps == timestep).nonzero()
163
+
164
+ # The sigma index that is taken for the **very** first `step`
165
+ # is always the second index (or the last index if there is only 1)
166
+ # This way we can ensure we don't accidentally skip a sigma in
167
+ # case we start in the middle of the denoising schedule (e.g. for image-to-image)
168
+ pos = 1 if len(indices) > 1 else 0
169
+
170
+ return indices[pos].item()
171
+
172
+ def _init_step_index(self, timestep):
173
+ if self.begin_index is None:
174
+ if isinstance(timestep, torch.Tensor):
175
+ timestep = timestep.to(self.timesteps.device)
176
+ self._step_index = self.index_for_timestep(timestep)
177
+ else:
178
+ self._step_index = self._begin_index
179
+
180
+ def scale_model_input(
181
+ self, sample: torch.Tensor, timestep: Optional[int] = None
182
+ ) -> torch.Tensor:
183
+ return sample
184
+
185
+ def sd3_time_shift(self, t: torch.Tensor):
186
+ return (self.config.shift * t) / (1 + (self.config.shift - 1) * t)
187
+
188
+ def step(
189
+ self,
190
+ model_output: torch.FloatTensor,
191
+ timestep: Union[float, torch.FloatTensor],
192
+ sample: torch.FloatTensor,
193
+ return_dict: bool = True,
194
+ ) -> Union[FlowMatchDiscreteSchedulerOutput, Tuple]:
195
+ """
196
+ Predict the sample from the previous timestep by reversing the SDE. This function propagates the diffusion
197
+ process from the learned model outputs (most often the predicted noise).
198
+
199
+ Args:
200
+ model_output (`torch.FloatTensor`):
201
+ The direct output from learned diffusion model.
202
+ timestep (`float`):
203
+ The current discrete timestep in the diffusion chain.
204
+ sample (`torch.FloatTensor`):
205
+ A current instance of a sample created by the diffusion process.
206
+ generator (`torch.Generator`, *optional*):
207
+ A random number generator.
208
+ n_tokens (`int`, *optional*):
209
+ Number of tokens in the input sequence.
210
+ return_dict (`bool`):
211
+ Whether or not to return a [`~schedulers.scheduling_euler_discrete.EulerDiscreteSchedulerOutput`] or
212
+ tuple.
213
+
214
+ Returns:
215
+ [`~schedulers.scheduling_euler_discrete.EulerDiscreteSchedulerOutput`] or `tuple`:
216
+ If return_dict is `True`, [`~schedulers.scheduling_euler_discrete.EulerDiscreteSchedulerOutput`] is
217
+ returned, otherwise a tuple is returned where the first element is the sample tensor.
218
+ """
219
+
220
+ if (
221
+ isinstance(timestep, int)
222
+ or isinstance(timestep, torch.IntTensor)
223
+ or isinstance(timestep, torch.LongTensor)
224
+ ):
225
+ raise ValueError(
226
+ (
227
+ "Passing integer indices (e.g. from `enumerate(timesteps)`) as timesteps to"
228
+ " `EulerDiscreteScheduler.step()` is not supported. Make sure to pass"
229
+ " one of the `scheduler.timesteps` as a timestep."
230
+ ),
231
+ )
232
+
233
+ if self.step_index is None:
234
+ self._init_step_index(timestep)
235
+
236
+ # Upcast to avoid precision issues when computing prev_sample
237
+ sample = sample.to(torch.float32)
238
+
239
+ dt = self.sigmas[self.step_index + 1] - self.sigmas[self.step_index]
240
+
241
+ if self.config.solver == "euler":
242
+ prev_sample = sample + model_output.to(torch.float32) * dt
243
+ else:
244
+ raise ValueError(
245
+ f"Solver {self.config.solver} not supported. Supported solvers: {self.supported_solver}"
246
+ )
247
+
248
+ # upon completion increase step index by one
249
+ self._step_index += 1
250
+
251
+ if not return_dict:
252
+ return (prev_sample,)
253
+
254
+ return FlowMatchDiscreteSchedulerOutput(prev_sample=prev_sample)
255
+
256
+ def __len__(self):
257
+ return self.config.num_train_timesteps
musubi_tuner/modules/unet_causal_3d_blocks.py ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ #
16
+ # Modified from diffusers==0.29.2
17
+ #
18
+ # ==============================================================================
19
+
20
+ from typing import Optional, Tuple, Union
21
+
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from torch import nn
25
+ from einops import rearrange
26
+
27
+ from diffusers.utils import logging
28
+ from diffusers.models.activations import get_activation
29
+ from diffusers.models.attention_processor import SpatialNorm
30
+ from diffusers.models.attention_processor import Attention
31
+ from diffusers.models.normalization import AdaGroupNorm
32
+ from diffusers.models.normalization import RMSNorm
33
+
34
+ logger = logging.get_logger(__name__) # pylint: disable=invalid-name
35
+
36
+
37
+ def prepare_causal_attention_mask(n_frame: int, n_hw: int, dtype, device, batch_size: int = None):
38
+ seq_len = n_frame * n_hw
39
+ mask = torch.full((seq_len, seq_len), float("-inf"), dtype=dtype, device=device)
40
+ for i in range(seq_len):
41
+ i_frame = i // n_hw
42
+ mask[i, : (i_frame + 1) * n_hw] = 0
43
+ if batch_size is not None:
44
+ mask = mask.unsqueeze(0).expand(batch_size, -1, -1)
45
+ return mask
46
+
47
+
48
+ class CausalConv3d(nn.Module):
49
+ """
50
+ Implements a causal 3D convolution layer where each position only depends on previous timesteps and current spatial locations.
51
+ This maintains temporal causality in video generation tasks.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ chan_in,
57
+ chan_out,
58
+ kernel_size: Union[int, Tuple[int, int, int]],
59
+ stride: Union[int, Tuple[int, int, int]] = 1,
60
+ dilation: Union[int, Tuple[int, int, int]] = 1,
61
+ pad_mode="replicate",
62
+ chunk_size=0,
63
+ **kwargs,
64
+ ):
65
+ super().__init__()
66
+
67
+ self.pad_mode = pad_mode
68
+ padding = (kernel_size // 2, kernel_size // 2, kernel_size // 2, kernel_size // 2, kernel_size - 1, 0) # W, H, T
69
+ self.time_causal_padding = padding
70
+ self.chunk_size = chunk_size
71
+
72
+ self.conv = nn.Conv3d(chan_in, chan_out, kernel_size, stride=stride, dilation=dilation, **kwargs)
73
+
74
+ def original_forward(self, x):
75
+ x = F.pad(x, self.time_causal_padding, mode=self.pad_mode)
76
+ return self.conv(x)
77
+
78
+ def forward(self, x):
79
+ if self.chunk_size == 0:
80
+ return self.original_forward(x)
81
+
82
+ # if not large, call original forward
83
+ if x.shape[4] < self.chunk_size * 1.5:
84
+ return self.original_forward(x)
85
+
86
+ # # debug: verify the original forward is the same as chunked forward
87
+ # orig_forwarded_value = None
88
+ # if x.shape[4] < self.chunk_size * 4:
89
+ # orig_forwarded_value = self.original_forward(x)
90
+
91
+ # get the kernel size
92
+ kernel_size = self.conv.kernel_size[0] # assume cubic kernel
93
+ assert kernel_size == self.conv.kernel_size[1] == self.conv.kernel_size[2], "Only cubic kernels are supported"
94
+ padding_size = kernel_size // 2 # 1 for kernel_size=3, 0 for kernel_size=1
95
+
96
+ x = F.pad(x, self.time_causal_padding, mode=self.pad_mode)
97
+
98
+ B, C, D, H, W = orig_shape = x.shape
99
+ chunk_size = self.chunk_size
100
+ chunk_size -= chunk_size % self.conv.stride[2] # make sure the chunk size is divisible by stride
101
+ # print(f"chunked forward: {x.shape}, chunk_size: {chunk_size}")
102
+
103
+ # calculate the indices for chunking with overlap and padding by kernel size and stride
104
+ indices = []
105
+ i = 0
106
+ while i < W - padding_size:
107
+ start_idx = i - padding_size
108
+ end_idx = min(i + chunk_size + padding_size, W)
109
+ if i == 0:
110
+ start_idx = 0
111
+ end_idx += padding_size # to make sure the first chunk is divisible by stride
112
+ if W - end_idx < chunk_size // 2: # small chunk at the end
113
+ end_idx = W
114
+ indices.append((start_idx, end_idx))
115
+ i = end_idx - padding_size
116
+ # print(f"chunked forward: {x.shape}, chunked indices: {indices}")
117
+
118
+ chunks = []
119
+ for start_idx, end_idx in indices:
120
+ chunk = x[:, :, :, :, start_idx:end_idx]
121
+ chunk_output = self.conv(chunk)
122
+ # print(chunk.shape, chunk_output.shape)
123
+ chunks.append(chunk_output)
124
+
125
+ # concatenate the chunks
126
+ x = torch.cat(chunks, dim=4)
127
+
128
+ assert (
129
+ x.shape[2] == ((D - padding_size * 2) + self.conv.stride[0] - 1) // self.conv.stride[0]
130
+ ), f"Invalid shape: {x.shape}, {orig_shape}, {padding_size}, {self.conv.stride}"
131
+ assert (
132
+ x.shape[3] == ((H - padding_size * 2) + self.conv.stride[1] - 1) // self.conv.stride[1]
133
+ ), f"Invalid shape: {x.shape}, {orig_shape}, {padding_size}, {self.conv.stride}"
134
+ assert (
135
+ x.shape[4] == ((W - padding_size * 2) + self.conv.stride[2] - 1) // self.conv.stride[2]
136
+ ), f"Invalid shape: {x.shape}, {orig_shape}, {padding_size}, {self.conv.stride}"
137
+
138
+ # # debug: verify the original forward is the same as chunked forward
139
+ # if orig_forwarded_value is not None:
140
+ # assert torch.allclose(
141
+ # orig_forwarded_value, x, rtol=1e-4, atol=1e-2
142
+ # ), f"Chunked forward is different from original forward. {x.shape}, {orig_shape}, {padding_size}, {self.conv.stride}, {self.conv.kernel_size}"
143
+
144
+ return x
145
+
146
+
147
+ class UpsampleCausal3D(nn.Module):
148
+ """
149
+ A 3D upsampling layer with an optional convolution.
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ channels: int,
155
+ use_conv: bool = False,
156
+ use_conv_transpose: bool = False,
157
+ out_channels: Optional[int] = None,
158
+ name: str = "conv",
159
+ kernel_size: Optional[int] = None,
160
+ padding=1,
161
+ norm_type=None,
162
+ eps=None,
163
+ elementwise_affine=None,
164
+ bias=True,
165
+ interpolate=True,
166
+ upsample_factor=(2, 2, 2),
167
+ ):
168
+ super().__init__()
169
+ self.channels = channels
170
+ self.out_channels = out_channels or channels
171
+ self.use_conv = use_conv
172
+ self.use_conv_transpose = use_conv_transpose
173
+ self.name = name
174
+ self.interpolate = interpolate
175
+ self.upsample_factor = upsample_factor
176
+
177
+ if norm_type == "ln_norm":
178
+ self.norm = nn.LayerNorm(channels, eps, elementwise_affine)
179
+ elif norm_type == "rms_norm":
180
+ self.norm = RMSNorm(channels, eps, elementwise_affine)
181
+ elif norm_type is None:
182
+ self.norm = None
183
+ else:
184
+ raise ValueError(f"unknown norm_type: {norm_type}")
185
+
186
+ conv = None
187
+ if use_conv_transpose:
188
+ raise NotImplementedError
189
+ elif use_conv:
190
+ if kernel_size is None:
191
+ kernel_size = 3
192
+ conv = CausalConv3d(self.channels, self.out_channels, kernel_size=kernel_size, bias=bias)
193
+
194
+ if name == "conv":
195
+ self.conv = conv
196
+ else:
197
+ self.Conv2d_0 = conv
198
+
199
+ def forward(
200
+ self,
201
+ hidden_states: torch.FloatTensor,
202
+ output_size: Optional[int] = None,
203
+ scale: float = 1.0,
204
+ ) -> torch.FloatTensor:
205
+ assert hidden_states.shape[1] == self.channels
206
+
207
+ if self.norm is not None:
208
+ raise NotImplementedError
209
+
210
+ if self.use_conv_transpose:
211
+ return self.conv(hidden_states)
212
+
213
+ # Cast to float32 to as 'upsample_nearest2d_out_frame' op does not support bfloat16
214
+ dtype = hidden_states.dtype
215
+ if dtype == torch.bfloat16:
216
+ hidden_states = hidden_states.to(torch.float32)
217
+
218
+ # upsample_nearest_nhwc fails with large batch sizes. see https://github.com/huggingface/diffusers/issues/984
219
+ if hidden_states.shape[0] >= 64:
220
+ hidden_states = hidden_states.contiguous()
221
+
222
+ # if `output_size` is passed we force the interpolation output
223
+ # size and do not make use of `scale_factor=2`
224
+ if self.interpolate:
225
+ B, C, T, H, W = hidden_states.shape
226
+ first_h, other_h = hidden_states.split((1, T - 1), dim=2)
227
+ if output_size is None:
228
+ if T > 1:
229
+ other_h = F.interpolate(other_h, scale_factor=self.upsample_factor, mode="nearest")
230
+
231
+ first_h = first_h.squeeze(2)
232
+ first_h = F.interpolate(first_h, scale_factor=self.upsample_factor[1:], mode="nearest")
233
+ first_h = first_h.unsqueeze(2)
234
+ else:
235
+ raise NotImplementedError
236
+
237
+ if T > 1:
238
+ hidden_states = torch.cat((first_h, other_h), dim=2)
239
+ else:
240
+ hidden_states = first_h
241
+
242
+ # If the input is bfloat16, we cast back to bfloat16
243
+ if dtype == torch.bfloat16:
244
+ hidden_states = hidden_states.to(dtype)
245
+
246
+ if self.use_conv:
247
+ if self.name == "conv":
248
+ hidden_states = self.conv(hidden_states)
249
+ else:
250
+ hidden_states = self.Conv2d_0(hidden_states)
251
+
252
+ return hidden_states
253
+
254
+
255
+ class DownsampleCausal3D(nn.Module):
256
+ """
257
+ A 3D downsampling layer with an optional convolution.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ channels: int,
263
+ use_conv: bool = False,
264
+ out_channels: Optional[int] = None,
265
+ padding: int = 1,
266
+ name: str = "conv",
267
+ kernel_size=3,
268
+ norm_type=None,
269
+ eps=None,
270
+ elementwise_affine=None,
271
+ bias=True,
272
+ stride=2,
273
+ ):
274
+ super().__init__()
275
+ self.channels = channels
276
+ self.out_channels = out_channels or channels
277
+ self.use_conv = use_conv
278
+ self.padding = padding
279
+ stride = stride
280
+ self.name = name
281
+
282
+ if norm_type == "ln_norm":
283
+ self.norm = nn.LayerNorm(channels, eps, elementwise_affine)
284
+ elif norm_type == "rms_norm":
285
+ self.norm = RMSNorm(channels, eps, elementwise_affine)
286
+ elif norm_type is None:
287
+ self.norm = None
288
+ else:
289
+ raise ValueError(f"unknown norm_type: {norm_type}")
290
+
291
+ if use_conv:
292
+ conv = CausalConv3d(self.channels, self.out_channels, kernel_size=kernel_size, stride=stride, bias=bias)
293
+ else:
294
+ raise NotImplementedError
295
+
296
+ if name == "conv":
297
+ self.Conv2d_0 = conv
298
+ self.conv = conv
299
+ elif name == "Conv2d_0":
300
+ self.conv = conv
301
+ else:
302
+ self.conv = conv
303
+
304
+ def forward(self, hidden_states: torch.FloatTensor, scale: float = 1.0) -> torch.FloatTensor:
305
+ assert hidden_states.shape[1] == self.channels
306
+
307
+ if self.norm is not None:
308
+ hidden_states = self.norm(hidden_states.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)
309
+
310
+ assert hidden_states.shape[1] == self.channels
311
+
312
+ hidden_states = self.conv(hidden_states)
313
+
314
+ return hidden_states
315
+
316
+
317
+ class ResnetBlockCausal3D(nn.Module):
318
+ r"""
319
+ A Resnet block.
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ *,
325
+ in_channels: int,
326
+ out_channels: Optional[int] = None,
327
+ conv_shortcut: bool = False,
328
+ dropout: float = 0.0,
329
+ temb_channels: int = 512,
330
+ groups: int = 32,
331
+ groups_out: Optional[int] = None,
332
+ pre_norm: bool = True,
333
+ eps: float = 1e-6,
334
+ non_linearity: str = "swish",
335
+ skip_time_act: bool = False,
336
+ # default, scale_shift, ada_group, spatial
337
+ time_embedding_norm: str = "default",
338
+ kernel: Optional[torch.FloatTensor] = None,
339
+ output_scale_factor: float = 1.0,
340
+ use_in_shortcut: Optional[bool] = None,
341
+ up: bool = False,
342
+ down: bool = False,
343
+ conv_shortcut_bias: bool = True,
344
+ conv_3d_out_channels: Optional[int] = None,
345
+ ):
346
+ super().__init__()
347
+ self.pre_norm = pre_norm
348
+ self.pre_norm = True
349
+ self.in_channels = in_channels
350
+ out_channels = in_channels if out_channels is None else out_channels
351
+ self.out_channels = out_channels
352
+ self.use_conv_shortcut = conv_shortcut
353
+ self.up = up
354
+ self.down = down
355
+ self.output_scale_factor = output_scale_factor
356
+ self.time_embedding_norm = time_embedding_norm
357
+ self.skip_time_act = skip_time_act
358
+
359
+ linear_cls = nn.Linear
360
+
361
+ if groups_out is None:
362
+ groups_out = groups
363
+
364
+ if self.time_embedding_norm == "ada_group":
365
+ self.norm1 = AdaGroupNorm(temb_channels, in_channels, groups, eps=eps)
366
+ elif self.time_embedding_norm == "spatial":
367
+ self.norm1 = SpatialNorm(in_channels, temb_channels)
368
+ else:
369
+ self.norm1 = torch.nn.GroupNorm(num_groups=groups, num_channels=in_channels, eps=eps, affine=True)
370
+
371
+ self.conv1 = CausalConv3d(in_channels, out_channels, kernel_size=3, stride=1)
372
+
373
+ if temb_channels is not None:
374
+ if self.time_embedding_norm == "default":
375
+ self.time_emb_proj = linear_cls(temb_channels, out_channels)
376
+ elif self.time_embedding_norm == "scale_shift":
377
+ self.time_emb_proj = linear_cls(temb_channels, 2 * out_channels)
378
+ elif self.time_embedding_norm == "ada_group" or self.time_embedding_norm == "spatial":
379
+ self.time_emb_proj = None
380
+ else:
381
+ raise ValueError(f"Unknown time_embedding_norm : {self.time_embedding_norm} ")
382
+ else:
383
+ self.time_emb_proj = None
384
+
385
+ if self.time_embedding_norm == "ada_group":
386
+ self.norm2 = AdaGroupNorm(temb_channels, out_channels, groups_out, eps=eps)
387
+ elif self.time_embedding_norm == "spatial":
388
+ self.norm2 = SpatialNorm(out_channels, temb_channels)
389
+ else:
390
+ self.norm2 = torch.nn.GroupNorm(num_groups=groups_out, num_channels=out_channels, eps=eps, affine=True)
391
+
392
+ self.dropout = torch.nn.Dropout(dropout)
393
+ conv_3d_out_channels = conv_3d_out_channels or out_channels
394
+ self.conv2 = CausalConv3d(out_channels, conv_3d_out_channels, kernel_size=3, stride=1)
395
+
396
+ self.nonlinearity = get_activation(non_linearity)
397
+
398
+ self.upsample = self.downsample = None
399
+ if self.up:
400
+ self.upsample = UpsampleCausal3D(in_channels, use_conv=False)
401
+ elif self.down:
402
+ self.downsample = DownsampleCausal3D(in_channels, use_conv=False, name="op")
403
+
404
+ self.use_in_shortcut = self.in_channels != conv_3d_out_channels if use_in_shortcut is None else use_in_shortcut
405
+
406
+ self.conv_shortcut = None
407
+ if self.use_in_shortcut:
408
+ self.conv_shortcut = CausalConv3d(
409
+ in_channels,
410
+ conv_3d_out_channels,
411
+ kernel_size=1,
412
+ stride=1,
413
+ bias=conv_shortcut_bias,
414
+ )
415
+
416
+ def forward(
417
+ self,
418
+ input_tensor: torch.FloatTensor,
419
+ temb: torch.FloatTensor,
420
+ scale: float = 1.0,
421
+ ) -> torch.FloatTensor:
422
+ hidden_states = input_tensor
423
+
424
+ if self.time_embedding_norm == "ada_group" or self.time_embedding_norm == "spatial":
425
+ hidden_states = self.norm1(hidden_states, temb)
426
+ else:
427
+ hidden_states = self.norm1(hidden_states)
428
+
429
+ hidden_states = self.nonlinearity(hidden_states)
430
+
431
+ if self.upsample is not None:
432
+ # upsample_nearest_nhwc fails with large batch sizes. see https://github.com/huggingface/diffusers/issues/984
433
+ if hidden_states.shape[0] >= 64:
434
+ input_tensor = input_tensor.contiguous()
435
+ hidden_states = hidden_states.contiguous()
436
+ input_tensor = self.upsample(input_tensor, scale=scale)
437
+ hidden_states = self.upsample(hidden_states, scale=scale)
438
+ elif self.downsample is not None:
439
+ input_tensor = self.downsample(input_tensor, scale=scale)
440
+ hidden_states = self.downsample(hidden_states, scale=scale)
441
+
442
+ hidden_states = self.conv1(hidden_states)
443
+
444
+ if self.time_emb_proj is not None:
445
+ if not self.skip_time_act:
446
+ temb = self.nonlinearity(temb)
447
+ temb = self.time_emb_proj(temb, scale)[:, :, None, None]
448
+
449
+ if temb is not None and self.time_embedding_norm == "default":
450
+ hidden_states = hidden_states + temb
451
+
452
+ if self.time_embedding_norm == "ada_group" or self.time_embedding_norm == "spatial":
453
+ hidden_states = self.norm2(hidden_states, temb)
454
+ else:
455
+ hidden_states = self.norm2(hidden_states)
456
+
457
+ if temb is not None and self.time_embedding_norm == "scale_shift":
458
+ scale, shift = torch.chunk(temb, 2, dim=1)
459
+ hidden_states = hidden_states * (1 + scale) + shift
460
+
461
+ hidden_states = self.nonlinearity(hidden_states)
462
+
463
+ hidden_states = self.dropout(hidden_states)
464
+ hidden_states = self.conv2(hidden_states)
465
+
466
+ if self.conv_shortcut is not None:
467
+ input_tensor = self.conv_shortcut(input_tensor)
468
+
469
+ output_tensor = (input_tensor + hidden_states) / self.output_scale_factor
470
+
471
+ return output_tensor
472
+
473
+
474
+ def get_down_block3d(
475
+ down_block_type: str,
476
+ num_layers: int,
477
+ in_channels: int,
478
+ out_channels: int,
479
+ temb_channels: int,
480
+ add_downsample: bool,
481
+ downsample_stride: int,
482
+ resnet_eps: float,
483
+ resnet_act_fn: str,
484
+ transformer_layers_per_block: int = 1,
485
+ num_attention_heads: Optional[int] = None,
486
+ resnet_groups: Optional[int] = None,
487
+ cross_attention_dim: Optional[int] = None,
488
+ downsample_padding: Optional[int] = None,
489
+ dual_cross_attention: bool = False,
490
+ use_linear_projection: bool = False,
491
+ only_cross_attention: bool = False,
492
+ upcast_attention: bool = False,
493
+ resnet_time_scale_shift: str = "default",
494
+ attention_type: str = "default",
495
+ resnet_skip_time_act: bool = False,
496
+ resnet_out_scale_factor: float = 1.0,
497
+ cross_attention_norm: Optional[str] = None,
498
+ attention_head_dim: Optional[int] = None,
499
+ downsample_type: Optional[str] = None,
500
+ dropout: float = 0.0,
501
+ ):
502
+ # If attn head dim is not defined, we default it to the number of heads
503
+ if attention_head_dim is None:
504
+ logger.warn(
505
+ f"It is recommended to provide `attention_head_dim` when calling `get_down_block`. Defaulting `attention_head_dim` to {num_attention_heads}."
506
+ )
507
+ attention_head_dim = num_attention_heads
508
+
509
+ down_block_type = down_block_type[7:] if down_block_type.startswith("UNetRes") else down_block_type
510
+ if down_block_type == "DownEncoderBlockCausal3D":
511
+ return DownEncoderBlockCausal3D(
512
+ num_layers=num_layers,
513
+ in_channels=in_channels,
514
+ out_channels=out_channels,
515
+ dropout=dropout,
516
+ add_downsample=add_downsample,
517
+ downsample_stride=downsample_stride,
518
+ resnet_eps=resnet_eps,
519
+ resnet_act_fn=resnet_act_fn,
520
+ resnet_groups=resnet_groups,
521
+ downsample_padding=downsample_padding,
522
+ resnet_time_scale_shift=resnet_time_scale_shift,
523
+ )
524
+ raise ValueError(f"{down_block_type} does not exist.")
525
+
526
+
527
+ def get_up_block3d(
528
+ up_block_type: str,
529
+ num_layers: int,
530
+ in_channels: int,
531
+ out_channels: int,
532
+ prev_output_channel: int,
533
+ temb_channels: int,
534
+ add_upsample: bool,
535
+ upsample_scale_factor: Tuple,
536
+ resnet_eps: float,
537
+ resnet_act_fn: str,
538
+ resolution_idx: Optional[int] = None,
539
+ transformer_layers_per_block: int = 1,
540
+ num_attention_heads: Optional[int] = None,
541
+ resnet_groups: Optional[int] = None,
542
+ cross_attention_dim: Optional[int] = None,
543
+ dual_cross_attention: bool = False,
544
+ use_linear_projection: bool = False,
545
+ only_cross_attention: bool = False,
546
+ upcast_attention: bool = False,
547
+ resnet_time_scale_shift: str = "default",
548
+ attention_type: str = "default",
549
+ resnet_skip_time_act: bool = False,
550
+ resnet_out_scale_factor: float = 1.0,
551
+ cross_attention_norm: Optional[str] = None,
552
+ attention_head_dim: Optional[int] = None,
553
+ upsample_type: Optional[str] = None,
554
+ dropout: float = 0.0,
555
+ ) -> nn.Module:
556
+ # If attn head dim is not defined, we default it to the number of heads
557
+ if attention_head_dim is None:
558
+ logger.warn(
559
+ f"It is recommended to provide `attention_head_dim` when calling `get_up_block`. Defaulting `attention_head_dim` to {num_attention_heads}."
560
+ )
561
+ attention_head_dim = num_attention_heads
562
+
563
+ up_block_type = up_block_type[7:] if up_block_type.startswith("UNetRes") else up_block_type
564
+ if up_block_type == "UpDecoderBlockCausal3D":
565
+ return UpDecoderBlockCausal3D(
566
+ num_layers=num_layers,
567
+ in_channels=in_channels,
568
+ out_channels=out_channels,
569
+ resolution_idx=resolution_idx,
570
+ dropout=dropout,
571
+ add_upsample=add_upsample,
572
+ upsample_scale_factor=upsample_scale_factor,
573
+ resnet_eps=resnet_eps,
574
+ resnet_act_fn=resnet_act_fn,
575
+ resnet_groups=resnet_groups,
576
+ resnet_time_scale_shift=resnet_time_scale_shift,
577
+ temb_channels=temb_channels,
578
+ )
579
+ raise ValueError(f"{up_block_type} does not exist.")
580
+
581
+
582
+ class UNetMidBlockCausal3D(nn.Module):
583
+ """
584
+ A 3D UNet mid-block [`UNetMidBlockCausal3D`] with multiple residual blocks and optional attention blocks.
585
+ """
586
+
587
+ def __init__(
588
+ self,
589
+ in_channels: int,
590
+ temb_channels: int,
591
+ dropout: float = 0.0,
592
+ num_layers: int = 1,
593
+ resnet_eps: float = 1e-6,
594
+ resnet_time_scale_shift: str = "default", # default, spatial
595
+ resnet_act_fn: str = "swish",
596
+ resnet_groups: int = 32,
597
+ attn_groups: Optional[int] = None,
598
+ resnet_pre_norm: bool = True,
599
+ add_attention: bool = True,
600
+ attention_head_dim: int = 1,
601
+ output_scale_factor: float = 1.0,
602
+ ):
603
+ super().__init__()
604
+ resnet_groups = resnet_groups if resnet_groups is not None else min(in_channels // 4, 32)
605
+ self.add_attention = add_attention
606
+
607
+ if attn_groups is None:
608
+ attn_groups = resnet_groups if resnet_time_scale_shift == "default" else None
609
+
610
+ # there is always at least one resnet
611
+ resnets = [
612
+ ResnetBlockCausal3D(
613
+ in_channels=in_channels,
614
+ out_channels=in_channels,
615
+ temb_channels=temb_channels,
616
+ eps=resnet_eps,
617
+ groups=resnet_groups,
618
+ dropout=dropout,
619
+ time_embedding_norm=resnet_time_scale_shift,
620
+ non_linearity=resnet_act_fn,
621
+ output_scale_factor=output_scale_factor,
622
+ pre_norm=resnet_pre_norm,
623
+ )
624
+ ]
625
+ attentions = []
626
+
627
+ if attention_head_dim is None:
628
+ logger.warn(
629
+ f"It is not recommend to pass `attention_head_dim=None`. Defaulting `attention_head_dim` to `in_channels`: {in_channels}."
630
+ )
631
+ attention_head_dim = in_channels
632
+
633
+ for _ in range(num_layers):
634
+ if self.add_attention:
635
+ attentions.append(
636
+ Attention(
637
+ in_channels,
638
+ heads=in_channels // attention_head_dim,
639
+ dim_head=attention_head_dim,
640
+ rescale_output_factor=output_scale_factor,
641
+ eps=resnet_eps,
642
+ norm_num_groups=attn_groups,
643
+ spatial_norm_dim=temb_channels if resnet_time_scale_shift == "spatial" else None,
644
+ residual_connection=True,
645
+ bias=True,
646
+ upcast_softmax=True,
647
+ _from_deprecated_attn_block=True,
648
+ )
649
+ )
650
+ else:
651
+ attentions.append(None)
652
+
653
+ resnets.append(
654
+ ResnetBlockCausal3D(
655
+ in_channels=in_channels,
656
+ out_channels=in_channels,
657
+ temb_channels=temb_channels,
658
+ eps=resnet_eps,
659
+ groups=resnet_groups,
660
+ dropout=dropout,
661
+ time_embedding_norm=resnet_time_scale_shift,
662
+ non_linearity=resnet_act_fn,
663
+ output_scale_factor=output_scale_factor,
664
+ pre_norm=resnet_pre_norm,
665
+ )
666
+ )
667
+
668
+ self.attentions = nn.ModuleList(attentions)
669
+ self.resnets = nn.ModuleList(resnets)
670
+
671
+ def forward(self, hidden_states: torch.FloatTensor, temb: Optional[torch.FloatTensor] = None) -> torch.FloatTensor:
672
+ hidden_states = self.resnets[0](hidden_states, temb)
673
+ for attn, resnet in zip(self.attentions, self.resnets[1:]):
674
+ if attn is not None:
675
+ B, C, T, H, W = hidden_states.shape
676
+ hidden_states = rearrange(hidden_states, "b c f h w -> b (f h w) c")
677
+ attention_mask = prepare_causal_attention_mask(T, H * W, hidden_states.dtype, hidden_states.device, batch_size=B)
678
+ hidden_states = attn(hidden_states, temb=temb, attention_mask=attention_mask)
679
+ hidden_states = rearrange(hidden_states, "b (f h w) c -> b c f h w", f=T, h=H, w=W)
680
+ hidden_states = resnet(hidden_states, temb)
681
+
682
+ return hidden_states
683
+
684
+
685
+ class DownEncoderBlockCausal3D(nn.Module):
686
+ def __init__(
687
+ self,
688
+ in_channels: int,
689
+ out_channels: int,
690
+ dropout: float = 0.0,
691
+ num_layers: int = 1,
692
+ resnet_eps: float = 1e-6,
693
+ resnet_time_scale_shift: str = "default",
694
+ resnet_act_fn: str = "swish",
695
+ resnet_groups: int = 32,
696
+ resnet_pre_norm: bool = True,
697
+ output_scale_factor: float = 1.0,
698
+ add_downsample: bool = True,
699
+ downsample_stride: int = 2,
700
+ downsample_padding: int = 1,
701
+ ):
702
+ super().__init__()
703
+ resnets = []
704
+
705
+ for i in range(num_layers):
706
+ in_channels = in_channels if i == 0 else out_channels
707
+ resnets.append(
708
+ ResnetBlockCausal3D(
709
+ in_channels=in_channels,
710
+ out_channels=out_channels,
711
+ temb_channels=None,
712
+ eps=resnet_eps,
713
+ groups=resnet_groups,
714
+ dropout=dropout,
715
+ time_embedding_norm=resnet_time_scale_shift,
716
+ non_linearity=resnet_act_fn,
717
+ output_scale_factor=output_scale_factor,
718
+ pre_norm=resnet_pre_norm,
719
+ )
720
+ )
721
+
722
+ self.resnets = nn.ModuleList(resnets)
723
+
724
+ if add_downsample:
725
+ self.downsamplers = nn.ModuleList(
726
+ [
727
+ DownsampleCausal3D(
728
+ out_channels,
729
+ use_conv=True,
730
+ out_channels=out_channels,
731
+ padding=downsample_padding,
732
+ name="op",
733
+ stride=downsample_stride,
734
+ )
735
+ ]
736
+ )
737
+ else:
738
+ self.downsamplers = None
739
+
740
+ def forward(self, hidden_states: torch.FloatTensor, scale: float = 1.0) -> torch.FloatTensor:
741
+ for resnet in self.resnets:
742
+ hidden_states = resnet(hidden_states, temb=None, scale=scale)
743
+
744
+ if self.downsamplers is not None:
745
+ for downsampler in self.downsamplers:
746
+ hidden_states = downsampler(hidden_states, scale)
747
+
748
+ return hidden_states
749
+
750
+
751
+ class UpDecoderBlockCausal3D(nn.Module):
752
+ def __init__(
753
+ self,
754
+ in_channels: int,
755
+ out_channels: int,
756
+ resolution_idx: Optional[int] = None,
757
+ dropout: float = 0.0,
758
+ num_layers: int = 1,
759
+ resnet_eps: float = 1e-6,
760
+ resnet_time_scale_shift: str = "default", # default, spatial
761
+ resnet_act_fn: str = "swish",
762
+ resnet_groups: int = 32,
763
+ resnet_pre_norm: bool = True,
764
+ output_scale_factor: float = 1.0,
765
+ add_upsample: bool = True,
766
+ upsample_scale_factor=(2, 2, 2),
767
+ temb_channels: Optional[int] = None,
768
+ ):
769
+ super().__init__()
770
+ resnets = []
771
+
772
+ for i in range(num_layers):
773
+ input_channels = in_channels if i == 0 else out_channels
774
+
775
+ resnets.append(
776
+ ResnetBlockCausal3D(
777
+ in_channels=input_channels,
778
+ out_channels=out_channels,
779
+ temb_channels=temb_channels,
780
+ eps=resnet_eps,
781
+ groups=resnet_groups,
782
+ dropout=dropout,
783
+ time_embedding_norm=resnet_time_scale_shift,
784
+ non_linearity=resnet_act_fn,
785
+ output_scale_factor=output_scale_factor,
786
+ pre_norm=resnet_pre_norm,
787
+ )
788
+ )
789
+
790
+ self.resnets = nn.ModuleList(resnets)
791
+
792
+ if add_upsample:
793
+ self.upsamplers = nn.ModuleList(
794
+ [
795
+ UpsampleCausal3D(
796
+ out_channels,
797
+ use_conv=True,
798
+ out_channels=out_channels,
799
+ upsample_factor=upsample_scale_factor,
800
+ )
801
+ ]
802
+ )
803
+ else:
804
+ self.upsamplers = None
805
+
806
+ self.resolution_idx = resolution_idx
807
+
808
+ def forward(
809
+ self, hidden_states: torch.FloatTensor, temb: Optional[torch.FloatTensor] = None, scale: float = 1.0
810
+ ) -> torch.FloatTensor:
811
+ for resnet in self.resnets:
812
+ hidden_states = resnet(hidden_states, temb=temb, scale=scale)
813
+
814
+ if self.upsamplers is not None:
815
+ for upsampler in self.upsamplers:
816
+ hidden_states = upsampler(hidden_states)
817
+
818
+ return hidden_states
musubi_tuner/utils/__init__.py ADDED
File without changes
musubi_tuner/utils/device_utils.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Union
2
+ import torch
3
+
4
+
5
+ def clean_memory_on_device(device: Optional[Union[str, torch.device]]):
6
+ if device is None:
7
+ return
8
+ if isinstance(device, str):
9
+ device = torch.device(device)
10
+ if device.type == "cuda":
11
+ torch.cuda.empty_cache()
12
+ elif device.type == "cpu":
13
+ pass
14
+ elif device.type == "mps": # not tested
15
+ torch.mps.empty_cache()
16
+
17
+
18
+ def synchronize_device(device: Optional[Union[str, torch.device]]):
19
+ if device is None:
20
+ return
21
+ if isinstance(device, str):
22
+ device = torch.device(device)
23
+ if device.type == "cuda":
24
+ torch.cuda.synchronize()
25
+ elif device.type == "xpu":
26
+ torch.xpu.synchronize()
27
+ elif device.type == "mps":
28
+ torch.mps.synchronize()
musubi_tuner/utils/huggingface_utils.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ from typing import Union, BinaryIO
3
+ from huggingface_hub import HfApi
4
+ from pathlib import Path
5
+ import argparse
6
+ import os
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+ logging.basicConfig(level=logging.INFO)
11
+
12
+
13
+ def fire_in_thread(f, *args, **kwargs):
14
+ threading.Thread(target=f, args=args, kwargs=kwargs).start()
15
+
16
+
17
+ def exists_repo(repo_id: str, repo_type: str, revision: str = "main", token: str = None):
18
+ api = HfApi(
19
+ token=token,
20
+ )
21
+ try:
22
+ api.repo_info(repo_id=repo_id, revision=revision, repo_type=repo_type)
23
+ return True
24
+ except:
25
+ return False
26
+
27
+
28
+ def upload(
29
+ args: argparse.Namespace,
30
+ src: Union[str, Path, bytes, BinaryIO],
31
+ dest_suffix: str = "",
32
+ force_sync_upload: bool = False,
33
+ ):
34
+ repo_id = args.huggingface_repo_id
35
+ repo_type = args.huggingface_repo_type
36
+ token = args.huggingface_token
37
+ path_in_repo = args.huggingface_path_in_repo + dest_suffix if args.huggingface_path_in_repo is not None else None
38
+ private = args.huggingface_repo_visibility is None or args.huggingface_repo_visibility != "public"
39
+ api = HfApi(token=token)
40
+ if not exists_repo(repo_id=repo_id, repo_type=repo_type, token=token):
41
+ try:
42
+ api.create_repo(repo_id=repo_id, repo_type=repo_type, private=private)
43
+ except Exception as e: # RepositoryNotFoundError or something else
44
+ logger.error("===========================================")
45
+ logger.error(f"failed to create HuggingFace repo / HuggingFaceのリポジトリの作成に失敗しました : {e}")
46
+ logger.error("===========================================")
47
+
48
+ is_folder = (type(src) == str and os.path.isdir(src)) or (isinstance(src, Path) and src.is_dir())
49
+
50
+ def uploader():
51
+ try:
52
+ if is_folder:
53
+ api.upload_folder(
54
+ repo_id=repo_id,
55
+ repo_type=repo_type,
56
+ folder_path=src,
57
+ path_in_repo=path_in_repo,
58
+ )
59
+ else:
60
+ api.upload_file(
61
+ repo_id=repo_id,
62
+ repo_type=repo_type,
63
+ path_or_fileobj=src,
64
+ path_in_repo=path_in_repo,
65
+ )
66
+ except Exception as e: # RuntimeError or something else
67
+ logger.error("===========================================")
68
+ logger.error(f"failed to upload to HuggingFace / HuggingFaceへのアップロードに失敗しました : {e}")
69
+ logger.error("===========================================")
70
+
71
+ if args.async_upload and not force_sync_upload:
72
+ fire_in_thread(uploader)
73
+ else:
74
+ uploader()
75
+
76
+
77
+ def list_dir(
78
+ repo_id: str,
79
+ subfolder: str,
80
+ repo_type: str,
81
+ revision: str = "main",
82
+ token: str = None,
83
+ ):
84
+ api = HfApi(
85
+ token=token,
86
+ )
87
+ repo_info = api.repo_info(repo_id=repo_id, revision=revision, repo_type=repo_type)
88
+ file_list = [file for file in repo_info.siblings if file.rfilename.startswith(subfolder)]
89
+ return file_list
musubi_tuner/utils/image_utils.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+ from PIL import Image
4
+ from typing import Tuple, Optional
5
+
6
+ from musubi_tuner.dataset import image_video_dataset
7
+
8
+
9
+ # prepare image
10
+ def preprocess_image(
11
+ image: Image, w: int, h: int, handle_alpha: bool = False
12
+ ) -> Tuple[torch.Tensor, np.ndarray, Optional[np.ndarray]]:
13
+ """
14
+ Preprocess the image for the model.
15
+ Args:
16
+ image (Image): The input image. RGB or RGBA format.
17
+ w (int): The target bucket width.
18
+ h (int): The target bucket height.
19
+ handle_alpha (bool): Whether to handle alpha channel for tensor and numpy array.
20
+ Returns:
21
+ Tuple[torch.Tensor, np.ndarray, Optional[np.ndarray]]:
22
+ - image_tensor: The preprocessed image tensor (NCHW format). -1.0 to 1.0.
23
+ - image_np: The original image as a numpy array (HWC format). 0 to 255.
24
+ - alpha: The alpha channel of the image if present in original size, otherwise None.
25
+ """
26
+ if image.mode == "RGBA":
27
+ alpha = image.split()[-1]
28
+ else:
29
+ alpha = None
30
+ if handle_alpha:
31
+ image = image.convert("RGBA")
32
+ else:
33
+ image = image.convert("RGB")
34
+
35
+ image_np = np.array(image) # PIL to numpy, HWC
36
+
37
+ image_np = image_video_dataset.resize_image_to_bucket(image_np, (w, h)) # TODO move this to this file
38
+ image_tensor = torch.from_numpy(image_np).float() / 127.5 - 1.0 # -1 to 1.0, HWC
39
+ image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(0) # HWC -> CHW -> NCHW, N=1
40
+ return image_tensor, image_np, alpha
musubi_tuner/utils/lora_utils.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from typing import Dict, List, Optional, Union
4
+ import torch
5
+
6
+ import logging
7
+
8
+ from tqdm import tqdm
9
+
10
+ from musubi_tuner.utils.device_utils import synchronize_device
11
+
12
+ logger = logging.getLogger(__name__)
13
+ logging.basicConfig(level=logging.INFO)
14
+
15
+
16
+ from musubi_tuner.modules.fp8_optimization_utils import load_safetensors_with_fp8_optimization
17
+ from musubi_tuner.utils.safetensors_utils import (
18
+ MemoryEfficientSafeOpen,
19
+ TensorWeightAdapter,
20
+ WeightTransformHooks,
21
+ get_split_weight_filenames,
22
+ )
23
+
24
+
25
+ def detect_network_type(lora_sd: Dict[str, torch.Tensor]) -> str:
26
+ """Detect network type (lora, loha, lokr) from state dict keys."""
27
+ for key in lora_sd:
28
+ if "lora_down" in key:
29
+ return "lora"
30
+ if "hada_w1_a" in key:
31
+ return "loha"
32
+ if "lokr_w1" in key:
33
+ return "lokr"
34
+ return "lora" # default
35
+
36
+
37
+ def filter_lora_state_dict(
38
+ weights_sd: Dict[str, torch.Tensor],
39
+ include_pattern: Optional[str] = None,
40
+ exclude_pattern: Optional[str] = None,
41
+ ) -> Dict[str, torch.Tensor]:
42
+ # apply include/exclude patterns
43
+ original_key_count = len(weights_sd.keys())
44
+ if include_pattern is not None:
45
+ regex_include = re.compile(include_pattern)
46
+ weights_sd = {k: v for k, v in weights_sd.items() if regex_include.search(k)}
47
+ logger.info(f"Filtered keys with include pattern {include_pattern}: {original_key_count} -> {len(weights_sd.keys())}")
48
+
49
+ if exclude_pattern is not None:
50
+ original_key_count_ex = len(weights_sd.keys())
51
+ regex_exclude = re.compile(exclude_pattern)
52
+ weights_sd = {k: v for k, v in weights_sd.items() if not regex_exclude.search(k)}
53
+ logger.info(f"Filtered keys with exclude pattern {exclude_pattern}: {original_key_count_ex} -> {len(weights_sd.keys())}")
54
+
55
+ if len(weights_sd) != original_key_count:
56
+ remaining_keys = list(set([k.split(".", 1)[0] for k in weights_sd.keys()]))
57
+ remaining_keys.sort()
58
+ logger.info(f"Remaining LoRA modules after filtering: {remaining_keys}")
59
+ if len(weights_sd) == 0:
60
+ logger.warning("No keys left after filtering.")
61
+
62
+ return weights_sd
63
+
64
+
65
+ def load_safetensors_with_lora_and_fp8(
66
+ model_files: Union[str, List[str]],
67
+ lora_weights_list: Optional[List[Dict[str, torch.Tensor]]],
68
+ lora_multipliers: Optional[List[float]],
69
+ fp8_optimization: bool,
70
+ calc_device: torch.device,
71
+ move_to_device: bool = False,
72
+ dit_weight_dtype: Optional[torch.dtype] = None,
73
+ target_keys: Optional[List[str]] = None,
74
+ exclude_keys: Optional[List[str]] = None,
75
+ disable_numpy_memmap: bool = False,
76
+ weight_transform_hooks: Optional[WeightTransformHooks] = None,
77
+ allow_prequantized_fp8: bool = False,
78
+ ) -> dict[str, torch.Tensor]:
79
+ """
80
+ Merge LoRA weights into the state dict of a model with fp8 optimization if needed.
81
+
82
+ Args:
83
+ model_files (Union[str, List[str]]): Path to the model file or list of paths. If the path matches a pattern like `00001-of-00004`, it will load all files with the same prefix.
84
+ lora_weights_list (Optional[List[Dict[str, torch.Tensor]]]): List of dictionaries of LoRA weight tensors to load.
85
+ lora_multipliers (Optional[List[float]]): List of multipliers for LoRA weights.
86
+ fp8_optimization (bool): Whether to apply FP8 optimization.
87
+ calc_device (torch.device): Device to calculate on.
88
+ move_to_device (bool): Whether to move tensors to the calculation device after loading.
89
+ target_keys (Optional[List[str]]): Keys to target for optimization.
90
+ exclude_keys (Optional[List[str]]): Keys to exclude from optimization.
91
+ disable_numpy_memmap (bool): Whether to disable numpy memmap when loading safetensors.
92
+ weight_transform_hooks (Optional[WeightTransformHooks]): Hooks for transforming weights during loading.
93
+ """
94
+
95
+ # if the file name ends with 00001-of-00004 etc, we need to load the files with the same prefix
96
+ if isinstance(model_files, str):
97
+ model_files = [model_files]
98
+
99
+ extended_model_files = []
100
+ for model_file in model_files:
101
+ split_filenames = get_split_weight_filenames(model_file)
102
+ if split_filenames is not None:
103
+ extended_model_files.extend(split_filenames)
104
+ else:
105
+ extended_model_files.append(model_file)
106
+ model_files = extended_model_files
107
+ logger.info(f"Loading model files: {model_files}")
108
+
109
+ # load LoRA weights
110
+ weight_hook = None
111
+ if lora_weights_list is None or len(lora_weights_list) == 0:
112
+ lora_weights_list = []
113
+ lora_multipliers = []
114
+ list_of_lora_weight_keys = []
115
+ else:
116
+ list_of_lora_weight_keys = []
117
+ for lora_sd in lora_weights_list:
118
+ lora_weight_keys = set(lora_sd.keys())
119
+ list_of_lora_weight_keys.append(lora_weight_keys)
120
+
121
+ if lora_multipliers is None:
122
+ lora_multipliers = [1.0] * len(lora_weights_list)
123
+ while len(lora_multipliers) < len(lora_weights_list):
124
+ lora_multipliers.append(1.0)
125
+ if len(lora_multipliers) > len(lora_weights_list):
126
+ lora_multipliers = lora_multipliers[: len(lora_weights_list)]
127
+
128
+ # detect network types for each lora_sd
129
+ lora_network_types = [detect_network_type(lora_sd) for lora_sd in lora_weights_list]
130
+
131
+ # Merge LoRA weights into the state dict
132
+ logger.info(f"Merging LoRA weights into state dict. multipliers: {lora_multipliers}, network types: {lora_network_types}")
133
+
134
+ # make hook for LoRA merging
135
+ def weight_hook_func(model_weight_key, model_weight: torch.Tensor, keep_on_calc_device=False):
136
+ nonlocal list_of_lora_weight_keys, lora_weights_list, lora_multipliers, lora_network_types, calc_device
137
+
138
+ if not model_weight_key.endswith(".weight"):
139
+ return model_weight
140
+
141
+ original_device = model_weight.device
142
+ if original_device != calc_device:
143
+ model_weight = model_weight.to(calc_device) # to make calculation faster
144
+
145
+ for lora_weight_keys, lora_sd, multiplier, net_type in zip(
146
+ list_of_lora_weight_keys, lora_weights_list, lora_multipliers, lora_network_types
147
+ ):
148
+ lora_name = model_weight_key.rsplit(".", 1)[0] # remove trailing ".weight"
149
+ lora_name = "lora_unet_" + lora_name.replace(".", "_")
150
+
151
+ if net_type == "loha":
152
+ from musubi_tuner.networks.loha import merge_weights_to_tensor as loha_merge
153
+
154
+ model_weight = loha_merge(model_weight, lora_name, lora_sd, lora_weight_keys, multiplier, calc_device)
155
+ elif net_type == "lokr":
156
+ from musubi_tuner.networks.lokr import merge_weights_to_tensor as lokr_merge
157
+
158
+ model_weight = lokr_merge(model_weight, lora_name, lora_sd, lora_weight_keys, multiplier, calc_device)
159
+ else:
160
+ # standard LoRA (lora_down/lora_up)
161
+ down_key = lora_name + ".lora_down.weight"
162
+ up_key = lora_name + ".lora_up.weight"
163
+ alpha_key = lora_name + ".alpha"
164
+ if down_key not in lora_weight_keys or up_key not in lora_weight_keys:
165
+ continue
166
+
167
+ # get LoRA weights
168
+ down_weight = lora_sd[down_key]
169
+ up_weight = lora_sd[up_key]
170
+
171
+ dim = down_weight.size()[0]
172
+ alpha = lora_sd.get(alpha_key, dim)
173
+ scale = alpha / dim
174
+
175
+ down_weight = down_weight.to(calc_device)
176
+ up_weight = up_weight.to(calc_device)
177
+
178
+ original_dtype = model_weight.dtype
179
+ if original_dtype.itemsize == 1: # fp8
180
+ # temporarily convert to float16 for calculation
181
+ model_weight = model_weight.to(torch.float16)
182
+ down_weight = down_weight.to(torch.float16)
183
+ up_weight = up_weight.to(torch.float16)
184
+
185
+ # W <- W + U * D
186
+ if len(model_weight.size()) == 2:
187
+ # linear
188
+ if len(up_weight.size()) == 4: # use linear projection mismatch
189
+ up_weight = up_weight.squeeze(3).squeeze(2)
190
+ down_weight = down_weight.squeeze(3).squeeze(2)
191
+ model_weight = model_weight + multiplier * (up_weight @ down_weight) * scale
192
+ elif down_weight.size()[2:4] == (1, 1):
193
+ # conv2d 1x1
194
+ model_weight = (
195
+ model_weight
196
+ + multiplier
197
+ * (up_weight.squeeze(3).squeeze(2) @ down_weight.squeeze(3).squeeze(2)).unsqueeze(2).unsqueeze(3)
198
+ * scale
199
+ )
200
+ else:
201
+ # conv2d 3x3
202
+ conved = torch.nn.functional.conv2d(down_weight.permute(1, 0, 2, 3), up_weight).permute(1, 0, 2, 3)
203
+ model_weight = model_weight + multiplier * conved * scale
204
+
205
+ if original_dtype.itemsize == 1: # fp8
206
+ model_weight = model_weight.to(original_dtype) # convert back to original dtype
207
+
208
+ # remove LoRA keys from set
209
+ lora_weight_keys.remove(down_key)
210
+ lora_weight_keys.remove(up_key)
211
+ if alpha_key in lora_weight_keys:
212
+ lora_weight_keys.remove(alpha_key)
213
+
214
+ if not keep_on_calc_device and original_device != calc_device:
215
+ model_weight = model_weight.to(original_device) # move back to original device
216
+ return model_weight
217
+
218
+ weight_hook = weight_hook_func
219
+
220
+ state_dict = load_safetensors_with_fp8_optimization_and_hook(
221
+ model_files,
222
+ fp8_optimization,
223
+ calc_device,
224
+ move_to_device,
225
+ dit_weight_dtype,
226
+ target_keys,
227
+ exclude_keys,
228
+ weight_hook=weight_hook,
229
+ disable_numpy_memmap=disable_numpy_memmap,
230
+ weight_transform_hooks=weight_transform_hooks,
231
+ allow_prequantized_fp8=allow_prequantized_fp8,
232
+ )
233
+
234
+ for lora_weight_keys in list_of_lora_weight_keys:
235
+ # check if all LoRA keys are used
236
+ if len(lora_weight_keys) > 0:
237
+ # if there are still LoRA keys left, it means they are not used in the model
238
+ # this is a warning, not an error
239
+ logger.warning(f"Warning: not all LoRA keys are used: {', '.join(lora_weight_keys)}")
240
+
241
+ return state_dict
242
+
243
+
244
+ def load_safetensors_with_fp8_optimization_and_hook(
245
+ model_files: list[str],
246
+ fp8_optimization: bool,
247
+ calc_device: torch.device,
248
+ move_to_device: bool = False,
249
+ dit_weight_dtype: Optional[torch.dtype] = None,
250
+ target_keys: Optional[List[str]] = None,
251
+ exclude_keys: Optional[List[str]] = None,
252
+ weight_hook: callable = None,
253
+ disable_numpy_memmap: bool = False,
254
+ weight_transform_hooks: Optional[WeightTransformHooks] = None,
255
+ allow_prequantized_fp8: bool = False,
256
+ ) -> dict[str, torch.Tensor]:
257
+ """
258
+ Load state dict from safetensors files and merge LoRA weights into the state dict with fp8 optimization if needed.
259
+ """
260
+ if fp8_optimization:
261
+ logger.info(
262
+ f"Loading state dict with FP8 optimization. Dtype of weight: {dit_weight_dtype}, hook enabled: {weight_hook is not None}"
263
+ )
264
+ # dit_weight_dtype is not used because we use fp8 optimization
265
+ state_dict = load_safetensors_with_fp8_optimization(
266
+ model_files,
267
+ calc_device,
268
+ target_keys,
269
+ exclude_keys,
270
+ move_to_device=move_to_device,
271
+ weight_hook=weight_hook,
272
+ disable_numpy_memmap=disable_numpy_memmap,
273
+ weight_transform_hooks=weight_transform_hooks,
274
+ allow_prequantized_fp8=allow_prequantized_fp8,
275
+ )
276
+ else:
277
+ logger.info(
278
+ f"Loading state dict without FP8 optimization. Dtype of weight: {dit_weight_dtype}, hook enabled: {weight_hook is not None}"
279
+ )
280
+ state_dict = {}
281
+ for model_file in model_files:
282
+ with MemoryEfficientSafeOpen(model_file, disable_numpy_memmap=disable_numpy_memmap) as original_f:
283
+ f = TensorWeightAdapter(weight_transform_hooks, original_f) if weight_transform_hooks is not None else original_f
284
+ for key in tqdm(f.keys(), desc=f"Loading {os.path.basename(model_file)}", leave=False):
285
+ if weight_hook is None and move_to_device:
286
+ value = f.get_tensor(key, device=calc_device, dtype=dit_weight_dtype)
287
+ else:
288
+ value = f.get_tensor(key) # we cannot directly load to device because get_tensor does non-blocking transfer
289
+ if weight_hook is not None:
290
+ value = weight_hook(key, value, keep_on_calc_device=move_to_device)
291
+ if move_to_device:
292
+ value = value.to(calc_device, dtype=dit_weight_dtype, non_blocking=True)
293
+ elif dit_weight_dtype is not None:
294
+ value = value.to(dit_weight_dtype)
295
+
296
+ state_dict[key] = value
297
+ if move_to_device:
298
+ synchronize_device(calc_device)
299
+
300
+ return state_dict
musubi_tuner/utils/model_utils.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import hashlib
3
+ from functools import lru_cache
4
+ from io import BytesIO
5
+ from typing import Any, Callable, Optional
6
+ import logging
7
+ import safetensors.torch
8
+ import torch
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def model_hash(filename):
14
+ """Old model hash used by stable-diffusion-webui"""
15
+ try:
16
+ with open(filename, "rb") as file:
17
+ m = hashlib.sha256()
18
+
19
+ file.seek(0x100000)
20
+ m.update(file.read(0x10000))
21
+ return m.hexdigest()[0:8]
22
+ except FileNotFoundError:
23
+ return "NOFILE"
24
+ except IsADirectoryError: # Linux?
25
+ return "IsADirectory"
26
+ except PermissionError: # Windows
27
+ return "IsADirectory"
28
+
29
+
30
+ def calculate_sha256(filename):
31
+ """New model hash used by stable-diffusion-webui"""
32
+ try:
33
+ hash_sha256 = hashlib.sha256()
34
+ blksize = 1024 * 1024
35
+
36
+ with open(filename, "rb") as f:
37
+ for chunk in iter(lambda: f.read(blksize), b""):
38
+ hash_sha256.update(chunk)
39
+
40
+ return hash_sha256.hexdigest()
41
+ except FileNotFoundError:
42
+ return "NOFILE"
43
+ except IsADirectoryError: # Linux?
44
+ return "IsADirectory"
45
+ except PermissionError: # Windows
46
+ return "IsADirectory"
47
+
48
+
49
+ def addnet_hash_legacy(b):
50
+ """Old model hash used by sd-webui-additional-networks for .safetensors format files"""
51
+ m = hashlib.sha256()
52
+
53
+ b.seek(0x100000)
54
+ m.update(b.read(0x10000))
55
+ return m.hexdigest()[0:8]
56
+
57
+
58
+ def addnet_hash_safetensors(b):
59
+ """New model hash used by sd-webui-additional-networks for .safetensors format files"""
60
+ hash_sha256 = hashlib.sha256()
61
+ blksize = 1024 * 1024
62
+
63
+ b.seek(0)
64
+ header = b.read(8)
65
+ n = int.from_bytes(header, "little")
66
+
67
+ offset = n + 8
68
+ b.seek(offset)
69
+ for chunk in iter(lambda: b.read(blksize), b""):
70
+ hash_sha256.update(chunk)
71
+
72
+ return hash_sha256.hexdigest()
73
+
74
+
75
+ def precalculate_safetensors_hashes(tensors, metadata):
76
+ """Precalculate the model hashes needed by sd-webui-additional-networks to
77
+ save time on indexing the model later."""
78
+
79
+ # Because writing user metadata to the file can change the result of
80
+ # sd_models.model_hash(), only retain the training metadata for purposes of
81
+ # calculating the hash, as they are meant to be immutable
82
+ metadata = {k: v for k, v in metadata.items() if k.startswith("ss_")}
83
+
84
+ bytes = safetensors.torch.save(tensors, metadata)
85
+ b = BytesIO(bytes)
86
+
87
+ model_hash = addnet_hash_safetensors(b)
88
+ legacy_hash = addnet_hash_legacy(b)
89
+ return model_hash, legacy_hash
90
+
91
+
92
+ def dtype_to_str(dtype: torch.dtype) -> str:
93
+ # get name of the dtype
94
+ dtype_name = str(dtype).split(".")[-1]
95
+ return dtype_name
96
+
97
+
98
+ def str_to_dtype(s: Optional[str], default_dtype: Optional[torch.dtype] = None) -> torch.dtype:
99
+ """
100
+ Convert a string to a torch.dtype
101
+
102
+ Args:
103
+ s: string representation of the dtype
104
+ default_dtype: default dtype to return if s is None
105
+
106
+ Returns:
107
+ torch.dtype: the corresponding torch.dtype
108
+
109
+ Raises:
110
+ ValueError: if the dtype is not supported
111
+
112
+ Examples:
113
+ >>> str_to_dtype("float32")
114
+ torch.float32
115
+ >>> str_to_dtype("fp32")
116
+ torch.float32
117
+ >>> str_to_dtype("float16")
118
+ torch.float16
119
+ >>> str_to_dtype("fp16")
120
+ torch.float16
121
+ >>> str_to_dtype("bfloat16")
122
+ torch.bfloat16
123
+ >>> str_to_dtype("bf16")
124
+ torch.bfloat16
125
+ >>> str_to_dtype("fp8")
126
+ torch.float8_e4m3fn
127
+ >>> str_to_dtype("fp8_e4m3fn")
128
+ torch.float8_e4m3fn
129
+ >>> str_to_dtype("fp8_e4m3fnuz")
130
+ torch.float8_e4m3fnuz
131
+ >>> str_to_dtype("fp8_e5m2")
132
+ torch.float8_e5m2
133
+ >>> str_to_dtype("fp8_e5m2fnuz")
134
+ torch.float8_e5m2fnuz
135
+ """
136
+ if s is None:
137
+ return default_dtype
138
+ if s in ["bf16", "bfloat16"]:
139
+ return torch.bfloat16
140
+ elif s in ["fp16", "float16"]:
141
+ return torch.float16
142
+ elif s in ["fp32", "float32", "float"]:
143
+ return torch.float32
144
+ elif s in ["fp8_e4m3fn", "e4m3fn", "float8_e4m3fn"]:
145
+ return torch.float8_e4m3fn
146
+ elif s in ["fp8_e4m3fnuz", "e4m3fnuz", "float8_e4m3fnuz"]:
147
+ return torch.float8_e4m3fnuz
148
+ elif s in ["fp8_e5m2", "e5m2", "float8_e5m2"]:
149
+ return torch.float8_e5m2
150
+ elif s in ["fp8_e5m2fnuz", "e5m2fnuz", "float8_e5m2fnuz"]:
151
+ return torch.float8_e5m2fnuz
152
+ elif s in ["fp8", "float8"]:
153
+ return torch.float8_e4m3fn # default fp8
154
+ else:
155
+ raise ValueError(f"Unsupported dtype: {s}")
156
+
157
+
158
+ @lru_cache(maxsize=1)
159
+ def _known_dtype_strs() -> tuple[str, ...]:
160
+ """All dtype strings dtype_to_str() can emit, longest first.
161
+
162
+ Sorting by length keeps multi-underscore names (e.g. "float8_e4m3fn") ahead of their
163
+ prefixes ("float8...") so the longest matching suffix wins.
164
+ """
165
+ names = set()
166
+ for attr in dir(torch):
167
+ try:
168
+ obj = getattr(torch, attr)
169
+ except Exception:
170
+ continue
171
+ if isinstance(obj, torch.dtype):
172
+ names.add(dtype_to_str(obj))
173
+ return tuple(sorted(names, key=len, reverse=True))
174
+
175
+
176
+ def remove_dtype_suffix(name: str) -> str:
177
+ """Remove a trailing ``_<dtype>`` suffix (as written by dtype_to_str) from a cache key.
178
+
179
+ Robust to dtype names that contain underscores such as ``float8_e4m3fn``; a plain
180
+ ``rsplit("_", 1)`` would only drop the final ``fn`` segment. Returns ``name`` unchanged
181
+ if it does not end with a known dtype suffix.
182
+ """
183
+ for dtype_str in _known_dtype_strs():
184
+ suffix = "_" + dtype_str
185
+ if name.endswith(suffix):
186
+ return name[: -len(suffix)]
187
+ return name
188
+
189
+
190
+ def to_device(x: Any, device: torch.device) -> Any:
191
+ if isinstance(x, torch.Tensor):
192
+ return x.to(device)
193
+ elif isinstance(x, list):
194
+ return [to_device(elem, device) for elem in x]
195
+ elif isinstance(x, tuple):
196
+ return tuple(to_device(elem, device) for elem in x)
197
+ elif isinstance(x, dict):
198
+ return {k: to_device(v, device) for k, v in x.items()}
199
+ else:
200
+ return x
201
+
202
+
203
+ def to_cpu(x: Any) -> Any:
204
+ """
205
+ Recursively moves torch.Tensor objects (and containers thereof) to CPU.
206
+
207
+ Args:
208
+ x: A torch.Tensor, or a (possibly nested) list, tuple, or dict containing tensors.
209
+
210
+ Returns:
211
+ The same structure as x, with all torch.Tensor objects moved to CPU.
212
+ Non-tensor objects are returned unchanged.
213
+ """
214
+ if isinstance(x, torch.Tensor):
215
+ return x.cpu()
216
+ elif isinstance(x, list):
217
+ return [to_cpu(elem) for elem in x]
218
+ elif isinstance(x, tuple):
219
+ return tuple(to_cpu(elem) for elem in x)
220
+ elif isinstance(x, dict):
221
+ return {k: to_cpu(v) for k, v in x.items()}
222
+ else:
223
+ return x
224
+
225
+
226
+ def create_cpu_offloading_wrapper(func: Callable, device: torch.device) -> Callable:
227
+ """
228
+ Create a wrapper function that offloads inputs to CPU before calling the original function
229
+ and moves outputs back to the specified device.
230
+
231
+ Args:
232
+ func: The original function to wrap.
233
+ device: The device to move outputs back to.
234
+
235
+ Returns:
236
+ A wrapped function that offloads inputs to CPU and moves outputs back to the specified device.
237
+ """
238
+
239
+ def wrapper(orig_func: Callable) -> Callable:
240
+ def custom_forward(*inputs):
241
+ nonlocal device, orig_func
242
+ cuda_inputs = to_device(inputs, device)
243
+ outputs = orig_func(*cuda_inputs)
244
+ return to_cpu(outputs)
245
+
246
+ return custom_forward
247
+
248
+ return wrapper(func)
249
+
250
+
251
+ def disable_linear_from_compile(module: torch.nn.Module):
252
+ """Monkey-patch to disable torch.compile for all Linear layers (if the class name ends with 'Linear') in the given module."""
253
+ for sub_module in module.modules():
254
+ # if isinstance(sub_module, torch.nn.Linear):
255
+ if sub_module.__class__.__name__.endswith("Linear"):
256
+ if not hasattr(sub_module, "_forward_before_disable_compile"):
257
+ sub_module._forward_before_disable_compile = sub_module.forward
258
+ sub_module._eager_forward = torch._dynamo.disable()(sub_module.forward)
259
+ sub_module.forward = sub_module._eager_forward # override forward to disable compile
260
+
261
+
262
+ def compile_transformer(
263
+ args: argparse.Namespace,
264
+ transformer: torch.nn.Module,
265
+ target_blocks: list[torch.nn.ModuleList | list[torch.nn.Module]],
266
+ disable_linear: bool,
267
+ ) -> torch.nn.Module:
268
+ if disable_linear:
269
+ logger.info("Disable linear from torch.compile for swap blocks...")
270
+ for blocks in target_blocks:
271
+ for block in blocks:
272
+ disable_linear_from_compile(block)
273
+
274
+ compile_dynamic = None
275
+ if args.compile_dynamic is not None:
276
+ compile_dynamic = {"true": True, "false": False, "auto": None}[args.compile_dynamic.lower()]
277
+
278
+ logger.info(
279
+ f"Compiling DiT model with torch.compile: backend={args.compile_backend}, mode={args.compile_mode}, dynamic={compile_dynamic}, fullgraph={args.compile_fullgraph}"
280
+ )
281
+
282
+ if args.compile_cache_size_limit is not None:
283
+ torch._dynamo.config.cache_size_limit = args.compile_cache_size_limit
284
+
285
+ for blocks in target_blocks:
286
+ for i, block in enumerate(blocks):
287
+ block = torch.compile(
288
+ block,
289
+ backend=args.compile_backend,
290
+ mode=args.compile_mode,
291
+ dynamic=compile_dynamic,
292
+ fullgraph=args.compile_fullgraph,
293
+ )
294
+ blocks[i] = block
295
+ return transformer
musubi_tuner/utils/safetensors_utils.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ import os
3
+ import re
4
+ import numpy as np
5
+ import torch
6
+ import json
7
+ import struct
8
+ from typing import Dict, Any, Union, Optional
9
+
10
+ from safetensors.torch import load_file
11
+
12
+ from musubi_tuner.utils.device_utils import synchronize_device
13
+
14
+
15
+ def mem_eff_save_file(tensors: Dict[str, torch.Tensor], filename: str, metadata: Dict[str, Any] = None):
16
+ """
17
+ memory efficient save file
18
+ """
19
+
20
+ _TYPES = {
21
+ torch.float64: "F64",
22
+ torch.float32: "F32",
23
+ torch.float16: "F16",
24
+ torch.bfloat16: "BF16",
25
+ torch.int64: "I64",
26
+ torch.int32: "I32",
27
+ torch.int16: "I16",
28
+ torch.int8: "I8",
29
+ torch.uint8: "U8",
30
+ torch.bool: "BOOL",
31
+ getattr(torch, "float8_e5m2", None): "F8_E5M2",
32
+ getattr(torch, "float8_e4m3fn", None): "F8_E4M3",
33
+ }
34
+ _ALIGN = 256
35
+
36
+ def validate_metadata(metadata: Dict[str, Any]) -> Dict[str, str]:
37
+ validated = {}
38
+ for key, value in metadata.items():
39
+ if not isinstance(key, str):
40
+ raise ValueError(f"Metadata key must be a string, got {type(key)}")
41
+ if not isinstance(value, str):
42
+ print(f"Warning: Metadata value for key '{key}' is not a string. Converting to string.")
43
+ validated[key] = str(value)
44
+ else:
45
+ validated[key] = value
46
+ return validated
47
+
48
+ # print(f"Using memory efficient save file: {filename}")
49
+
50
+ header = {}
51
+ offset = 0
52
+ if metadata:
53
+ header["__metadata__"] = validate_metadata(metadata)
54
+ for k, v in tensors.items():
55
+ if v.numel() == 0: # empty tensor
56
+ header[k] = {"dtype": _TYPES[v.dtype], "shape": list(v.shape), "data_offsets": [offset, offset]}
57
+ else:
58
+ size = v.numel() * v.element_size()
59
+ header[k] = {"dtype": _TYPES[v.dtype], "shape": list(v.shape), "data_offsets": [offset, offset + size]}
60
+ offset += size
61
+
62
+ hjson = json.dumps(header).encode("utf-8")
63
+ hjson += b" " * (-(len(hjson) + 8) % _ALIGN)
64
+
65
+ with open(filename, "wb") as f:
66
+ f.write(struct.pack("<Q", len(hjson)))
67
+ f.write(hjson)
68
+
69
+ for k, v in tensors.items():
70
+ if v.numel() == 0:
71
+ continue
72
+ if v.is_cuda:
73
+ # Direct GPU to disk save
74
+ with torch.cuda.device(v.device):
75
+ if v.dim() == 0: # if scalar, need to add a dimension to work with view
76
+ v = v.unsqueeze(0)
77
+ tensor_bytes = v.contiguous().view(torch.uint8)
78
+ tensor_bytes.cpu().numpy().tofile(f)
79
+ else:
80
+ # CPU tensor save
81
+ if v.dim() == 0: # if scalar, need to add a dimension to work with view
82
+ v = v.unsqueeze(0)
83
+ v.contiguous().view(torch.uint8).numpy().tofile(f)
84
+
85
+
86
+ class MemoryEfficientSafeOpen:
87
+ """Memory-efficient reader for safetensors files.
88
+
89
+ This class provides a memory-efficient way to read tensors from safetensors files
90
+ by using memory mapping for large tensors and avoiding unnecessary copies.
91
+ """
92
+
93
+ def __init__(self, filename, disable_numpy_memmap=False):
94
+ """Initialize the SafeTensor reader.
95
+
96
+ Args:
97
+ filename (str): Path to the safetensors file to read.
98
+ disable_numpy_memmap (bool): If True, disable numpy memory mapping for large tensors, using standard file read instead.
99
+ """
100
+ self.filename = filename
101
+ self.file = open(filename, "rb")
102
+ self.header, self.header_size = self._read_header()
103
+ self.disable_numpy_memmap = disable_numpy_memmap
104
+
105
+ def __enter__(self):
106
+ """Enter context manager."""
107
+ return self
108
+
109
+ def __exit__(self, exc_type, exc_val, exc_tb):
110
+ """Exit context manager and close file."""
111
+ self.file.close()
112
+
113
+ def keys(self):
114
+ """Get all tensor keys in the file.
115
+
116
+ Returns:
117
+ list: List of tensor names (excludes metadata).
118
+ """
119
+ return [k for k in self.header.keys() if k != "__metadata__"]
120
+
121
+ def metadata(self) -> Dict[str, str]:
122
+ """Get metadata from the file.
123
+
124
+ Returns:
125
+ Dict[str, str]: Metadata dictionary.
126
+ """
127
+ return self.header.get("__metadata__", {})
128
+
129
+ def _read_header(self):
130
+ """Read and parse the header from the safetensors file.
131
+
132
+ Returns:
133
+ tuple: (header_dict, header_size) containing parsed header and its size.
134
+ """
135
+ # Read header size (8 bytes, little-endian unsigned long long)
136
+ header_size = struct.unpack("<Q", self.file.read(8))[0]
137
+ # Read and decode header JSON
138
+ header_json = self.file.read(header_size).decode("utf-8")
139
+ return json.loads(header_json), header_size
140
+
141
+ def get_tensor(self, key: str, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None):
142
+ """Load a tensor from the file with memory-efficient strategies.
143
+
144
+ **Note:**
145
+ If device is 'cuda' , the transfer to GPU is done efficiently using pinned memory and non-blocking transfer.
146
+ So you must ensure that the transfer is completed before using the tensor (e.g., by `torch.cuda.synchronize()`).
147
+
148
+ If the tensor is large (>10MB) and the target device is CUDA, memory mapping with numpy.memmap is used to avoid intermediate copies.
149
+
150
+ Args:
151
+ key (str): Name of the tensor to load.
152
+ device (Optional[torch.device]): Target device for the tensor.
153
+ dtype (Optional[torch.dtype]): Target dtype for the tensor.
154
+
155
+ Returns:
156
+ torch.Tensor: The loaded tensor.
157
+
158
+ Raises:
159
+ KeyError: If the tensor key is not found in the file.
160
+ """
161
+ if key not in self.header:
162
+ raise KeyError(f"Tensor '{key}' not found in the file")
163
+
164
+ metadata = self.header[key]
165
+ offset_start, offset_end = metadata["data_offsets"]
166
+ num_bytes = offset_end - offset_start
167
+
168
+ original_dtype = self._get_torch_dtype(metadata["dtype"])
169
+ target_dtype = dtype if dtype is not None else original_dtype
170
+
171
+ # Handle empty tensors
172
+ if num_bytes == 0:
173
+ return torch.empty(metadata["shape"], dtype=target_dtype, device=device)
174
+
175
+ # Determine if we should use pinned memory for GPU transfer
176
+ non_blocking = device is not None and device.type == "cuda"
177
+
178
+ # Calculate absolute file offset
179
+ tensor_offset = self.header_size + 8 + offset_start # adjust offset by header size
180
+
181
+ # Memory mapping strategy for large tensors to GPU
182
+ # Use memmap for large tensors to avoid intermediate copies.
183
+ # If device is cpu, tensor is not copied to gpu, so using memmap locks the file, which is not desired.
184
+ # So we only use memmap if device is not cpu.
185
+ # If disable_numpy_memmap is True, skip numpy memory mapping to load with standard file read.
186
+ if not self.disable_numpy_memmap and num_bytes > 10 * 1024 * 1024 and device is not None and device.type != "cpu":
187
+ # Create memory map for zero-copy reading
188
+ mm = np.memmap(self.filename, mode="c", dtype=np.uint8, offset=tensor_offset, shape=(num_bytes,))
189
+ byte_tensor = torch.from_numpy(mm) # zero copy
190
+ del mm
191
+
192
+ # Deserialize tensor (view and reshape)
193
+ cpu_tensor = self._deserialize_tensor(byte_tensor, metadata) # view and reshape
194
+ del byte_tensor
195
+
196
+ # Transfer to target device and dtype
197
+ gpu_tensor = cpu_tensor.to(device=device, dtype=target_dtype, non_blocking=non_blocking)
198
+ del cpu_tensor
199
+ return gpu_tensor
200
+
201
+ # Standard file reading strategy for smaller tensors or CPU target
202
+ # seek to the specified position
203
+ self.file.seek(tensor_offset)
204
+
205
+ # read directly into a numpy array by numpy.fromfile without intermediate copy
206
+ numpy_array = np.fromfile(self.file, dtype=np.uint8, count=num_bytes)
207
+ byte_tensor = torch.from_numpy(numpy_array)
208
+ del numpy_array
209
+
210
+ # deserialize (view and reshape)
211
+ deserialized_tensor = self._deserialize_tensor(byte_tensor, metadata)
212
+ del byte_tensor
213
+
214
+ # cast to target dtype and move to device
215
+ return deserialized_tensor.to(device=device, dtype=target_dtype, non_blocking=non_blocking)
216
+
217
+ def _deserialize_tensor(self, byte_tensor: torch.Tensor, metadata: Dict):
218
+ """Deserialize byte tensor to the correct shape and dtype.
219
+
220
+ Args:
221
+ byte_tensor (torch.Tensor): Raw byte tensor from file.
222
+ metadata (Dict): Tensor metadata containing dtype and shape info.
223
+
224
+ Returns:
225
+ torch.Tensor: Deserialized tensor with correct shape and dtype.
226
+ """
227
+ dtype = self._get_torch_dtype(metadata["dtype"])
228
+ shape = metadata["shape"]
229
+
230
+ # Handle special float8 types
231
+ if metadata["dtype"] in ["F8_E5M2", "F8_E4M3"]:
232
+ return self._convert_float8(byte_tensor, metadata["dtype"], shape)
233
+
234
+ # Standard conversion: view as target dtype and reshape
235
+ return byte_tensor.view(dtype).reshape(shape)
236
+
237
+ @staticmethod
238
+ def _get_torch_dtype(dtype_str):
239
+ """Convert string dtype to PyTorch dtype.
240
+
241
+ Args:
242
+ dtype_str (str): String representation of the dtype.
243
+
244
+ Returns:
245
+ torch.dtype: Corresponding PyTorch dtype.
246
+ """
247
+ # Standard dtype mappings
248
+ dtype_map = {
249
+ "F64": torch.float64,
250
+ "F32": torch.float32,
251
+ "F16": torch.float16,
252
+ "BF16": torch.bfloat16,
253
+ "I64": torch.int64,
254
+ "I32": torch.int32,
255
+ "I16": torch.int16,
256
+ "I8": torch.int8,
257
+ "U8": torch.uint8,
258
+ "BOOL": torch.bool,
259
+ }
260
+ # Add float8 types if available in PyTorch version
261
+ if hasattr(torch, "float8_e5m2"):
262
+ dtype_map["F8_E5M2"] = torch.float8_e5m2
263
+ if hasattr(torch, "float8_e4m3fn"):
264
+ dtype_map["F8_E4M3"] = torch.float8_e4m3fn
265
+ return dtype_map.get(dtype_str)
266
+
267
+ @staticmethod
268
+ def _convert_float8(byte_tensor, dtype_str, shape):
269
+ """Convert byte tensor to float8 format if supported.
270
+
271
+ Args:
272
+ byte_tensor (torch.Tensor): Raw byte tensor.
273
+ dtype_str (str): Float8 dtype string ("F8_E5M2" or "F8_E4M3").
274
+ shape (tuple): Target tensor shape.
275
+
276
+ Returns:
277
+ torch.Tensor: Tensor with float8 dtype.
278
+
279
+ Raises:
280
+ ValueError: If float8 type is not supported in current PyTorch version.
281
+ """
282
+ # Convert to specific float8 types if available
283
+ if dtype_str == "F8_E5M2" and hasattr(torch, "float8_e5m2"):
284
+ return byte_tensor.view(torch.float8_e5m2).reshape(shape)
285
+ elif dtype_str == "F8_E4M3" and hasattr(torch, "float8_e4m3fn"):
286
+ return byte_tensor.view(torch.float8_e4m3fn).reshape(shape)
287
+ else:
288
+ # Float8 not supported in this PyTorch version
289
+ raise ValueError(f"Unsupported float8 type: {dtype_str} (upgrade PyTorch to support float8 types)")
290
+
291
+
292
+ def load_safetensors(
293
+ path: str,
294
+ device: Union[str, torch.device],
295
+ disable_mmap: bool = False,
296
+ dtype: Optional[torch.dtype] = None,
297
+ disable_numpy_memmap: bool = False,
298
+ ) -> dict[str, torch.Tensor]:
299
+ if disable_mmap:
300
+ # return safetensors.torch.load(open(path, "rb").read())
301
+ # use experimental loader
302
+ # logger.info(f"Loading without mmap (experimental)")
303
+ state_dict = {}
304
+ device = torch.device(device) if device is not None else None
305
+ with MemoryEfficientSafeOpen(path, disable_numpy_memmap=disable_numpy_memmap) as f:
306
+ for key in f.keys():
307
+ state_dict[key] = f.get_tensor(key, device=device, dtype=dtype)
308
+ synchronize_device(device)
309
+ return state_dict
310
+ else:
311
+ try:
312
+ state_dict = load_file(path, device=device)
313
+ except:
314
+ state_dict = load_file(path) # prevent device invalid Error
315
+ if dtype is not None:
316
+ for key in state_dict.keys():
317
+ state_dict[key] = state_dict[key].to(dtype=dtype)
318
+ return state_dict
319
+
320
+
321
+ def get_split_weight_filenames(file_path: str) -> Optional[list[str]]:
322
+ """
323
+ Get the list of split weight filenames (full paths) if the file name ends with 00001-of-00004 etc.
324
+ Returns None if the file is not split.
325
+ """
326
+ basename = os.path.basename(file_path)
327
+ match = re.match(r"^(.*?)(\d+)-of-(\d+)\.safetensors$", basename)
328
+ if match:
329
+ prefix = basename[: match.start(2)]
330
+ count = int(match.group(3))
331
+ filenames = []
332
+ for i in range(count):
333
+ filename = f"{prefix}{i + 1:05d}-of-{count:05d}.safetensors"
334
+ filepath = os.path.join(os.path.dirname(file_path), filename)
335
+ if os.path.exists(filepath):
336
+ filenames.append(filepath)
337
+ else:
338
+ raise FileNotFoundError(f"File {filepath} not found")
339
+ return filenames
340
+ else:
341
+ return None
342
+
343
+
344
+ def load_split_weights(
345
+ file_path: str, device: Union[str, torch.device] = "cpu", disable_mmap: bool = False, dtype: Optional[torch.dtype] = None
346
+ ) -> Dict[str, torch.Tensor]:
347
+ """
348
+ Load split weights from a file. If the file name ends with 00001-of-00004 etc, it will load all files with the same prefix.
349
+ dtype is as is, no conversion is done.
350
+ """
351
+ device = torch.device(device)
352
+
353
+ # if the file name ends with 00001-of-00004 etc, we need to load the files with the same prefix
354
+ split_filenames = get_split_weight_filenames(file_path)
355
+ if split_filenames is not None:
356
+ state_dict = {}
357
+ for filename in split_filenames:
358
+ state_dict.update(load_safetensors(filename, device=device, disable_mmap=disable_mmap, dtype=dtype))
359
+ else:
360
+ state_dict = load_safetensors(file_path, device=device, disable_mmap=disable_mmap, dtype=dtype)
361
+ return state_dict
362
+
363
+
364
+ def find_key(safetensors_file: str, starts_with: Optional[str] = None, ends_with: Optional[str] = None) -> Optional[str]:
365
+ """
366
+ Find a key in a safetensors file that starts with `starts_with` and ends with `ends_with`.
367
+ If `starts_with` is None, it will match any key.
368
+ If `ends_with` is None, it will match any key.
369
+ Returns the first matching key or None if no key matches.
370
+ """
371
+ with MemoryEfficientSafeOpen(safetensors_file) as f:
372
+ for key in f.keys():
373
+ if (starts_with is None or key.startswith(starts_with)) and (ends_with is None or key.endswith(ends_with)):
374
+ return key
375
+ return None
376
+
377
+
378
+ def find_keys(safetensors_file: str, starts_with: Optional[str] = None, ends_with: Optional[str] = None) -> list[str]:
379
+ """
380
+ Find all keys in a safetensors file that start with `starts_with` and end with `ends_with`.
381
+ If `starts_with` is None, it will match any key. If `ends_with` is None, it will match any key.
382
+
383
+ The matching keys are returned sorted, so callers get a deterministic order regardless of the
384
+ order the keys happen to be stored in the file header. This matters when the keys are folded into
385
+ a bucket key (e.g. ``latents_control_{i}``): otherwise two files holding the same tensors in a
386
+ different header order, or with the index<->shape association swapped, could end up in different
387
+ buckets or be wrongly batched together.
388
+ """
389
+ with MemoryEfficientSafeOpen(safetensors_file) as f:
390
+ keys = [
391
+ key
392
+ for key in f.keys()
393
+ if (starts_with is None or key.startswith(starts_with)) and (ends_with is None or key.endswith(ends_with))
394
+ ]
395
+ return sorted(keys)
396
+
397
+
398
+ @dataclass
399
+ class WeightTransformHooks:
400
+ split_hook: Optional[callable] = None
401
+ concat_hook: Optional[callable] = None
402
+
403
+
404
+ class TensorWeightAdapter:
405
+ """
406
+ A wrapper for weight conversion hooks (split and concat) to be used with MemoryEfficientSafeOpen.
407
+ This wrapper adapts the original MemoryEfficientSafeOpen to apply the provided split and concat hooks
408
+ when loading tensors.
409
+
410
+ split_hook: A callable that takes (original_key: str, original_tensor: torch.Tensor) and returns (new_keys: list[str], new_tensors: list[torch.Tensor]).
411
+ concat_hook: A callable that takes (original_key: str, tensors: dict[str, torch.Tensor]) and returns (new_key: str, concatenated_tensor: torch.Tensor).
412
+
413
+ If tensors is None, the hook should return only the new keys (for split) or new key (for concat), without tensors.
414
+
415
+ No need to implement __enter__ and __exit__ methods, as they are handled by the original MemoryEfficientSafeOpen.
416
+ Do not use this wrapper as a context manager directly, like `with WeightConvertHookWrapper(...) as f:`.
417
+
418
+ **concat_hook is not tested yet.**
419
+ """
420
+
421
+ def __init__(self, weight_convert_hook: WeightTransformHooks, original_f: MemoryEfficientSafeOpen):
422
+ self.original_f = original_f
423
+ self.new_key_to_original_key_map: dict[
424
+ str, Union[str, list[str]]
425
+ ] = {} # for split: new_key -> original_key; for concat: new_key -> list of original_keys
426
+ self.concat_key_set = set() # set of keys that are created by concat_hook, to distinguish from split_hook
427
+ self.new_keys = []
428
+ self.tensor_cache = {} # cache for split tensors
429
+ self.split_hook = weight_convert_hook.split_hook
430
+ self.concat_hook = weight_convert_hook.concat_hook
431
+
432
+ for key in self.original_f.keys():
433
+ if self.split_hook is not None:
434
+ converted_keys, _ = self.split_hook(key, None) # get new keys only
435
+ if converted_keys is not None:
436
+ for new_key in converted_keys:
437
+ self.new_key_to_original_key_map[new_key] = key
438
+ self.new_keys.extend(converted_keys)
439
+ continue # skip concat_hook if split_hook is applied
440
+
441
+ if self.concat_hook is not None:
442
+ converted_key, _ = self.concat_hook(key, None) # get new key only
443
+ if converted_key is not None:
444
+ if converted_key not in self.concat_key_set: # first time seeing this concatenated key
445
+ self.concat_key_set.add(converted_key)
446
+ self.new_key_to_original_key_map[converted_key] = []
447
+
448
+ # multiple original keys map to the same concatenated key
449
+ self.new_key_to_original_key_map[converted_key].append(key)
450
+
451
+ self.new_keys.append(converted_key)
452
+ continue # skip to next key
453
+
454
+ # direct mapping
455
+ self.new_keys.append(key)
456
+
457
+ def keys(self) -> list[str]:
458
+ return self.new_keys
459
+
460
+ def get_tensor(self, new_key: str, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None) -> torch.Tensor:
461
+ # load tensor by new_key, applying split or concat hooks as needed
462
+ if new_key not in self.new_key_to_original_key_map:
463
+ # direct mapping
464
+ return self.original_f.get_tensor(new_key, device=device, dtype=dtype)
465
+
466
+ elif new_key not in self.concat_key_set:
467
+ # split hook: split key is requested multiple times, so we cache the result
468
+ original_key = self.new_key_to_original_key_map[new_key]
469
+ if original_key not in self.tensor_cache: # not yet split
470
+ original_tensor = self.original_f.get_tensor(original_key, device=device, dtype=dtype)
471
+ new_keys, new_tensors = self.split_hook(original_key, original_tensor) # apply split hook
472
+ for k, t in zip(new_keys, new_tensors):
473
+ self.tensor_cache[k] = t
474
+ return self.tensor_cache.pop(new_key) # return and remove from cache
475
+
476
+ else:
477
+ # concat hook: concatenated key is requested only once, so we do not cache the result
478
+ tensors = {}
479
+ for original_key in self.new_key_to_original_key_map[new_key]:
480
+ tensor = self.original_f.get_tensor(original_key, device=device, dtype=dtype)
481
+ tensors[original_key] = tensor
482
+ _, concatenated_tensors = self.concat_hook(self.new_key_to_original_key_map[new_key][0], tensors) # apply concat hook
483
+ return concatenated_tensors
musubi_tuner/utils/sai_model_spec.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # based on https://github.com/Stability-AI/ModelSpec
2
+ import datetime
3
+ import hashlib
4
+ from io import BytesIO
5
+ import os
6
+ from typing import List, Optional, Tuple, Union
7
+ import safetensors
8
+ import logging
9
+
10
+ from musubi_tuner.dataset.image_video_dataset import (
11
+ ARCHITECTURE_HUNYUAN_VIDEO,
12
+ ARCHITECTURE_HUNYUAN_VIDEO_1_5,
13
+ ARCHITECTURE_HIDREAM_O1,
14
+ ARCHITECTURE_IDEOGRAM4,
15
+ ARCHITECTURE_QWEN_IMAGE,
16
+ ARCHITECTURE_QWEN_IMAGE_EDIT,
17
+ ARCHITECTURE_QWEN_IMAGE_LAYERED,
18
+ ARCHITECTURE_WAN,
19
+ ARCHITECTURE_FRAMEPACK,
20
+ ARCHITECTURE_FLUX_KONTEXT,
21
+ ARCHITECTURE_FLUX_2_DEV,
22
+ ARCHITECTURE_FLUX_2_KLEIN_4B,
23
+ ARCHITECTURE_FLUX_2_KLEIN_9B,
24
+ ARCHITECTURE_KANDINSKY5,
25
+ ARCHITECTURE_KREA2,
26
+ ARCHITECTURE_Z_IMAGE,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+ logger.setLevel(logging.INFO)
31
+
32
+
33
+ r"""
34
+ # Metadata Example
35
+ metadata = {
36
+ # === Must ===
37
+ "modelspec.sai_model_spec": "1.0.0", # Required version ID for the spec
38
+ "modelspec.architecture": "stable-diffusion-xl-v1-base", # Architecture, reference the ID of the original model of the arch to match the ID
39
+ "modelspec.implementation": "sgm",
40
+ "modelspec.title": "Example Model Version 1.0", # Clean, human-readable title. May use your own phrasing/language/etc
41
+ # === Should ===
42
+ "modelspec.author": "Example Corp", # Your name or company name
43
+ "modelspec.description": "This is my example model to show you how to do it!", # Describe the model in your own words/language/etc. Focus on what users need to know
44
+ "modelspec.date": "2023-07-20", # ISO-8601 compliant date of when the model was created
45
+ # === Can ===
46
+ "modelspec.license": "ExampleLicense-1.0", # eg CreativeML Open RAIL, etc.
47
+ "modelspec.usage_hint": "Use keyword 'example'" # In your own language, very short hints about how the user should use the model
48
+ }
49
+ """
50
+
51
+ BASE_METADATA = {
52
+ # === Must ===
53
+ "modelspec.sai_model_spec": "1.0.0", # Required version ID for the spec
54
+ "modelspec.architecture": None,
55
+ "modelspec.implementation": None,
56
+ "modelspec.title": None,
57
+ "modelspec.resolution": None,
58
+ # === Should ===
59
+ "modelspec.description": None,
60
+ "modelspec.author": None,
61
+ "modelspec.date": None,
62
+ # === Can ===
63
+ "modelspec.license": None,
64
+ "modelspec.tags": None,
65
+ "modelspec.merged_from": None,
66
+ "modelspec.prediction_type": None,
67
+ "modelspec.timestep_range": None,
68
+ "modelspec.encoder_layer": None,
69
+ }
70
+
71
+ # 別に使うやつだけ定義
72
+ MODELSPEC_TITLE = "modelspec.title"
73
+
74
+ ARCH_HUNYUAN_VIDEO = "hunyuan-video"
75
+
76
+ # Official Wan2.1 weights does not have sai_model_spec, so we use this as an architecture name
77
+ ARCH_WAN = "wan2.1"
78
+
79
+ ARCH_FRAMEPACK = "framepack"
80
+ ARCH_FLUX_KONTEXT = "Flux.1-dev"
81
+ ARCH_FLUX_2_DEV = "Flux.2-dev"
82
+ ARCH_FLUX_2_KLEIN_4B = "Flux.2-klein-4b"
83
+ ARCH_FLUX_2_KLEIN_9B = "Flux.2-klein-9b"
84
+ ARCH_QWEN_IMAGE = "Qwen-Image"
85
+ ARCH_QWEN_IMAGE_EDIT = "Qwen-Image-Edit"
86
+ ARCH_QWEN_IMAGE_EDIT_PLUS = "Qwen-Image-Edit-Plus"
87
+ ARCH_QWEN_IMAGE_EDIT_2511 = "Qwen-Image-Edit-2511"
88
+ CUSTOM_ARCH_QWEN_IMAGE_EDIT_PLUS = "@@Qwen-Image-Edit-Plus@@" # special custom architecture name for Qwen-Image-Edit-Plus
89
+ CUSTOM_ARCH_QWEN_IMAGE_EDIT_2511 = "@@Qwen-Image-Edit-2511@@" # special custom architecture name for Qwen-Image-Edit-2511
90
+ ARCH_QWEN_IMAGE_LAYERED = "Qwen-Image-Layered"
91
+ ARCH_KANDINSKY5 = "Kandinsky-5"
92
+ ARCH_HUNYUAN_VIDEO_1_5 = "hunyuan-video-1.5"
93
+ ARCH_Z_IMAGE = "Z-Image"
94
+ ARCH_HIDREAM_O1 = "HiDream-O1-Image"
95
+ ARCH_IDEOGRAM4 = "Ideogram-4"
96
+ ARCH_KREA2 = "Krea-2"
97
+
98
+ ADAPTER_LORA = "lora"
99
+
100
+ IMPL_HUNYUAN_VIDEO = "https://github.com/Tencent/HunyuanVideo"
101
+ IMPL_WAN = "https://github.com/Wan-Video/Wan2.1"
102
+ IMPL_FRAMEPACK = "https://github.com/lllyasviel/FramePack"
103
+ IMPL_FLUX_KONTEXT = "https://github.com/black-forest-labs/flux"
104
+ IMPL_FLUX_2 = "https://github.com/black-forest-labs/flux2"
105
+ IMPL_QWEN_IMAGE = "https://github.com/QwenLM/Qwen-Image"
106
+ IMPL_QWEN_IMAGE_EDIT = IMPL_QWEN_IMAGE
107
+ IMPL_QWEN_IMAGE_LAYERED = "https://github.com/QwenLM/Qwen-Image-Layered"
108
+ IMPL_KANDINSKY5 = "https://github.com/kandinskylab/kandinsky-5"
109
+ IMPL_HUNYUAN_VIDEO_1_5 = "https://github.com/Tencent-Hunyuan/HunyuanVideo-1.5"
110
+ IMPL_Z_IMAGE = "https://github.com/Tongyi-MAI/Z-Image"
111
+ IMPL_HIDREAM_O1 = "https://github.com/HiDream-ai/HiDream-O1-Image"
112
+ IMPL_IDEOGRAM4 = "https://huggingface.co/Comfy-Org/Ideogram-4"
113
+ IMPL_KREA2 = "https://github.com/krea-ai/krea-2"
114
+
115
+ PRED_TYPE_EPSILON = "epsilon"
116
+ # PRED_TYPE_V = "v"
117
+
118
+
119
+ def load_bytes_in_safetensors(tensors):
120
+ bytes = safetensors.torch.save(tensors)
121
+ b = BytesIO(bytes)
122
+
123
+ b.seek(0)
124
+ header = b.read(8)
125
+ n = int.from_bytes(header, "little")
126
+
127
+ offset = n + 8
128
+ b.seek(offset)
129
+
130
+ return b.read()
131
+
132
+
133
+ def precalculate_safetensors_hashes(state_dict):
134
+ # calculate each tensor one by one to reduce memory usage
135
+ hash_sha256 = hashlib.sha256()
136
+ for tensor in state_dict.values():
137
+ single_tensor_sd = {"tensor": tensor}
138
+ bytes_for_tensor = load_bytes_in_safetensors(single_tensor_sd)
139
+ hash_sha256.update(bytes_for_tensor)
140
+
141
+ return f"0x{hash_sha256.hexdigest()}"
142
+
143
+
144
+ def update_hash_sha256(metadata: dict, state_dict: dict):
145
+ raise NotImplementedError
146
+
147
+
148
+ def build_metadata(
149
+ state_dict: Optional[dict],
150
+ architecture: str,
151
+ timestamp: float,
152
+ title: Optional[str] = None,
153
+ reso: Optional[Union[str, int, Tuple[int, int]]] = None,
154
+ author: Optional[str] = None,
155
+ description: Optional[str] = None,
156
+ license: Optional[str] = None,
157
+ tags: Optional[str] = None,
158
+ merged_from: Optional[str] = None,
159
+ timesteps: Optional[Tuple[int, int]] = None,
160
+ is_lora: bool = True,
161
+ custom_arch: Optional[str] = None,
162
+ ):
163
+ metadata = {}
164
+ metadata.update(BASE_METADATA)
165
+
166
+ # TODO implement if we can calculate hash without loading all tensors
167
+ # if state_dict is not None:
168
+ # hash = precalculate_safetensors_hashes(state_dict)
169
+ # metadata["modelspec.hash_sha256"] = hash
170
+
171
+ # arch = ARCH_HUNYUAN_VIDEO
172
+ if architecture == ARCHITECTURE_HUNYUAN_VIDEO:
173
+ arch = ARCH_HUNYUAN_VIDEO
174
+ impl = IMPL_HUNYUAN_VIDEO
175
+ elif architecture == ARCHITECTURE_WAN:
176
+ arch = ARCH_WAN
177
+ impl = IMPL_WAN
178
+ elif architecture == ARCHITECTURE_FRAMEPACK:
179
+ arch = ARCH_FRAMEPACK
180
+ impl = IMPL_FRAMEPACK
181
+ elif architecture == ARCHITECTURE_FLUX_KONTEXT:
182
+ arch = ARCH_FLUX_KONTEXT
183
+ impl = IMPL_FLUX_KONTEXT
184
+ elif (
185
+ architecture == ARCHITECTURE_FLUX_2_DEV
186
+ or architecture == ARCHITECTURE_FLUX_2_KLEIN_4B
187
+ or architecture == ARCHITECTURE_FLUX_2_KLEIN_9B
188
+ ):
189
+ if architecture == ARCHITECTURE_FLUX_2_DEV:
190
+ arch = ARCH_FLUX_2_DEV
191
+ elif architecture == ARCHITECTURE_FLUX_2_KLEIN_4B:
192
+ arch = ARCH_FLUX_2_KLEIN_4B
193
+ elif architecture == ARCHITECTURE_FLUX_2_KLEIN_9B:
194
+ arch = ARCH_FLUX_2_KLEIN_9B
195
+ impl = IMPL_FLUX_2
196
+ elif architecture == ARCHITECTURE_QWEN_IMAGE:
197
+ arch = ARCH_QWEN_IMAGE
198
+ impl = IMPL_QWEN_IMAGE
199
+ elif architecture == ARCHITECTURE_QWEN_IMAGE_EDIT:
200
+ # We treat Qwen-Image-Edit and Qwen-Image-Edit-Plus the same for architecture and implementation
201
+ # So we must distinguish them by custom_arch if needed
202
+ impl = IMPL_QWEN_IMAGE_EDIT
203
+ if custom_arch is None:
204
+ arch = ARCH_QWEN_IMAGE_EDIT
205
+ elif custom_arch == CUSTOM_ARCH_QWEN_IMAGE_EDIT_PLUS:
206
+ arch = ARCH_QWEN_IMAGE_EDIT_PLUS
207
+ custom_arch = None # clear custom_arch to avoid override later
208
+ elif custom_arch == CUSTOM_ARCH_QWEN_IMAGE_EDIT_2511:
209
+ arch = ARCH_QWEN_IMAGE_EDIT_2511
210
+ custom_arch = None # clear custom_arch to avoid override later
211
+ else:
212
+ arch = ARCH_QWEN_IMAGE_EDIT # override with custom_arch later
213
+ elif architecture == ARCHITECTURE_QWEN_IMAGE_LAYERED:
214
+ arch = ARCH_QWEN_IMAGE_LAYERED
215
+ impl = IMPL_QWEN_IMAGE_LAYERED
216
+ elif architecture == ARCHITECTURE_KANDINSKY5:
217
+ arch = ARCH_KANDINSKY5
218
+ impl = IMPL_KANDINSKY5
219
+ elif architecture == ARCHITECTURE_HUNYUAN_VIDEO_1_5:
220
+ arch = ARCH_HUNYUAN_VIDEO_1_5
221
+ impl = IMPL_HUNYUAN_VIDEO_1_5
222
+ elif architecture == ARCHITECTURE_Z_IMAGE:
223
+ arch = ARCH_Z_IMAGE
224
+ impl = IMPL_Z_IMAGE
225
+ elif architecture == ARCHITECTURE_HIDREAM_O1:
226
+ arch = ARCH_HIDREAM_O1
227
+ impl = IMPL_HIDREAM_O1
228
+ elif architecture == ARCHITECTURE_IDEOGRAM4:
229
+ arch = ARCH_IDEOGRAM4
230
+ impl = IMPL_IDEOGRAM4
231
+ elif architecture == ARCHITECTURE_KREA2:
232
+ arch = ARCH_KREA2
233
+ impl = IMPL_KREA2
234
+ else:
235
+ raise ValueError(f"Unknown architecture: {architecture}")
236
+
237
+ # Override with custom architecture if provided
238
+ if custom_arch is not None:
239
+ arch = custom_arch
240
+
241
+ if is_lora:
242
+ arch += f"/{ADAPTER_LORA}"
243
+ metadata["modelspec.architecture"] = arch
244
+
245
+ metadata["modelspec.implementation"] = impl
246
+
247
+ if title is None:
248
+ title = "LoRA" if is_lora else "Hunyuan-Video"
249
+ title += f"@{timestamp}"
250
+ metadata[MODELSPEC_TITLE] = title
251
+
252
+ if author is not None:
253
+ metadata["modelspec.author"] = author
254
+ else:
255
+ del metadata["modelspec.author"]
256
+
257
+ if description is not None:
258
+ metadata["modelspec.description"] = description
259
+ else:
260
+ del metadata["modelspec.description"]
261
+
262
+ if merged_from is not None:
263
+ metadata["modelspec.merged_from"] = merged_from
264
+ else:
265
+ del metadata["modelspec.merged_from"]
266
+
267
+ if license is not None:
268
+ metadata["modelspec.license"] = license
269
+ else:
270
+ del metadata["modelspec.license"]
271
+
272
+ if tags is not None:
273
+ metadata["modelspec.tags"] = tags
274
+ else:
275
+ del metadata["modelspec.tags"]
276
+
277
+ # remove microsecond from time
278
+ int_ts = int(timestamp)
279
+
280
+ # time to iso-8601 compliant date
281
+ date = datetime.datetime.fromtimestamp(int_ts).isoformat()
282
+ metadata["modelspec.date"] = date
283
+
284
+ if reso is not None:
285
+ # comma separated to tuple
286
+ if isinstance(reso, str):
287
+ reso = tuple(map(int, reso.split(",")))
288
+ if len(reso) == 1:
289
+ reso = (reso[0], reso[0])
290
+ else:
291
+ # resolution is defined in dataset, so use default here
292
+ # Use 1328x1328 for Qwen-Image, 1024x1024 for Qwen-Image-Edit and Z-Image, or 1280x720 for others (this is just a placeholder, actual resolution may vary)
293
+ if architecture == ARCHITECTURE_QWEN_IMAGE:
294
+ reso = (1328, 1328)
295
+ elif architecture == ARCHITECTURE_QWEN_IMAGE_EDIT:
296
+ reso = (1024, 1024)
297
+ elif architecture == ARCHITECTURE_Z_IMAGE:
298
+ reso = (1024, 1024)
299
+ elif architecture == ARCHITECTURE_HIDREAM_O1:
300
+ reso = (2048, 2048)
301
+ elif architecture == ARCHITECTURE_IDEOGRAM4:
302
+ reso = (1024, 1024)
303
+ elif architecture == ARCHITECTURE_KREA2:
304
+ reso = (1024, 1024)
305
+ else:
306
+ reso = (1280, 720)
307
+ if isinstance(reso, int):
308
+ reso = (reso, reso)
309
+
310
+ metadata["modelspec.resolution"] = f"{reso[0]}x{reso[1]}"
311
+
312
+ # metadata["modelspec.prediction_type"] = PRED_TYPE_EPSILON
313
+ del metadata["modelspec.prediction_type"]
314
+
315
+ if timesteps is not None:
316
+ if isinstance(timesteps, str) or isinstance(timesteps, int):
317
+ timesteps = (timesteps, timesteps)
318
+ if len(timesteps) == 1:
319
+ timesteps = (timesteps[0], timesteps[0])
320
+ metadata["modelspec.timestep_range"] = f"{timesteps[0]},{timesteps[1]}"
321
+ else:
322
+ del metadata["modelspec.timestep_range"]
323
+
324
+ # if clip_skip is not None:
325
+ # metadata["modelspec.encoder_layer"] = f"{clip_skip}"
326
+ # else:
327
+ del metadata["modelspec.encoder_layer"]
328
+
329
+ # # assert all values are filled
330
+ # assert all([v is not None for v in metadata.values()]), metadata
331
+ if not all([v is not None for v in metadata.values()]):
332
+ logger.error(f"Internal error: some metadata values are None: {metadata}")
333
+
334
+ return metadata
335
+
336
+
337
+ # region utils
338
+
339
+
340
+ def get_title(metadata: dict) -> Optional[str]:
341
+ return metadata.get(MODELSPEC_TITLE, None)
342
+
343
+
344
+ def load_metadata_from_safetensors(model: str) -> dict:
345
+ if not model.endswith(".safetensors"):
346
+ return {}
347
+
348
+ with safetensors.safe_open(model, framework="pt") as f:
349
+ metadata = f.metadata()
350
+ if metadata is None:
351
+ metadata = {}
352
+ return metadata
353
+
354
+
355
+ def build_merged_from(models: List[str]) -> str:
356
+ def get_title(model: str):
357
+ metadata = load_metadata_from_safetensors(model)
358
+ title = metadata.get(MODELSPEC_TITLE, None)
359
+ if title is None:
360
+ title = os.path.splitext(os.path.basename(model))[0] # use filename
361
+ return title
362
+
363
+ titles = [get_title(model) for model in models]
364
+ return ", ".join(titles)
365
+
366
+
367
+ # endregion
368
+
369
+
370
+ r"""
371
+ if __name__ == "__main__":
372
+ import argparse
373
+ import torch
374
+ from safetensors.torch import load_file
375
+ from library import train_util
376
+
377
+ parser = argparse.ArgumentParser()
378
+ parser.add_argument("--ckpt", type=str, required=True)
379
+ args = parser.parse_args()
380
+
381
+ print(f"Loading {args.ckpt}")
382
+ state_dict = load_file(args.ckpt)
383
+
384
+ print(f"Calculating metadata")
385
+ metadata = get(state_dict, False, False, False, False, "sgm", False, False, "title", "date", 256, 1000, 0)
386
+ print(metadata)
387
+ del state_dict
388
+
389
+ # by reference implementation
390
+ with open(args.ckpt, mode="rb") as file_data:
391
+ file_hash = hashlib.sha256()
392
+ head_len = struct.unpack("Q", file_data.read(8)) # int64 header length prefix
393
+ header = json.loads(file_data.read(head_len[0])) # header itself, json string
394
+ content = (
395
+ file_data.read()
396
+ ) # All other content is tightly packed tensors. Copy to RAM for simplicity, but you can avoid this read with a more careful FS-dependent impl.
397
+ file_hash.update(content)
398
+ # ===== Update the hash for modelspec =====
399
+ by_ref = f"0x{file_hash.hexdigest()}"
400
+ print(by_ref)
401
+ print("is same?", by_ref == metadata["modelspec.hash_sha256"])
402
+
403
+ """
musubi_tuner/utils/train_utils.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import shutil
5
+ from typing import Callable
6
+
7
+ import accelerate
8
+ import torch
9
+
10
+ from musubi_tuner.utils import huggingface_utils
11
+ from musubi_tuner.utils.model_utils import str_to_dtype
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logging.basicConfig(level=logging.INFO)
15
+
16
+
17
+ # checkpointファイル名
18
+ EPOCH_STATE_NAME = "{}-{:06d}-state"
19
+ EPOCH_FILE_NAME = "{}-{:06d}"
20
+ EPOCH_DIFFUSERS_DIR_NAME = "{}-{:06d}"
21
+ LAST_STATE_NAME = "{}-state"
22
+ STEP_STATE_NAME = "{}-step{:08d}-state"
23
+ STEP_FILE_NAME = "{}-step{:08d}"
24
+ STEP_DIFFUSERS_DIR_NAME = "{}-step{:08d}"
25
+
26
+
27
+ def get_sanitized_config_or_none(args: argparse.Namespace):
28
+ # if `--log_config` is enabled, return args for logging. if not, return None.
29
+ # when `--log_config is enabled, filter out sensitive values from args
30
+ # if wandb is not enabled, the log is not exposed to the public, but it is fine to filter out sensitive values to be safe
31
+
32
+ if not args.log_config:
33
+ return None
34
+
35
+ sensitive_args = ["wandb_api_key", "huggingface_token"]
36
+ sensitive_path_args = [
37
+ "dit",
38
+ "vae",
39
+ "text_encoder1",
40
+ "text_encoder2",
41
+ "image_encoder",
42
+ "base_weights",
43
+ "network_weights",
44
+ "output_dir",
45
+ "logging_dir",
46
+ ]
47
+ filtered_args = {}
48
+ for k, v in vars(args).items():
49
+ # filter out sensitive values and convert to string if necessary
50
+ if k not in sensitive_args + sensitive_path_args:
51
+ # Accelerate values need to have type `bool`,`str`, `float`, `int`, or `None`.
52
+ if v is None or isinstance(v, bool) or isinstance(v, str) or isinstance(v, float) or isinstance(v, int):
53
+ filtered_args[k] = v
54
+ # accelerate does not support lists
55
+ elif isinstance(v, list):
56
+ filtered_args[k] = f"{v}"
57
+ # accelerate does not support objects
58
+ elif isinstance(v, object):
59
+ filtered_args[k] = f"{v}"
60
+
61
+ return filtered_args
62
+
63
+
64
+ class LossRecorder:
65
+ def __init__(self):
66
+ self.loss_list: list[float] = []
67
+ self.loss_total: float = 0.0
68
+
69
+ def add(self, *, epoch: int, step: int, loss: float) -> None:
70
+ if epoch == 0:
71
+ self.loss_list.append(loss)
72
+ else:
73
+ while len(self.loss_list) <= step:
74
+ self.loss_list.append(0.0)
75
+ self.loss_total -= self.loss_list[step]
76
+ self.loss_list[step] = loss
77
+ self.loss_total += loss
78
+
79
+ @property
80
+ def moving_average(self) -> float:
81
+ return self.loss_total / len(self.loss_list)
82
+
83
+
84
+ def get_epoch_ckpt_name(model_name, epoch_no: int):
85
+ return EPOCH_FILE_NAME.format(model_name, epoch_no) + ".safetensors"
86
+
87
+
88
+ def get_step_ckpt_name(model_name, step_no: int):
89
+ return STEP_FILE_NAME.format(model_name, step_no) + ".safetensors"
90
+
91
+
92
+ def get_last_ckpt_name(model_name):
93
+ return model_name + ".safetensors"
94
+
95
+
96
+ def get_remove_epoch_no(args: argparse.Namespace, epoch_no: int):
97
+ if args.save_last_n_epochs is None:
98
+ return None
99
+
100
+ remove_epoch_no = epoch_no - args.save_every_n_epochs * args.save_last_n_epochs
101
+ if remove_epoch_no < 0:
102
+ return None
103
+ return remove_epoch_no
104
+
105
+
106
+ def get_remove_step_no(args: argparse.Namespace, step_no: int):
107
+ if args.save_last_n_steps is None:
108
+ return None
109
+
110
+ # calculate the step number to remove from the last_n_steps and save_every_n_steps
111
+ # e.g. if save_every_n_steps=10, save_last_n_steps=30, at step 50, keep 30 steps and remove step 10
112
+ remove_step_no = step_no - args.save_last_n_steps - 1
113
+ remove_step_no = remove_step_no - (remove_step_no % args.save_every_n_steps)
114
+ if remove_step_no < 0:
115
+ return None
116
+ return remove_step_no
117
+
118
+
119
+ def save_and_remove_state_on_epoch_end(args: argparse.Namespace, accelerator: accelerate.Accelerator, epoch_no: int):
120
+ model_name = args.output_name
121
+
122
+ logger.info("")
123
+ logger.info(f"saving state at epoch {epoch_no}")
124
+ os.makedirs(args.output_dir, exist_ok=True)
125
+
126
+ state_dir = os.path.join(args.output_dir, EPOCH_STATE_NAME.format(model_name, epoch_no))
127
+ accelerator.save_state(state_dir)
128
+ if args.save_state_to_huggingface:
129
+ logger.info("uploading state to huggingface.")
130
+ huggingface_utils.upload(args, state_dir, "/" + EPOCH_STATE_NAME.format(model_name, epoch_no))
131
+
132
+ last_n_epochs = args.save_last_n_epochs_state if args.save_last_n_epochs_state else args.save_last_n_epochs
133
+ if last_n_epochs is not None:
134
+ remove_epoch_no = epoch_no - args.save_every_n_epochs * last_n_epochs
135
+ state_dir_old = os.path.join(args.output_dir, EPOCH_STATE_NAME.format(model_name, remove_epoch_no))
136
+ if os.path.exists(state_dir_old):
137
+ logger.info(f"removing old state: {state_dir_old}")
138
+ shutil.rmtree(state_dir_old)
139
+
140
+
141
+ def save_and_remove_state_stepwise(args: argparse.Namespace, accelerator: accelerate.Accelerator, step_no: int):
142
+ model_name = args.output_name
143
+
144
+ logger.info("")
145
+ logger.info(f"saving state at step {step_no}")
146
+ os.makedirs(args.output_dir, exist_ok=True)
147
+
148
+ state_dir = os.path.join(args.output_dir, STEP_STATE_NAME.format(model_name, step_no))
149
+ accelerator.save_state(state_dir)
150
+ if args.save_state_to_huggingface:
151
+ logger.info("uploading state to huggingface.")
152
+ huggingface_utils.upload(args, state_dir, "/" + STEP_STATE_NAME.format(model_name, step_no))
153
+
154
+ last_n_steps = args.save_last_n_steps_state if args.save_last_n_steps_state else args.save_last_n_steps
155
+ if last_n_steps is not None:
156
+ # last_n_steps前のstep_noから、save_every_n_stepsの倍数のstep_noを計算して削除する
157
+ remove_step_no = step_no - last_n_steps - 1
158
+ remove_step_no = remove_step_no - (remove_step_no % args.save_every_n_steps)
159
+
160
+ if remove_step_no > 0:
161
+ state_dir_old = os.path.join(args.output_dir, STEP_STATE_NAME.format(model_name, remove_step_no))
162
+ if os.path.exists(state_dir_old):
163
+ logger.info(f"removing old state: {state_dir_old}")
164
+ shutil.rmtree(state_dir_old)
165
+
166
+
167
+ def save_state_on_train_end(args: argparse.Namespace, accelerator: accelerate.Accelerator):
168
+ model_name = args.output_name
169
+
170
+ logger.info("")
171
+ logger.info("saving last state.")
172
+ os.makedirs(args.output_dir, exist_ok=True)
173
+
174
+ state_dir = os.path.join(args.output_dir, LAST_STATE_NAME.format(model_name))
175
+ accelerator.save_state(state_dir)
176
+
177
+ if args.save_state_to_huggingface:
178
+ logger.info("uploading last state to huggingface.")
179
+ huggingface_utils.upload(args, state_dir, "/" + LAST_STATE_NAME.format(model_name))
180
+
181
+
182
+ def get_lin_function(x1: float = 256, y1: float = 0.5, x2: float = 4096, y2: float = 1.15) -> Callable[[float], float]:
183
+ m = (y2 - y1) / (x2 - x1)
184
+ b = y1 - m * x1
185
+ return lambda x: m * x + b
186
+
187
+
188
+ def resolve_save_dtype(save_precision: str | None, full_fp16: bool = False, full_bf16: bool = False) -> torch.dtype:
189
+ """Resolve the dtype for saving network weights.
190
+
191
+ Explicit --save_precision wins; otherwise follow full_fp16/full_bf16 so the
192
+ saved weights match the training precision; otherwise fp32, the precision
193
+ the network weights are actually trained in.
194
+ """
195
+ if save_precision is not None:
196
+ return str_to_dtype(save_precision)
197
+ if full_fp16:
198
+ return torch.float16
199
+ if full_bf16:
200
+ return torch.bfloat16
201
+ return torch.float32
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.9.1
2
+ torchvision==0.24.1
3
+ transformers==4.57.6
4
+ accelerate==1.6.0
5
+ huggingface-hub==0.34.3
6
+ safetensors
7
+ av
8
+ numpy
9
+ pillow
10
+ einops
rtcwm/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Real-time Code World Model: CARLA data generation and causal fine-tuning of the released CWM."""
2
+
3
+ __version__ = "0.1.0"
rtcwm/data/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Data generation: CARLA renders, coarse proxies, captions, QC. Runs in the `carla` environment
2
+ (rendering) or any environment with numpy / PIL / av (proxies, QC); captions need the `cwm` one."""
rtcwm/data/anchors.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Start frames (Picture 1) for streaming generation.
2
+
3
+ A stream starts from a start frame: Picture 1, its static caption and the proxy of that frame. Training
4
+ draws Picture 1 from many points of a clip -- the first frame of every ANCHOR_EVERY-th chunk -- either
5
+ right at the start of the sampled span (generation from that image, no history) or earlier in the clip.
6
+ Start frames inside a caption window flagged by QC are skipped. Per start chunk m:
7
+ CLIP/prompts/static_cMM.txt the caption (rtcwm.data.captions --static)
8
+ CACHE/CLIP/anchor_cMM.png the frame
9
+ CACHE/CLIP/anchor[n]_cMM.safetensors its Picture 1 latent (n: native 1344x768, else 2048 short edge)
10
+ CACHE/CLIP/static[n]_cMM.text.safetensors its Qwen states (rtcwm.train.preencode --stage static_text)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from cwm_h3_inference.constants import STRIDE_FRAMES, WINDOW_FRAMES
17
+
18
+ from .. import paths
19
+ from .clip import read_meta
20
+
21
+ ANCHOR_EVERY = 3 # chunks between start frames
22
+ CLIP_FRAMES = 17 # frames per chunk (one VAE clip)
23
+ STREAM_CHUNKS = 54 # chunks of a 934-frame render's stream
24
+ LAST_SPAN_START = STREAM_CHUNKS - 6
25
+
26
+
27
+ def anchor_frame(m: int) -> int:
28
+ return CLIP_FRAMES * m
29
+
30
+
31
+ def _flagged(clip: str):
32
+ from ..data.qc import lists
33
+ _, bad = lists.load()
34
+ return [(STRIDE_FRAMES * w, STRIDE_FRAMES * w + WINDOW_FRAMES) for w in bad.get(clip, ())]
35
+
36
+
37
+ def clip_anchors(clip: Path | str) -> list[int]:
38
+ """Start chunks of a clip: every ANCHOR_EVERY-th chunk up to the last span start, outside flagged windows."""
39
+ name = clip if isinstance(clip, str) else clip.name
40
+ flagged = _flagged(name)
41
+ return [m for m in range(0, LAST_SPAN_START + 1, ANCHOR_EVERY)
42
+ if not any(a <= anchor_frame(m) < b for a, b in flagged)]
43
+
44
+
45
+ def caption_path(clip: Path, m: int) -> Path:
46
+ return clip / "prompts" / f"static_c{m:02d}.txt"
47
+
48
+
49
+ # Picture 1 resolution. "native": the renders' 1344x768 frame as is (1008 H3 rows, ~2.6k Qwen rows; equal or
50
+ # better than the released 2048 short edge, which only upsamples our frames -- ops/dbg/res_test.py).
51
+ # "2048": the released CWM anchor size (7168 H3 rows, ~8.8k Qwen rows), used by stream_df_v1 / v2.
52
+ PICTURE1_RES = ("native", "2048")
53
+
54
+
55
+ def cache_paths(clip: str, m: int, res: str = "native") -> dict:
56
+ assert res in PICTURE1_RES, res
57
+ d = paths.CACHE / clip
58
+ tag = "n" if res == "native" else ""
59
+ return dict(png=d / f"anchor_c{m:02d}.png", latent=d / f"anchor{tag}_c{m:02d}.safetensors",
60
+ text=d / f"static{tag}_c{m:02d}.text.safetensors")
rtcwm/data/captions.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CWM-style captions with Qwen3-VL-32B: per window (the released CWM format) or one static caption per clip.
2
+
3
+ Each 124-frame window (stride 90) gets 8 evenly spaced frames, one released CWM prompt as the format
4
+ reference, and the ground-truth motion of the protagonist per 0.75 s slot (from the simulated
5
+ trajectory, so a stop or a turn-round hard to see in 8 frames is not missed). Windows after the first
6
+ get CWM's rolling-history preamble. Output: CLIP/prompts/NNN.txt; existing prompts are kept.
7
+
8
+ python -m rtcwm.data.captions CLIP_DIR [CLIP_DIR ...] (env MAX_WINDOWS limits windows per clip)
9
+ python -m rtcwm.data.captions --static CLIP_DIR [...] -> CLIP/prompts/static_cMM.txt
10
+
11
+ Static captions are for streaming generation: a stream starts from a start frame (Picture 1), and the
12
+ caption describes only that frame -- the protagonist and the scene visible in it -- plus one fixed sentence
13
+ on the walking and the trailing camera, so the text carries no timing and nothing from later frames;
14
+ motion comes from the proxy alone. Start frames: the first frame of every ANCHOR_EVERY-th chunk
15
+ (rtcwm.data.anchors), so training draws Picture 1 from many points of a clip.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import av
27
+ import numpy as np
28
+
29
+ from .. import paths
30
+ from .clip import FPS, STRIDE_FRAMES, WINDOW_FRAMES, n_windows, read_meta
31
+
32
+ REFERENCE_EXAMPLE = "example_30"
33
+ HISTORY = ("The first 1.4167 seconds are the immutable 34-frame rolling history and remain unchanged, continuing the "
34
+ "established camera motion, positions, scale, orientation, materials, lighting, and ongoing actions without a cut, restart, or reset.\n\n")
35
+ SLOTS = [(0.0, 0.75), (0.75, 1.5), (1.5, 2.25), (2.25, 3.0), (3.0, 3.75), (3.75, 4.5), (4.5, 5.17)]
36
+ N_FRAMES = 8
37
+
38
+ INSTRUCTION = """You write video captions for a world model. Below is a REFERENCE caption that shows the required format and level of detail.
39
+
40
+ REFERENCE:
41
+ <<<
42
+ {ref}
43
+ >>>
44
+
45
+ Now look at the {n} frames, which are evenly spaced over a 5.17-second clip (frame k is at {times}).
46
+ Write a caption for THIS clip in exactly the same structure as the reference:
47
+ 1. "Appearance and scene:" then one paragraph describing the protagonist (the person the camera follows, seen mostly from behind): build, hair, clothing, stable identity features, and where they are in frame at 0.00s.
48
+ 2. One paragraph describing the setting: ground surface, road, buildings, vegetation, vehicles, sky, lighting and weather, and the camera (a close trailing third-person camera behind the protagonist) and how it moves.
49
+ 3. "Protagonist action timeline:" then exactly seven segments "0.00–0.75s: ...; 0.75–1.50s: ...; 1.50–2.25s: ...; 2.25–3.00s: ...; 3.00–3.75s: ...; 3.75–4.50s: ...; 4.50–5.17s: ..." describing walking, turning left or right, slowing, crossing, etc., based on what changes between frames.
50
+ 4. "Other visible actors:" then a short paragraph about other cars or people that move, with time ranges, or state that none move.
51
+ Ground-truth motion of the protagonist in this clip (follow it exactly in the action timeline; the trailing camera always swings round to stay behind the protagonist, so after turning around the protagonist is again seen from behind):
52
+ {hints}
53
+ Describe only what is visible. Do not mention frames, images, renders, simulation, CARLA, or games. Output the caption only."""
54
+
55
+
56
+ def reference_prompt() -> str:
57
+ cfg = json.loads((paths.CWM_EXAMPLES / REFERENCE_EXAMPLE / "config.json").read_text())
58
+ return cfg["windows"][1]["prompt"].replace("\r\n", "\n")
59
+
60
+
61
+ def motion_hints(traj, w: int) -> str:
62
+ t = np.asarray(traj)[:, :2]
63
+ head = []
64
+ for i in range(len(t)):
65
+ d = t[min(len(t) - 1, i + 6)] - t[max(0, i - 6)]
66
+ head.append(math.degrees(math.atan2(d[1], d[0])) if np.hypot(*d) > 0.3 else None)
67
+ out = []
68
+ for s0, s1 in SLOTS:
69
+ f0, f1 = STRIDE_FRAMES * w + int(s0 * FPS), min(STRIDE_FRAMES * w + int(s1 * FPS), len(t) - 1)
70
+ dist = float(np.linalg.norm(t[f1] - t[f0]))
71
+ h0, h1 = head[f0], head[f1]
72
+ turn = None if h0 is None or h1 is None else abs((h1 - h0 + 180) % 360 - 180)
73
+ if dist < 0.25:
74
+ state = "stands still / pauses"
75
+ elif turn is not None and turn > 100:
76
+ state = "turns around and starts walking back the way they came"
77
+ elif turn is not None and turn > 35:
78
+ state = "walks while turning to follow the path"
79
+ else:
80
+ state = "walks steadily forward along the path"
81
+ out.append(f"{s0:.2f}-{s1:.2f}s: {state}")
82
+ return "; ".join(out)
83
+
84
+
85
+ def clean_caption(text: str) -> str:
86
+ """Strip prompt delimiters the model sometimes echoes; put the seven timeline segments on one
87
+ line joined by '; ' as in the released CWM captions."""
88
+ text = text.replace("<<<", "").replace(">>>", "").strip()
89
+ if "Protagonist action timeline:" in text:
90
+ head, rest = text.split("Protagonist action timeline:", 1)
91
+ tl, sep, tail = rest.partition("Other visible actors:")
92
+ segs = [re.sub(r"\s+", " ", x).strip().rstrip(";") for x in re.split(r"\n\s*\n|;\s*(?=\d+\.\d+[–-])", tl) if x.strip()]
93
+ text = head.rstrip() + "\n\nProtagonist action timeline:\n\n" + "; ".join(segs)
94
+ if sep:
95
+ text += "\n\nOther visible actors:\n\n" + re.sub(r"\s+", " ", tail).strip()
96
+ return text
97
+
98
+
99
+ STATIC_INSTRUCTION = """You write the text prompt for a world model that continues a video from this single starting image.
100
+ Write exactly two paragraphs, starting with the line "Appearance and scene:":
101
+ 1. The protagonist (the person in the foreground, seen from behind): build, hair, skin tone, clothing, shoes and stable
102
+ identity features, and where they are in the frame.
103
+ 2. The setting visible in this image: ground surface, sidewalk, road, buildings, walls, vegetation, vehicles, other
104
+ people, sky, lighting and weather, and the camera (a close trailing third-person camera behind the protagonist).
105
+ Describe only what is visible in this image. Do not describe actions, events or anything that happens later. Do not
106
+ mention images, renders, simulation, CARLA, or games. Output the two paragraphs only."""
107
+ STATIC_MOTION = ("The protagonist walks along the sidewalk, and the close trailing third-person camera follows behind "
108
+ "them, swinging round to stay behind the protagonist whenever they turn.")
109
+
110
+
111
+ class Captioner:
112
+ def __init__(self):
113
+ import torch
114
+ from transformers import AutoModelForImageTextToText, AutoProcessor
115
+ self.torch = torch
116
+ self.proc = AutoProcessor.from_pretrained(str(paths.qwen_vl()))
117
+ self.model = AutoModelForImageTextToText.from_pretrained(str(paths.qwen_vl()), torch_dtype=torch.bfloat16,
118
+ device_map="auto").eval()
119
+ self.ref = reference_prompt()
120
+
121
+ def caption(self, images, times: str, hints: str) -> str:
122
+ content = [{"type": "image", "image": im} for im in images]
123
+ content.append({"type": "text", "text": INSTRUCTION.format(ref=self.ref, n=len(images), times=times, hints=hints)})
124
+ inputs = self.proc.apply_chat_template([{"role": "user", "content": content}], tokenize=True,
125
+ add_generation_prompt=True, return_dict=True,
126
+ return_tensors="pt").to(self.model.device)
127
+ with self.torch.inference_mode():
128
+ out = self.model.generate(**inputs, max_new_tokens=1200, do_sample=False)
129
+ return self.proc.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0].strip()
130
+
131
+ def caption_static(self, clip: Path):
132
+ from .anchors import anchor_frame, caption_path, clip_anchors
133
+ todo = [m for m in clip_anchors(clip) if not caption_path(clip, m).exists()]
134
+ if not todo:
135
+ return
136
+ with av.open(str(clip / "rgb.mp4")) as c:
137
+ frames = {}
138
+ for i, f in enumerate(c.decode(video=0)):
139
+ if i in {anchor_frame(m) for m in todo}:
140
+ frames[i] = f.to_image()
141
+ for m in todo:
142
+ self._caption_frame(frames[anchor_frame(m)], caption_path(clip, m))
143
+ print(clip.name, "static", m, flush=True)
144
+
145
+ def _caption_frame(self, first, outp: Path):
146
+ content = [{"type": "image", "image": first}, {"type": "text", "text": STATIC_INSTRUCTION}]
147
+ inputs = self.proc.apply_chat_template([{"role": "user", "content": content}], tokenize=True,
148
+ add_generation_prompt=True, return_dict=True,
149
+ return_tensors="pt").to(self.model.device)
150
+ with self.torch.inference_mode():
151
+ out = self.model.generate(**inputs, max_new_tokens=700, do_sample=False)
152
+ text = self.proc.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0].strip()
153
+ text = text.replace("<<<", "").replace(">>>", "").strip()
154
+ outp.parent.mkdir(exist_ok=True)
155
+ outp.write_text(text + "\n\n" + STATIC_MOTION)
156
+
157
+ def caption_clip(self, clip: Path):
158
+ meta = read_meta(clip)
159
+ windows = min(n_windows(meta["frames"]), int(os.environ.get("MAX_WINDOWS", 99)))
160
+ (clip / "prompts").mkdir(exist_ok=True)
161
+ with av.open(str(clip / "rgb.mp4")) as c:
162
+ video = [f.to_image() for f in c.decode(video=0)]
163
+ for w in range(windows):
164
+ outp = clip / "prompts" / f"{w:03d}.txt"
165
+ if outp.exists():
166
+ continue
167
+ idx = [STRIDE_FRAMES * w + round(k * (WINDOW_FRAMES - 1) / (N_FRAMES - 1)) for k in range(N_FRAMES)]
168
+ times = ", ".join(f"{k}: {(i - STRIDE_FRAMES * w) / FPS:.2f}s" for k, i in enumerate(idx))
169
+ text = clean_caption(self.caption([video[i].resize((672, 384)) for i in idx], times,
170
+ motion_hints(meta["hero_traj"], w)))
171
+ outp.write_text((HISTORY + text) if w > 0 else text)
172
+ print(clip.name, w, len(text.split()), "words", flush=True)
173
+
174
+
175
+ def main():
176
+ args = sys.argv[1:]
177
+ static = "--static" in args
178
+ cap = Captioner()
179
+ for clip in map(Path, [a for a in args if a != "--static"]):
180
+ cap.caption_static(clip) if static else cap.caption_clip(clip)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
rtcwm/data/clip.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reading a rendered clip directory.
2
+
3
+ rgb.mp4 1344x768, 24 fps
4
+ conditions/ fine proxy (CARLA depth + segmentation), NNNNNN.{depth.f32,semantic_id.png}
5
+ conditions_coarse/ coarse proxy (rtcwm.data.proxy)
6
+ cameras.json per-frame UE camera pose + RGB intrinsics
7
+ scene_static.json level boxes, lane waypoints, parked vehicles (input of the coarse proxy)
8
+ scene_frames.jsonl per-frame protagonist bones and background walkers
9
+ meta.json map, seed, route, camera parameters, trajectory, QC
10
+ prompts/NNN.txt one caption per 124-frame window
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ from PIL import Image
20
+
21
+ from .geometry import PROXY_H, PROXY_W
22
+
23
+ FPS = 24
24
+ WINDOW_FRAMES, STRIDE_FRAMES = 124, 90
25
+
26
+
27
+ def n_windows(frames: int) -> int:
28
+ return (frames - WINDOW_FRAMES) // STRIDE_FRAMES + 1
29
+
30
+
31
+ def window_frames(w: int) -> slice:
32
+ return slice(STRIDE_FRAMES * w, STRIDE_FRAMES * w + WINDOW_FRAMES)
33
+
34
+
35
+ def read_meta(clip: Path) -> dict:
36
+ return json.loads((Path(clip) / "meta.json").read_text())
37
+
38
+
39
+ def read_cameras(clip: Path) -> list:
40
+ return json.loads((Path(clip) / "cameras.json").read_text())["frames"]
41
+
42
+
43
+ def read_scene_frames(clip: Path) -> list:
44
+ with open(Path(clip) / "scene_frames.jsonl") as fh:
45
+ return [json.loads(line) for line in fh]
46
+
47
+
48
+ def read_scene_static(clip: Path) -> dict:
49
+ return json.loads((Path(clip) / "scene_static.json").read_text())
50
+
51
+
52
+ def read_proxy(clip: Path, i: int, kind: str = "conditions"):
53
+ """(depth [192,336] float32, semantic [192,336] uint8) of frame i."""
54
+ d = Path(clip) / kind
55
+ depth = np.fromfile(d / f"{i:06d}.depth.f32", dtype="<f4").reshape(PROXY_H, PROXY_W)
56
+ sem = np.array(Image.open(d / f"{i:06d}.semantic_id.png"))
57
+ return depth, sem
58
+
59
+
60
+ def qc_pass(clip: Path) -> bool:
61
+ try:
62
+ return bool(read_meta(clip).get("qc", {}).get("pass"))
63
+ except (OSError, ValueError):
64
+ return False
65
+
66
+
67
+ def has_coarse_proxy(clip: Path) -> bool:
68
+ d = Path(clip) / "conditions_coarse"
69
+ try:
70
+ return d.is_dir() and len(os.listdir(d)) >= 2 * read_meta(clip)["frames"]
71
+ except (OSError, ValueError, KeyError):
72
+ return False
73
+
74
+
75
+ def has_prompts(clip: Path) -> bool:
76
+ try:
77
+ nw = n_windows(read_meta(clip)["frames"])
78
+ except (OSError, ValueError, KeyError):
79
+ return False
80
+ return all((Path(clip) / "prompts" / f"{w:03d}.txt").exists() for w in range(nw))
81
+
82
+
83
+ def complete(clip: Path) -> bool:
84
+ """Rendered, passed QC and has its coarse proxy."""
85
+ return qc_pass(clip) and has_coarse_proxy(clip)
rtcwm/data/geometry.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conventions shared by the renderer, the coarse proxy and the QC scans.
2
+
3
+ CARLA / UE world: x forward, y right, z up (left-handed), metres; rotations are degrees.
4
+ open-code renderer world: x right, y down, z forward.
5
+ CWM proxy frames: 336x192, focal 206.6 px, semantic ids {0,1,2,4,5,8,9,10}.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+
13
+ PROXY_W, PROXY_H = 336, 192
14
+ PROXY_F = 206.6
15
+ PROXY_CX, PROXY_CY = PROXY_W / 2, PROXY_H / 2
16
+ HFOV_DEG = math.degrees(2 * math.atan(PROXY_CX / PROXY_F))
17
+
18
+ # CWM semantic ids
19
+ CLS_OTHER, CLS_SKY, CLS_WATER, CLS_ROAD, CLS_VEGETATION, CLS_HERO, CLS_VEHICLE = 0, 1, 2, 4, 5, 8, 10
20
+
21
+ FEET_BONES = ("crl_foot__L", "crl_foot__R", "crl_toe__L", "crl_toe__R")
22
+
23
+
24
+ def carla_to_renderer(p) -> np.ndarray:
25
+ return np.array([p[1], -p[2], p[0]], float)
26
+
27
+
28
+ def renderer_to_carla(q) -> list:
29
+ return [float(q[2]), float(q[0]), float(-q[1])]
30
+
31
+
32
+ def renderer_axes(yaw_deg: float) -> np.ndarray:
33
+ """Renderer-space unit axes (rows: forward, right, up) of a CARLA yaw."""
34
+ y = math.radians(yaw_deg)
35
+ return np.stack([carla_to_renderer([math.cos(y), math.sin(y), 0.0]),
36
+ carla_to_renderer([-math.sin(y), math.cos(y), 0.0]),
37
+ carla_to_renderer([0.0, 0.0, 1.0])])
38
+
39
+
40
+ def camera_basis(pitch_deg: float, yaw_deg: float):
41
+ """UE camera axes (forward, right, up) in world coordinates."""
42
+ p, y = math.radians(pitch_deg), math.radians(yaw_deg)
43
+ fwd = np.array([math.cos(p) * math.cos(y), math.cos(p) * math.sin(y), math.sin(p)])
44
+ right = np.array([-math.sin(y), math.cos(y), 0.0])
45
+ up = np.cross(fwd, right) # UE is left-handed: this is +up
46
+ return fwd, right, up
47
+
48
+
49
+ def project(points, cam_loc, cam_rot):
50
+ """World points [N,3] -> proxy pixel (u, v) and planar depth z for a UE camera pose."""
51
+ fwd, right, up = camera_basis(*cam_rot[:2])
52
+ q = np.atleast_2d(np.asarray(points, float)) - np.asarray(cam_loc, float)
53
+ z = q @ fwd
54
+ with np.errstate(divide="ignore", invalid="ignore"):
55
+ u = PROXY_CX + PROXY_F * (q @ right) / z
56
+ v = PROXY_CY - PROXY_F * (q @ up) / z
57
+ return u, v, z
58
+
59
+
60
+ def box_local(xy, centre, yaw_deg: float):
61
+ """2D offsets of points from a box centre, in the box's own (u, v) axes."""
62
+ yaw = math.radians(yaw_deg)
63
+ d = np.atleast_2d(np.asarray(xy, float))[:, :2] - np.asarray(centre[:2], float)
64
+ return d[:, 0] * math.cos(yaw) + d[:, 1] * math.sin(yaw), -d[:, 0] * math.sin(yaw) + d[:, 1] * math.cos(yaw)
65
+
66
+
67
+ def longest_run(flags) -> int:
68
+ best = run = 0
69
+ for f in flags:
70
+ run = run + 1 if f else 0
71
+ best = max(best, run)
72
+ return best