Spaces:
Running on Zero
Running on Zero
prep stage
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +8 -8
- app.py +124 -0
- cwm_h3_inference/__init__.py +6 -0
- cwm_h3_inference/__main__.py +3 -0
- cwm_h3_inference/cache.py +321 -0
- cwm_h3_inference/cli.py +53 -0
- cwm_h3_inference/config.py +174 -0
- cwm_h3_inference/constants.py +43 -0
- cwm_h3_inference/duv.py +72 -0
- cwm_h3_inference/engine.py +340 -0
- cwm_h3_inference/io.py +44 -0
- cwm_h3_inference/presentation.py +63 -0
- cwm_h3_inference/prompts/system_w0.txt +51 -0
- cwm_h3_inference/prompts/system_wn.txt +57 -0
- cwm_h3_inference/runtime.py +78 -0
- materialize_hf_examples.py +369 -0
- musubi_tuner/__init__.py +0 -0
- musubi_tuner/minimax_h3/__init__.py +1 -0
- musubi_tuner/minimax_h3/audio_vae.py +467 -0
- musubi_tuner/minimax_h3/checkpoint.py +154 -0
- musubi_tuner/minimax_h3/media.py +299 -0
- musubi_tuner/minimax_h3/model.py +952 -0
- musubi_tuner/minimax_h3/packing.py +591 -0
- musubi_tuner/minimax_h3/sampling.py +396 -0
- musubi_tuner/minimax_h3/text_encoder.py +384 -0
- musubi_tuner/minimax_h3/video_vae.py +686 -0
- musubi_tuner/modules/__init__.py +0 -0
- musubi_tuner/modules/adafactor_fused.py +141 -0
- musubi_tuner/modules/attention.py +297 -0
- musubi_tuner/modules/custom_offloading_utils.py +1096 -0
- musubi_tuner/modules/fp8_optimization_utils.py +564 -0
- musubi_tuner/modules/lr_schedulers.py +67 -0
- musubi_tuner/modules/scheduling_flow_match_discrete.py +257 -0
- musubi_tuner/modules/unet_causal_3d_blocks.py +818 -0
- musubi_tuner/utils/__init__.py +0 -0
- musubi_tuner/utils/device_utils.py +28 -0
- musubi_tuner/utils/huggingface_utils.py +89 -0
- musubi_tuner/utils/image_utils.py +40 -0
- musubi_tuner/utils/lora_utils.py +300 -0
- musubi_tuner/utils/model_utils.py +295 -0
- musubi_tuner/utils/safetensors_utils.py +483 -0
- musubi_tuner/utils/sai_model_spec.py +403 -0
- musubi_tuner/utils/train_utils.py +201 -0
- requirements.txt +10 -0
- rtcwm/__init__.py +3 -0
- rtcwm/data/__init__.py +2 -0
- rtcwm/data/anchors.py +60 -0
- rtcwm/data/captions.py +184 -0
- rtcwm/data/clip.py +85 -0
- rtcwm/data/geometry.py +72 -0
README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
---
|
| 2 |
title: Streaming Code World Model
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 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
|