Spaces:
Running on Zero
Running on Zero
Commit ·
3a016a8
1
Parent(s): c4170c1
Price bookings against the AoTI blocks, generate from 2 s again, cross-link the demos
Browse filesget_duration is refit on the measured AoTI per-step table, AoTI is opt-in behind H3_AOTI with a card and torch check before it loads, and the duration slider starts at 2 s again by lowering the pipeline's own floor.
- __pycache__/app.cpython-310.pyc +0 -0
- __pycache__/h3_aoti.cpython-310.pyc +0 -0
- __pycache__/h3_split_blocks.cpython-310.pyc +0 -0
- __pycache__/spaces_constant_binding_patch.cpython-310.pyc +0 -0
- app.py +66 -84
- h3_aoti.py +72 -197
- h3_split_blocks.py +16 -46
- requirements.txt +4 -7
- spaces_constant_binding_patch.py +13 -38
__pycache__/app.cpython-310.pyc
ADDED
|
Binary file (14.3 kB). View file
|
|
|
__pycache__/h3_aoti.cpython-310.pyc
ADDED
|
Binary file (11.1 kB). View file
|
|
|
__pycache__/h3_split_blocks.cpython-310.pyc
ADDED
|
Binary file (6.82 kB). View file
|
|
|
__pycache__/spaces_constant_binding_patch.cpython-310.pyc
ADDED
|
Binary file (8.67 kB). View file
|
|
|
app.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
"""MiniMax-H3, split deployment — denoising
|
| 2 |
-
"""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
@@ -9,23 +8,22 @@ import time
|
|
| 9 |
import traceback
|
| 10 |
from functools import cache
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
import spaces
|
| 15 |
import gradio as gr
|
| 16 |
|
| 17 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 18 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 19 |
-
# `
|
| 20 |
-
# `ComponentsManager.enable_auto_cpu_offload`
|
| 21 |
-
# deliberate — see `load_models`: the 150 GB storage quota, not the 95 GiB card, is what rules that out here.
|
| 22 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
|
| 23 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 24 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
| 25 |
-
GPU_DURATION = int(os.environ.get("H3_GPU_DURATION", "900"))
|
| 26 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
| 27 |
-
ON_SPACES = bool(os.environ.get("SPACE_ID"))
|
| 28 |
|
|
|
|
|
|
|
| 29 |
CANVASES = {
|
| 30 |
# 16:9
|
| 31 |
"960x544 · 16:9 fast": (544, 960),
|
|
@@ -51,9 +49,9 @@ CANVASES = {
|
|
| 51 |
}
|
| 52 |
DEFAULT_CANVAS = "960x544 · 16:9 fast"
|
| 53 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 54 |
-
#
|
| 55 |
-
#
|
| 56 |
-
MIN_UI_DURATION, MAX_UI_DURATION =
|
| 57 |
|
| 58 |
|
| 59 |
def snap_frames(seconds: float) -> int:
|
|
@@ -64,6 +62,13 @@ def snap_frames(seconds: float) -> int:
|
|
| 64 |
return frames
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
PIPE = None
|
| 68 |
MANAGER = None
|
| 69 |
LOAD_ERROR: str | None = None
|
|
@@ -84,24 +89,12 @@ def status() -> str:
|
|
| 84 |
|
| 85 |
|
| 86 |
def load_models() -> str | None:
|
| 87 |
-
"""Load the denoising half
|
| 88 |
-
|
| 89 |
-
`MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`,
|
| 90 |
-
`
|
| 91 |
-
`
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
Both autoencoders carry `_keep_in_fp32_modules` over every module, so the `dtype` below is refused for them and
|
| 95 |
-
they stay float32: a bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
|
| 96 |
-
|
| 97 |
-
Nothing is moved onto the card here, which is the one place this Space departs from the ZeroGPU idiom, and the
|
| 98 |
-
reason is storage rather than memory. `spaces`' startup `torch.pack()` writes every startup-resident CUDA tensor
|
| 99 |
-
to a **second copy on disk** and only deletes the downloaded originals afterwards; 77.3 GB of weights plus a
|
| 100 |
-
77.3 GB pack is 154.6 GB against a 150 GB quota, and the Space is evicted mid-pack with `OSError: [Errno 28] No
|
| 101 |
-
space left on device` out of `os.posix_fallocate`. Deleting the shards first does not help either: the pack's own
|
| 102 |
-
cleanup walks the still-open mappings and `lstat`s them, so an unlinked blob turns into `FileNotFoundError:
|
| 103 |
-
... (deleted)`. Placement therefore happens on the first GPU call, where it costs about 10 s of PCIe and then
|
| 104 |
-
persists across every later request in the same worker.
|
| 105 |
"""
|
| 106 |
global PIPE, MANAGER, LOAD_ERROR, LOADED_IN
|
| 107 |
|
|
@@ -115,6 +108,7 @@ def load_models() -> str | None:
|
|
| 115 |
|
| 116 |
from h3_split_blocks import MiniMaxH3GeneratorBlocks
|
| 117 |
|
|
|
|
| 118 |
manager = ComponentsManager()
|
| 119 |
blocks = MiniMaxH3GeneratorBlocks()
|
| 120 |
print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
|
@@ -122,19 +116,16 @@ def load_models() -> str | None:
|
|
| 122 |
pipe.load_components(dtype=torch.bfloat16)
|
| 123 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 124 |
|
| 125 |
-
# Still startup, still free: an AoTI package carries no weights and opens its
|
| 126 |
-
#
|
| 127 |
import h3_aoti
|
| 128 |
|
| 129 |
h3_aoti.maybe_load(pipe.transformer)
|
| 130 |
|
| 131 |
if PLACEMENT == "pack":
|
| 132 |
-
#
|
| 133 |
-
#
|
| 134 |
-
# VAEs
|
| 135 |
-
# packs to ~123 GB total and fits. The VAEs (~10 GB) take the lazy path on first GPU call, ~2 s.
|
| 136 |
-
# With AoTI the packed transformer pairs with the precompiled blocks: no placement, no compile,
|
| 137 |
-
# first request runs at steady state.
|
| 138 |
pipe.transformer.to("cuda")
|
| 139 |
|
| 140 |
if PLACEMENT == "offload":
|
|
@@ -153,9 +144,8 @@ def load_models() -> str | None:
|
|
| 153 |
def _arm_decode_hooks(pipe):
|
| 154 |
"""Make the offload hooks fire for the two VAEs.
|
| 155 |
|
| 156 |
-
`enable_auto_cpu_offload`
|
| 157 |
-
|
| 158 |
-
is still on the host when the latents arrive on the card.
|
| 159 |
"""
|
| 160 |
for name in ("vae", "audio_vae"):
|
| 161 |
module = getattr(pipe, name)
|
|
@@ -172,20 +162,16 @@ def _arm_decode_hooks(pipe):
|
|
| 172 |
|
| 173 |
@cache
|
| 174 |
def conditioner():
|
| 175 |
-
"""The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call,
|
| 176 |
-
|
| 177 |
from gradio_client import Client
|
| 178 |
|
| 179 |
return Client(CONDITIONER_SPACE)
|
| 180 |
|
| 181 |
|
| 182 |
def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
|
| 183 |
-
"""
|
| 184 |
-
|
| 185 |
-
`rewrite_prompt` asks the conditioner to rewrite the request into MiniMax-H3's trained format with its own
|
| 186 |
-
Qwen3-VL and encode that, handing the rewrite back under the plan's `refined_prompt`. It runs on the conditioner's
|
| 187 |
-
booking, and this call happens before `_generate` books a card here, so `get_duration` is unaffected.
|
| 188 |
-
"""
|
| 189 |
from gradio_client import handle_file
|
| 190 |
from safetensors import safe_open
|
| 191 |
|
|
@@ -203,37 +189,37 @@ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewri
|
|
| 203 |
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
|
| 204 |
|
| 205 |
|
| 206 |
-
#
|
| 207 |
-
#
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
| 211 |
|
| 212 |
|
| 213 |
def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, *a, **k):
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
return max(60, int(
|
| 221 |
|
| 222 |
|
| 223 |
@spaces.GPU(duration=get_duration, size=GPU_SIZE)
|
| 224 |
def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
|
| 225 |
"""The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
|
| 226 |
|
| 227 |
-
Only the three generated outputs come back
|
| 228 |
the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
|
| 229 |
"""
|
| 230 |
import torch
|
| 231 |
|
| 232 |
if PLACEMENT == "lazy":
|
| 233 |
-
# 72.16 GiB across PCIe on the first request of a worker, a no-op walk on every one after it.
|
| 234 |
PIPE.to("cuda")
|
| 235 |
elif PLACEMENT == "pack":
|
| 236 |
-
# Transformer was packed at startup; only the ~10 GB of fp32 VAEs walk across on a cold worker.
|
| 237 |
PIPE.vae.to("cuda")
|
| 238 |
PIPE.audio_vae.to("cuda")
|
| 239 |
|
|
@@ -252,7 +238,7 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
|
|
| 252 |
|
| 253 |
|
| 254 |
def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
|
| 255 |
-
"""One request. `upsample` is last and defaults off, so a positional API client
|
| 256 |
if LOAD_ERROR:
|
| 257 |
raise gr.Error(LOAD_ERROR)
|
| 258 |
if PIPE is None:
|
|
@@ -275,9 +261,9 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
|
|
| 275 |
height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
|
| 276 |
refined = plan.get("refined_prompt") or ""
|
| 277 |
|
| 278 |
-
# EXIF-transposed and in RGB, the same way the conditioner prepares it: the conditioning latents encoded here have
|
| 279 |
-
# to be of the image the conditioner looked at.
|
| 280 |
def keyframe(path):
|
|
|
|
|
|
|
| 281 |
return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
|
| 282 |
|
| 283 |
progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
|
|
@@ -310,10 +296,9 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
|
|
| 310 |
return path, report, refined, gr.update(visible=bool(refined))
|
| 311 |
|
| 312 |
|
| 313 |
-
|
| 314 |
def _fit_keyframe(image_path, current_canvas):
|
| 315 |
-
"""Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's
|
| 316 |
-
|
| 317 |
if not image_path:
|
| 318 |
return gr.update(), gr.update()
|
| 319 |
from PIL import Image as _Image
|
|
@@ -336,16 +321,15 @@ def _fit_keyframe(image_path, current_canvas):
|
|
| 336 |
target = w / h
|
| 337 |
if abs(img.width / img.height - target) <= 1e-3:
|
| 338 |
return gr.update(), gr.update(value=label)
|
| 339 |
-
if
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
img.save(image_path)
|
| 349 |
return gr.update(value=image_path), gr.update(value=label)
|
| 350 |
|
| 351 |
|
|
@@ -355,8 +339,8 @@ INTRO = """# MiniMax-H3
|
|
| 355 |
|
| 356 |
<div align="center">
|
| 357 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a>
|
| 358 |
-
<a href="
|
| 359 |
-
<a href="https://
|
| 360 |
</div>
|
| 361 |
|
| 362 |
**MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
|
|
@@ -413,8 +397,6 @@ with gr.Blocks(title="MiniMax-H3") as demo:
|
|
| 413 |
cache_mode="lazy",
|
| 414 |
)
|
| 415 |
|
| 416 |
-
# `upsample` is last and defaults off, so a positional API client that predates it is unaffected; so is the
|
| 417 |
-
# output order, with the upsampled prompt appended after the video and the report.
|
| 418 |
run.click(
|
| 419 |
generate,
|
| 420 |
[prompt, image, last_image, canvas, duration, steps, seed, upsample],
|
|
|
|
| 1 |
+
"""MiniMax-H3 `t2va` / `fl2va`, split deployment — the denoising half."""
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 8 |
import traceback
|
| 9 |
from functools import cache
|
| 10 |
|
| 11 |
+
# Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
|
| 12 |
+
# startup rather than on GPU time.
|
| 13 |
import spaces
|
| 14 |
import gradio as gr
|
| 15 |
|
| 16 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 17 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 18 |
+
# `pack` places the transformer at startup, `lazy` moves everything on the first GPU call, `offload` hands placement to
|
| 19 |
+
# `ComponentsManager.enable_auto_cpu_offload`.
|
|
|
|
| 20 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
|
| 21 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 22 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
|
|
|
| 23 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
|
|
|
| 24 |
|
| 25 |
+
# Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
|
| 26 |
+
# is rejected there and surfaces as a failure here.
|
| 27 |
CANVASES = {
|
| 28 |
# 16:9
|
| 29 |
"960x544 · 16:9 fast": (544, 960),
|
|
|
|
| 49 |
}
|
| 50 |
DEFAULT_CANVAS = "960x544 · 16:9 fast"
|
| 51 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 52 |
+
# It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
|
| 53 |
+
# 15.083 s, and is refused.
|
| 54 |
+
MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
|
| 55 |
|
| 56 |
|
| 57 |
def snap_frames(seconds: float) -> int:
|
|
|
|
| 62 |
return frames
|
| 63 |
|
| 64 |
|
| 65 |
+
def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
|
| 66 |
+
"""Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
|
| 67 |
+
from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
|
| 68 |
+
|
| 69 |
+
MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
PIPE = None
|
| 73 |
MANAGER = None
|
| 74 |
LOAD_ERROR: str | None = None
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def load_models() -> str | None:
|
| 92 |
+
"""Load the denoising half at startup.
|
| 93 |
+
|
| 94 |
+
`MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, the two schedulers and `video_processor`,
|
| 95 |
+
so `load_components` fetches exactly those subfolders — `text_encoder/` and `transformer_ref/` are never touched.
|
| 96 |
+
Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio VAE decodes
|
| 97 |
+
the soundtrack roughly 20 dB too quiet.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
"""
|
| 99 |
global PIPE, MANAGER, LOAD_ERROR, LOADED_IN
|
| 100 |
|
|
|
|
| 108 |
|
| 109 |
from h3_split_blocks import MiniMaxH3GeneratorBlocks
|
| 110 |
|
| 111 |
+
lower_duration_floor()
|
| 112 |
manager = ComponentsManager()
|
| 113 |
blocks = MiniMaxH3GeneratorBlocks()
|
| 114 |
print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
|
|
|
| 116 |
pipe.load_components(dtype=torch.bfloat16)
|
| 117 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 118 |
|
| 119 |
+
# Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
|
| 120 |
+
# worker. Off unless `H3_AOTI=1`.
|
| 121 |
import h3_aoti
|
| 122 |
|
| 123 |
h3_aoti.maybe_load(pipe.transformer)
|
| 124 |
|
| 125 |
if PLACEMENT == "pack":
|
| 126 |
+
# Scoped to the transformer. `spaces` packs every startup-resident CUDA tensor into a second on-disk copy,
|
| 127 |
+
# and packing all 77.3 GB busts the 150 GB storage quota; the 61.7 GB transformer alone fits. The ~10 GB of
|
| 128 |
+
# fp32 VAEs move on the first GPU call instead.
|
|
|
|
|
|
|
|
|
|
| 129 |
pipe.transformer.to("cuda")
|
| 130 |
|
| 131 |
if PLACEMENT == "offload":
|
|
|
|
| 144 |
def _arm_decode_hooks(pipe):
|
| 145 |
"""Make the offload hooks fire for the two VAEs.
|
| 146 |
|
| 147 |
+
`enable_auto_cpu_offload` wraps `forward`, and the decode blocks call `vae.decode(...)` directly, so the hook
|
| 148 |
+
never runs and the VAE is still on the host when the latents arrive on the card.
|
|
|
|
| 149 |
"""
|
| 150 |
for name in ("vae", "audio_vae"):
|
| 151 |
module = getattr(pipe, name)
|
|
|
|
| 162 |
|
| 163 |
@cache
|
| 164 |
def conditioner():
|
| 165 |
+
"""The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
|
| 166 |
+
conditioner's booking is billed to whoever asked for the video."""
|
| 167 |
from gradio_client import Client
|
| 168 |
|
| 169 |
return Client(CONDITIONER_SPACE)
|
| 170 |
|
| 171 |
|
| 172 |
def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
|
| 173 |
+
"""`/encode` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with the
|
| 174 |
+
resolved `height` / `width` / `num_frames` in its metadata, plus the plan. `canvas` is the label."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
from gradio_client import handle_file
|
| 176 |
from safetensors import safe_open
|
| 177 |
|
|
|
|
| 189 |
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
|
| 190 |
|
| 191 |
|
| 192 |
+
# Seconds of GPU one request needs, from the packed video rows it is about to denoise: linear in the rows for the
|
| 193 |
+
# matmuls, quadratic for the attention, against the AoTI block package this Space runs.
|
| 194 |
+
_DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
|
| 195 |
+
# The two resident decoders and the mux, which scale with the output rather than with the step count.
|
| 196 |
+
_DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
|
| 197 |
+
# `pack` mode: only the ~10 GB of VAEs move on a cold worker.
|
| 198 |
+
_PLACEMENT_ALLOWANCE, _PAD = 12, 10
|
| 199 |
|
| 200 |
|
| 201 |
def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, *a, **k):
|
| 202 |
+
height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
|
| 203 |
+
latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
|
| 204 |
+
patches = (height // 32) * (width // 32)
|
| 205 |
+
rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
|
| 206 |
+
denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
|
| 207 |
+
decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
|
| 208 |
+
return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
|
| 209 |
|
| 210 |
|
| 211 |
@spaces.GPU(duration=get_duration, size=GPU_SIZE)
|
| 212 |
def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
|
| 213 |
"""The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
|
| 214 |
|
| 215 |
+
Only the three generated outputs come back — a `@spaces.GPU` return crosses a process boundary by pickling, and
|
| 216 |
the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
|
| 217 |
"""
|
| 218 |
import torch
|
| 219 |
|
| 220 |
if PLACEMENT == "lazy":
|
|
|
|
| 221 |
PIPE.to("cuda")
|
| 222 |
elif PLACEMENT == "pack":
|
|
|
|
| 223 |
PIPE.vae.to("cuda")
|
| 224 |
PIPE.audio_vae.to("cuda")
|
| 225 |
|
|
|
|
| 238 |
|
| 239 |
|
| 240 |
def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
|
| 241 |
+
"""One request. `upsample` is last and defaults off, so a positional API client that predates it is unaffected."""
|
| 242 |
if LOAD_ERROR:
|
| 243 |
raise gr.Error(LOAD_ERROR)
|
| 244 |
if PIPE is None:
|
|
|
|
| 261 |
height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
|
| 262 |
refined = plan.get("refined_prompt") or ""
|
| 263 |
|
|
|
|
|
|
|
| 264 |
def keyframe(path):
|
| 265 |
+
# The conditioning latents encoded here have to be of the image the conditioner looked at, which it prepares
|
| 266 |
+
# exactly this way.
|
| 267 |
return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
|
| 268 |
|
| 269 |
progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
|
|
|
|
| 296 |
return path, report, refined, gr.update(visible=bool(refined))
|
| 297 |
|
| 298 |
|
|
|
|
| 299 |
def _fit_keyframe(image_path, current_canvas):
|
| 300 |
+
"""Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's smallest
|
| 301 |
+
(fastest) canvas, unless the user already picked a matching ratio."""
|
| 302 |
if not image_path:
|
| 303 |
return gr.update(), gr.update()
|
| 304 |
from PIL import Image as _Image
|
|
|
|
| 321 |
target = w / h
|
| 322 |
if abs(img.width / img.height - target) <= 1e-3:
|
| 323 |
return gr.update(), gr.update(value=label)
|
| 324 |
+
if img.width / img.height > target:
|
| 325 |
+
new_w = int(img.height * target)
|
| 326 |
+
left = (img.width - new_w) // 2
|
| 327 |
+
img = img.crop((left, 0, left + new_w, img.height))
|
| 328 |
+
else:
|
| 329 |
+
new_h = int(img.width / target)
|
| 330 |
+
top = (img.height - new_h) // 2
|
| 331 |
+
img = img.crop((0, top, img.width, top + new_h))
|
| 332 |
+
img.save(image_path)
|
|
|
|
| 333 |
return gr.update(value=image_path), gr.update(value=label)
|
| 334 |
|
| 335 |
|
|
|
|
| 339 |
|
| 340 |
<div align="center">
|
| 341 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a>
|
| 342 |
+
<a href="https://www.minimax.io/blog/minimax-h3"><strong>[ blog ]</strong></a>
|
| 343 |
+
<a href="https://huggingface.co/spaces/multimodalart/minimax-h3-reference"><strong>[ reference to video ]</strong></a>
|
| 344 |
</div>
|
| 345 |
|
| 346 |
**MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
|
|
|
|
| 397 |
cache_mode="lazy",
|
| 398 |
)
|
| 399 |
|
|
|
|
|
|
|
| 400 |
run.click(
|
| 401 |
generate,
|
| 402 |
[prompt, image, last_image, canvas, duration, steps, seed, upsample],
|
h3_aoti.py
CHANGED
|
@@ -1,59 +1,6 @@
|
|
| 1 |
-
"""ZeroGPU AoTI for MiniMax-H3:
|
| 2 |
-
|
| 3 |
-
Shared byte-identically by every MiniMax-H3 Space. A Space only
|
| 4 |
-
the debug Space's "Compile (AoTI)" tab, or off-Space from `job_bf16_aoti.py` on an `rtx-pro-6000` Job, and pushes its
|
| 5 |
-
artifacts to `multimodalart/minimax-h3-aoti` under `<width>/torch<X.Y>/sm<cc>/<shape>`.
|
| 6 |
-
|
| 7 |
-
What is measured, so nobody has to guess whether this is worth turning on. Unquantized bfloat16, 124 frames,
|
| 8 |
-
everything resident, one dynamic-sequence package serving every row — on an RTX PRO 6000 Blackwell, torch 2.11,
|
| 9 |
-
cuDNN attention:
|
| 10 |
-
|
| 11 |
-
canvas (HxW) eager s/step AoTI s/step saved faster
|
| 12 |
-
768x1344 10.20 9.73 0.47 s +4.6%
|
| 13 |
-
704x1280 8.59 7.87 0.72 s +8.4%
|
| 14 |
-
640x1152 6.46 5.88 0.59 s +9.1%
|
| 15 |
-
576x1024 4.74 4.24 0.50 s +10.5%
|
| 16 |
-
544x960 4.02 3.58 0.44 s +11.0%
|
| 17 |
-
|
| 18 |
-
Read the *absolute* column: AoTI removes a near-constant ~0.5 s/step no matter how big the canvas is. That is exactly
|
| 19 |
-
the shape of what it can remove — 50 blocks' worth of kernel-launch overhead and the norm / rotary / AdaLN-gather
|
| 20 |
-
epilogues around the matmuls. It cannot touch the matmuls themselves, and at S = 37726 one block is ~70 TFLOP of GEMM
|
| 21 |
-
and attention, so the released 768x1344 canvas is compute bound and only 4.6% comes back. The smaller the default
|
| 22 |
-
canvas gets, the better this pays.
|
| 23 |
-
|
| 24 |
-
The trap that cost a day, recorded here because the symptom is a segfault with no message: a **shallow clone exported
|
| 25 |
-
in torch.export's default non-strict mode duplicates every weight** — once as a named `PARAMETER` and once as an
|
| 26 |
-
anonymous `lifted_tensor_<N>` `CONSTANT_TENSOR` aliasing the same storage — and `LazyAOTIModel` binds constants by
|
| 27 |
-
name, so the anonymous half binds to nothing and the compiled kernel reads pointers nobody set. It is neither
|
| 28 |
-
accelerate's offload hooks nor torchao's tensor subclasses, which were both blamed first; it reproduces in plain
|
| 29 |
-
bfloat16 with no subclass anywhere, and it goes away with `strict=True`. See `export_block`.
|
| 30 |
-
|
| 31 |
-
Why block level rather than the whole transformer: `MiniMaxH3Transformer3DModel.forward` decides whether the packed
|
| 32 |
-
sequence needs a padding attention mask with `bool(is_pad.any())`, a data-dependent branch `torch.export` cannot
|
| 33 |
-
trace. One `MiniMaxH3TransformerBlock` is where all the time goes anyway (50 of them per step), and the sequence
|
| 34 |
-
length is the only thing that changes between requests, which a single dynamic dimension covers. The block's fifth
|
| 35 |
-
argument, `attention_mask`, is always `None` in practice — `packing.py` never emits a padding row, so `token_tags` is
|
| 36 |
-
never negative — which is what makes one static signature enough.
|
| 37 |
-
|
| 38 |
-
What `spaces` 0.51.1 actually provides (checked against the installed package, not the klein-era blog post):
|
| 39 |
-
|
| 40 |
-
spaces.aoti_capture(module) context manager, grabs the args of the next call and aborts it
|
| 41 |
-
spaces.aoti_compile(exported_program, configs) in-process compile, returns a ZeroGPUCompiledModel
|
| 42 |
-
spaces.aoti_compile_and_save(dir, ep, configs, submodule=...)
|
| 43 |
-
compile and write `<dir>/submodules/<submodule>/package.pt2`
|
| 44 |
-
spaces.aoti_apply(compiled, module) in-process apply
|
| 45 |
-
spaces.aoti_patch(module, LazyAOTIModel) apply a package to one module, weights stay live
|
| 46 |
-
spaces.aoti_load_from_package_dir(module, dir) walk `<dir>/{root,submodules/*}` and patch, ModuleList aware
|
| 47 |
-
spaces.aoti_load(module, repo_id, ...) the convenience wrapper — NOT usable here: it hardcodes
|
| 48 |
-
`snapshot_download(allow_patterns="package/*")` on a *model*
|
| 49 |
-
repo, and these artifacts are keyed by quant/torch/arch under a
|
| 50 |
-
dataset, so the download is done here and only the loader
|
| 51 |
-
(`aoti_load_from_package_dir`) is reused.
|
| 52 |
-
spaces.aoti_blocks_load(module, repo_id, variant) the `_repeated_blocks` convenience — same repo-layout mismatch.
|
| 53 |
-
|
| 54 |
-
Weights are *not* baked into the package: `aoti_patch` binds the block's live `state_dict()`, so one package serves all
|
| 55 |
-
50 blocks, and the quantized weights it reads are whatever the block holds. Which is also why quantization has to
|
| 56 |
-
happen *before* the export — the same ordering constraint as fusing a LoRA before AoTI.
|
| 57 |
"""
|
| 58 |
|
| 59 |
from __future__ import annotations
|
|
@@ -62,46 +9,22 @@ import os
|
|
| 62 |
from pathlib import Path
|
| 63 |
|
| 64 |
AOTI = os.environ.get("H3_AOTI", "0") == "1"
|
| 65 |
-
# The artifacts are keyed by quant/torch/arch under `<width>/torch<X.Y>/sm<cc>/<shape>` rather than laid out the way
|
| 66 |
-
# `spaces.aoti_load` expects, so the download is done by hand (see `maybe_load`).
|
| 67 |
AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
|
| 68 |
AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
|
| 69 |
-
#
|
| 70 |
-
#
|
| 71 |
-
|
| 72 |
-
# `
|
| 73 |
-
# static package would
|
| 74 |
-
#
|
| 75 |
-
# A `HxWxF` value instead pins the artifact to one static shape. That is the fallback for a width whose dynamic export
|
| 76 |
-
# is refused, which is what the NVFP4 attempt hit: export rejected the dimension and offered only the affine
|
| 77 |
-
# `S = 128 * k - 34` it had derived from that one capture — an offset that is a property of one canvas *and* one prompt
|
| 78 |
-
# rather than of the model, so a package built that way serves almost nothing. The 128 is the alignment torchao's
|
| 79 |
-
# scaled matmuls want, though that has not been re-verified since the bfloat16 path was proven, and a static package is
|
| 80 |
-
# only worth building for a width that has been shown to need one.
|
| 81 |
AOTI_SHAPE = os.environ.get("H3_AOTI_SHAPE", "dynamic")
|
| 82 |
AOTI_DURATION = int(os.environ.get("H3_AOTI_DURATION", "1500"))
|
| 83 |
|
| 84 |
-
#
|
| 85 |
-
# runs a handful of text rows a couple of times per step, so it is left eager.
|
| 86 |
BLOCK_CONTAINER = "transformer_blocks"
|
| 87 |
|
| 88 |
-
# Height of the AdaLN table baked into the package. `temb`
|
| 89 |
-
#
|
| 90 |
-
#
|
| 91 |
-
# share a noise level and `temb` has a single row, and from step 1 their sigma schedules diverge and it grows one.
|
| 92 |
-
# Exporting whatever the first call happened to show bakes in a 3-row table and the later steps then walk off it:
|
| 93 |
-
#
|
| 94 |
-
# Assertion `index out of bounds: 0 <= tmp22 < 3` failed
|
| 95 |
-
#
|
| 96 |
-
# A dynamic dimension is the wrong tool — `torch.export` specializes size-1 dimensions unconditionally, so a `Dim`
|
| 97 |
-
# taken from a 2-row capture carries a `>= 2` guard that step 0 violates. Instead `temb` is padded to a fixed height
|
| 98 |
-
# on both sides of the compile. Rows past the live ones are never gathered, so the output is unchanged, and the shape
|
| 99 |
-
# becomes a constant. Must match the `H3_AOTI_TEMB_ROWS` the package was compiled with.
|
| 100 |
-
#
|
| 101 |
-
# 4 is what the published bfloat16 packages were built with, and the padding is *validated* rather than assumed: the
|
| 102 |
-
# build job replays a real 1-row (step 0) call and a real 2-row (step 1) call through the compiled block and diffs both
|
| 103 |
-
# against eager. Two streams at two noise levels is the realistic maximum, so 4 is loose on purpose, and the cost is
|
| 104 |
-
# one slightly taller AdaLN projection per block against the block's own 70 TFLOP.
|
| 105 |
TEMB_ROWS = int(os.environ.get("H3_AOTI_TEMB_ROWS", "4"))
|
| 106 |
|
| 107 |
_LOADED: set[int] = set()
|
|
@@ -123,12 +46,7 @@ def pad_temb(temb, rows: int = TEMB_ROWS):
|
|
| 123 |
|
| 124 |
|
| 125 |
def width() -> str:
|
| 126 |
-
"""Which transformer these artifacts belong to: `bf16`, `fp8`, `nvfp4`, ...
|
| 127 |
-
|
| 128 |
-
`H3_WIDTH` wins, so a Space that has no `h3_core` — the unquantized split deployment is two standalone Spaces —
|
| 129 |
-
can use this module by setting one variable. Otherwise it comes from `h3_core`, which derives it from `H3_QUANT`
|
| 130 |
-
or from the pre-quantized repository's suffix.
|
| 131 |
-
"""
|
| 132 |
if explicit := os.environ.get("H3_WIDTH"):
|
| 133 |
return explicit.lower()
|
| 134 |
try:
|
|
@@ -139,12 +57,15 @@ def width() -> str:
|
|
| 139 |
return "bf16"
|
| 140 |
|
| 141 |
|
| 142 |
-
def artifact_key() -> str:
|
| 143 |
-
"""`<width>/torch<X.Y>/sm<cc>/<shape>`
|
| 144 |
-
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
return f"{width()}/torch{torch_version}/sm{major}{minor}/{AOTI_SHAPE}"
|
| 149 |
|
| 150 |
|
|
@@ -159,32 +80,15 @@ def status() -> str:
|
|
| 159 |
def patch_blocks(transformer, package_dir) -> None:
|
| 160 |
"""Point all 50 blocks at the one compiled package, binding each block's own weights on its first call.
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
patches, and `maybe_load` runs at startup, while the components are still on the host — `place()` only moves them
|
| 166 |
-
on the first request, because ZeroGPU cannot pack a startup-resident `Float8Tensor` (it packs CUDA tensors with
|
| 167 |
-
`aten.empty_like(..., pin_memory=True)`, which the subclass does not implement). `Module.to` rebinds `param.data`
|
| 168 |
-
to a fresh CUDA tensor, so a snapshot taken at startup keeps pointing at the host copies and the compiled block
|
| 169 |
-
would run against host memory. Reading the state dict on the first call instead picks it up wherever it now is.
|
| 170 |
-
|
| 171 |
-
*`temb` is padded on the way in*, to the fixed height the package was exported with — see `TEMB_ROWS`.
|
| 172 |
-
|
| 173 |
-
The clone-and-flatten here is `spaces.aoti_patch`'s own preparation, kept because a quantized width needs it: the
|
| 174 |
-
names it produces are the FQNs the package's constants were derived from. For an unquantized block it is a no-op
|
| 175 |
-
and the resulting names are exactly `blocks[0].state_dict()`, which is what `export_block` exported — the two
|
| 176 |
-
sides agree either way. What must *not* be mirrored is the clone on the export side under non-strict tracing; see
|
| 177 |
-
`export_block` for why that is the difference between a working package and a SIGSEGV.
|
| 178 |
"""
|
| 179 |
from spaces.zero.torch.aoti import LazyAOTIModel, _shallow_clone_module
|
| 180 |
from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
|
| 181 |
|
| 182 |
-
# `LazyAOTIModel` binds constants by
|
| 183 |
-
#
|
| 184 |
-
# binds nothing and the compiled block then dereferences constants nobody set — a SIGSEGV. This patch
|
| 185 |
-
# resolves those names through the `constant_aliases.json` the compile side writes, and **raises** a
|
| 186 |
-
# readable error if it still cannot. Purely protective: with a well-formed package it changes nothing,
|
| 187 |
-
# which is why a missing sidecar module is a warning rather than a failure.
|
| 188 |
try:
|
| 189 |
from spaces_constant_binding_patch import apply_spaces_constant_binding_patch
|
| 190 |
|
|
@@ -210,31 +114,41 @@ def patch_blocks(transformer, package_dir) -> None:
|
|
| 210 |
|
| 211 |
|
| 212 |
def maybe_load(transformer) -> None:
|
| 213 |
-
"""Patch the block stack with its compiled package
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
"""
|
| 219 |
if not AOTI or id(transformer) in _LOADED:
|
| 220 |
return
|
| 221 |
|
| 222 |
-
import spaces
|
| 223 |
-
from huggingface_hub import snapshot_download
|
| 224 |
-
|
| 225 |
key = artifact_key()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
print(f"[h3-aoti] loading {AOTI_REPO}:{key} ...", flush=True)
|
| 227 |
-
|
| 228 |
-
repo_id=AOTI_REPO,
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
package_dir = Path(local) / key / "package"
|
| 233 |
if not package_dir.is_dir():
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
)
|
| 238 |
patch_blocks(transformer, package_dir)
|
| 239 |
_LOADED.add(id(transformer))
|
| 240 |
print(f"[h3-aoti] compiled blocks in place (temb padded to {TEMB_ROWS} rows)", flush=True)
|
|
@@ -243,8 +157,8 @@ def maybe_load(transformer) -> None:
|
|
| 243 |
def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
| 244 |
"""Capture one block call out of a real request and export it with a dynamic sequence dimension.
|
| 245 |
|
| 246 |
-
Runs on the GPU, after the transformer has been quantized and moved there:
|
| 247 |
-
|
| 248 |
"""
|
| 249 |
import torch
|
| 250 |
import spaces
|
|
@@ -254,9 +168,8 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 254 |
transformer = h3.transformer_of(pipe)
|
| 255 |
blocks = getattr(transformer, BLOCK_CONTAINER)
|
| 256 |
|
| 257 |
-
#
|
| 258 |
-
#
|
| 259 |
-
# Text encoding and packing run either way, which is the point — these are the real inputs.
|
| 260 |
original_forward = blocks[0].forward
|
| 261 |
widest = {"args": (), "kwargs": {}, "rows": -1}
|
| 262 |
seen = []
|
|
@@ -285,16 +198,9 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 285 |
raise RuntimeError("Nothing was captured — the transformer block was never called.")
|
| 286 |
print(f"[h3-aoti] temb rows seen: {sorted(set(seen))}; exporting with {TEMB_ROWS} (padded)", flush=True)
|
| 287 |
|
| 288 |
-
# `block(hidden_states, temb, adaln_indices, rotary_emb, attention_mask)`
|
| 289 |
-
#
|
| 290 |
-
#
|
| 291 |
-
# adaln_indices (S,)
|
| 292 |
-
# rotary_emb ((S, dim), (S, dim))
|
| 293 |
-
# attention_mask None for a padless sequence, which is what these pipelines build
|
| 294 |
-
#
|
| 295 |
-
# Only the sequence is asked for. `temb`'s row count is held constant by padding instead (see `TEMB_ROWS`), which
|
| 296 |
-
# is both cheaper to reason about and the only thing that works: `torch.export` specializes size-1 dimensions
|
| 297 |
-
# unconditionally, so a `Dim` on a dimension that is 1 at step 0 cannot be expressed at all.
|
| 298 |
if AOTI_SHAPE == "dynamic":
|
| 299 |
sequence = torch.export.Dim("sequence", min=2048, max=262144)
|
| 300 |
dynamic_shapes = ({1: sequence}, None, {0: sequence}, ({0: sequence}, {0: sequence}), None)
|
|
@@ -302,34 +208,12 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 302 |
else:
|
| 303 |
dynamic_shapes = None
|
| 304 |
|
| 305 |
-
# `temb` to its fixed height, so the AdaLN table the package bakes in is the one `maybe_load` will feed it.
|
| 306 |
args = (call.args[0], pad_temb(call.args[1]), *call.args[2:])
|
| 307 |
|
| 308 |
-
#
|
| 309 |
-
#
|
| 310 |
-
#
|
| 311 |
-
#
|
| 312 |
-
# For a subclass that is genuinely necessary: inductor's constant handling wraps a constant back into
|
| 313 |
-
# `torch.nn.Parameter`, which rejects a non-floating dtype ("Only Tensors of floating point and complex dtype can
|
| 314 |
-
# require gradients"), so `Float8Tensor` / `NVFP4Tensor` parameters have to be flattened first.
|
| 315 |
-
#
|
| 316 |
-
# But a shallow clone exported in `torch.export`'s **default non-strict** mode duplicates every weight: the same
|
| 317 |
-
# tensor comes out once as a named `PARAMETER` and again as an anonymous `lifted_tensor_<N>` `CONSTANT_TENSOR`,
|
| 318 |
-
# 12 of each for this block, 1.2 GiB of them, `data_ptr()` proving the two sets alias. `LazyAOTIModel` binds by
|
| 319 |
-
# name, so the anonymous half binds to nothing and the compiled block dereferences constants nobody set. That is
|
| 320 |
-
# the crash that stalled this work, blamed first on accelerate's offload hooks and then on torchao's subclasses;
|
| 321 |
-
# it is neither. Measured on an rtx-pro-6000 Job at full size, torch 2.11, plain bfloat16, no subclass anywhere:
|
| 322 |
-
#
|
| 323 |
-
# live block, non-strict 12 PARAMETER, 0 CONSTANT_TENSOR <- what the shipped bf16 package used
|
| 324 |
-
# live block, strict 12 PARAMETER, 0 CONSTANT_TENSOR
|
| 325 |
-
# shallow clone, non-strict 12 PARAMETER, 12 CONSTANT_TENSOR <- the bug
|
| 326 |
-
# shallow clone, strict 12 PARAMETER, 0 CONSTANT_TENSOR
|
| 327 |
-
#
|
| 328 |
-
# So: export the **live block** whenever it has no subclass parameters to flatten, which is every unquantized
|
| 329 |
-
# width and, per the fp8 investigation, quantized ones whose weights are still registered parameters. Only fall
|
| 330 |
-
# back to the clone when flattening is actually needed, and then in `strict` mode, which is also clean. The clone
|
| 331 |
-
# is safe to skip for the live-block path precisely because there is nothing to unwrap: `state_dict()` names are
|
| 332 |
-
# then identical on both sides by construction.
|
| 333 |
from spaces.zero.torch.aoti import _shallow_clone_module
|
| 334 |
from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
|
| 335 |
|
|
@@ -344,13 +228,9 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 344 |
strict = False
|
| 345 |
print("[h3-aoti] plain parameters: exporting the live block, non-strict", flush=True)
|
| 346 |
|
| 347 |
-
# `torch.export` only gives a lifted tensor a real FQN when it is a registered parameter or buffer; a
|
| 348 |
-
#
|
| 349 |
-
#
|
| 350 |
-
# registered, never the tensor and never the forward. A well-formed block has none, and this returns empty.
|
| 351 |
-
# Only ever on the clone: `register_loose_tensors` *re-registers* attributes, so running it on the live block would
|
| 352 |
-
# mutate the model the eager path uses. A `MiniMaxH3TransformerBlock` has no loose tensor attributes, so this is
|
| 353 |
-
# empty in practice and the live-block export needs nothing; if that ever changes, the warning below catches it.
|
| 354 |
if block is not blocks[0]:
|
| 355 |
try:
|
| 356 |
from spaces_constant_binding_patch import register_loose_tensors
|
|
@@ -364,8 +244,6 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 364 |
try:
|
| 365 |
exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes, strict=strict)
|
| 366 |
except Exception as error:
|
| 367 |
-
# Dynamo refuses some modules it cannot trace. Non-strict is still worth attempting, with the duplication
|
| 368 |
-
# reported loudly below rather than left to segfault at load time.
|
| 369 |
if not strict:
|
| 370 |
raise
|
| 371 |
print(f"[h3-aoti] strict export failed ({type(error).__name__}: {error}); retrying non-strict", flush=True)
|
|
@@ -377,8 +255,7 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 377 |
if anonymous:
|
| 378 |
print(
|
| 379 |
f"[h3-aoti] WARNING {len(anonymous)} constants lifted anonymously: {anonymous[:6]}. The loader binds by "
|
| 380 |
-
f"name, so
|
| 381 |
-
f"`patch_blocks` raises rather than letting it segfault.",
|
| 382 |
flush=True,
|
| 383 |
)
|
| 384 |
return exported
|
|
@@ -387,9 +264,8 @@ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
|
| 387 |
def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> Path:
|
| 388 |
"""Inductor-compile the exported block into `<destination>/package/submodules/transformer_blocks/package.pt2`.
|
| 389 |
|
| 390 |
-
That layout is what `aoti_load_from_package_dir` walks
|
| 391 |
-
`transformer_blocks` `ModuleList` and
|
| 392 |
-
this single package.
|
| 393 |
"""
|
| 394 |
import spaces
|
| 395 |
|
|
@@ -397,9 +273,8 @@ def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> P
|
|
| 397 |
print("[h3-aoti] inductor compile (minutes) ...", flush=True)
|
| 398 |
spaces.aoti_compile_and_save(package_dir, exported_program, submodule=BLOCK_CONTAINER)
|
| 399 |
|
| 400 |
-
# The compiled artifact
|
| 401 |
-
#
|
| 402 |
-
# mapping next to the package while it is still available; the loader reads it back.
|
| 403 |
try:
|
| 404 |
from spaces_constant_binding_patch import write_constant_aliases
|
| 405 |
|
|
@@ -414,7 +289,7 @@ def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> P
|
|
| 414 |
|
| 415 |
|
| 416 |
def upload(package_dir: str | os.PathLike[str], key: str) -> str:
|
| 417 |
-
"""Push the package under its `<
|
| 418 |
from huggingface_hub import HfApi
|
| 419 |
|
| 420 |
token = os.environ.get("HF_TOKEN")
|
|
|
|
| 1 |
+
"""ZeroGPU AoTI for MiniMax-H3: one compiled `MiniMaxH3TransformerBlock` package, reused by all 50 blocks.
|
| 2 |
+
|
| 3 |
+
Shared byte-identically by every MiniMax-H3 Space. A Space only calls `maybe_load()`; the rest is the build path.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
AOTI = os.environ.get("H3_AOTI", "0") == "1"
|
|
|
|
|
|
|
| 12 |
AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
|
| 13 |
AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
|
| 14 |
+
# A package is valid for exactly one `<width>/torch<X.Y>/sm<cc>/<shape>`, and a mismatched one segfaults rather than
|
| 15 |
+
# raising, so `maybe_load` refuses anything but this key.
|
| 16 |
+
AOTI_KEY = os.environ.get("H3_AOTI_KEY", "bf16/torch2.11/sm120/dynamic")
|
| 17 |
+
# `dynamic` is the sequence dimension: `build_packed_sequence` pads nothing, so `S` moves with the prompt as well as
|
| 18 |
+
# the canvas and a static package would serve one prompt length.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
AOTI_SHAPE = os.environ.get("H3_AOTI_SHAPE", "dynamic")
|
| 20 |
AOTI_DURATION = int(os.environ.get("H3_AOTI_DURATION", "1500"))
|
| 21 |
|
| 22 |
+
# Where a step spends its time. `MiniMaxH3TokenRefinerBlock` is also repeated but runs a handful of text rows.
|
|
|
|
| 23 |
BLOCK_CONTAINER = "transformer_blocks"
|
| 24 |
|
| 25 |
+
# Height of the AdaLN table baked into the package. `temb` grows from 1 row (step 0, both streams at one noise level)
|
| 26 |
+
# to 2 (from step 1, sigmas diverged), and the block gathers from `3 * rows`, so the row count is part of the compiled
|
| 27 |
+
# shape and is pinned by padding on both sides of the compile. Must match the package's `H3_AOTI_TEMB_ROWS`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
TEMB_ROWS = int(os.environ.get("H3_AOTI_TEMB_ROWS", "4"))
|
| 29 |
|
| 30 |
_LOADED: set[int] = set()
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def width() -> str:
|
| 49 |
+
"""Which transformer these artifacts belong to: `bf16`, `fp8`, `nvfp4`, ..."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if explicit := os.environ.get("H3_WIDTH"):
|
| 51 |
return explicit.lower()
|
| 52 |
try:
|
|
|
|
| 57 |
return "bf16"
|
| 58 |
|
| 59 |
|
| 60 |
+
def artifact_key() -> str | None:
|
| 61 |
+
"""`<width>/torch<X.Y>/sm<cc>/<shape>` of the card this process is on, or `None` when there is no CUDA."""
|
| 62 |
+
try:
|
| 63 |
+
import torch
|
| 64 |
|
| 65 |
+
torch_version = ".".join(torch.__version__.split(".")[:2])
|
| 66 |
+
major, minor = torch.cuda.get_device_capability()
|
| 67 |
+
except Exception:
|
| 68 |
+
return None
|
| 69 |
return f"{width()}/torch{torch_version}/sm{major}{minor}/{AOTI_SHAPE}"
|
| 70 |
|
| 71 |
|
|
|
|
| 80 |
def patch_blocks(transformer, package_dir) -> None:
|
| 81 |
"""Point all 50 blocks at the one compiled package, binding each block's own weights on its first call.
|
| 82 |
|
| 83 |
+
`spaces.aoti_load_from_package_dir` with two changes. Weights are read on the first forward rather than at patch
|
| 84 |
+
time, because this runs at startup and `Module.to` later rebinds `param.data` to fresh CUDA tensors. And `temb` is
|
| 85 |
+
padded to the height the package was exported with — see `TEMB_ROWS`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
"""
|
| 87 |
from spaces.zero.torch.aoti import LazyAOTIModel, _shallow_clone_module
|
| 88 |
from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
|
| 89 |
|
| 90 |
+
# `LazyAOTIModel` binds constants by name and silently keeps what it cannot match, which is a SIGSEGV rather than
|
| 91 |
+
# an error. The patch resolves anonymous names through the compile side's sidecar and raises if it still cannot.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
from spaces_constant_binding_patch import apply_spaces_constant_binding_patch
|
| 94 |
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def maybe_load(transformer) -> None:
|
| 117 |
+
"""Patch the block stack with its compiled package, or leave it eager. Safe to call at **startup**.
|
| 118 |
|
| 119 |
+
Off unless `H3_AOTI=1`, and anything that does not line up — another card, another torch, no `spaces` AoTI
|
| 120 |
+
helpers, no published package — falls back to eager with one line rather than raising or segfaulting. Nothing here
|
| 121 |
+
touches a GPU: the download is CPU work and the `.pt2` is not opened until the first forward.
|
| 122 |
"""
|
| 123 |
if not AOTI or id(transformer) in _LOADED:
|
| 124 |
return
|
| 125 |
|
|
|
|
|
|
|
|
|
|
| 126 |
key = artifact_key()
|
| 127 |
+
if key is None:
|
| 128 |
+
print("[h3-aoti] no CUDA device visible; running eager", flush=True)
|
| 129 |
+
return
|
| 130 |
+
if key != AOTI_KEY:
|
| 131 |
+
print(f"[h3-aoti] this card wants `{key}`, only `{AOTI_KEY}` is published; running eager", flush=True)
|
| 132 |
+
return
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
from huggingface_hub import snapshot_download
|
| 136 |
+
from spaces.zero.torch.aoti import LazyAOTIModel # noqa: F401
|
| 137 |
+
except Exception as error:
|
| 138 |
+
print(f"[h3-aoti] no AoTI loader here ({type(error).__name__}: {error}); running eager", flush=True)
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
print(f"[h3-aoti] loading {AOTI_REPO}:{key} ...", flush=True)
|
| 142 |
+
try:
|
| 143 |
+
local = snapshot_download(repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, allow_patterns=f"{key}/package/*")
|
| 144 |
+
except Exception as error:
|
| 145 |
+
print(f"[h3-aoti] {AOTI_REPO}:{key} unreachable ({type(error).__name__}: {error}); running eager", flush=True)
|
| 146 |
+
return
|
| 147 |
package_dir = Path(local) / key / "package"
|
| 148 |
if not package_dir.is_dir():
|
| 149 |
+
print(f"[h3-aoti] no package at `{AOTI_REPO}:{key}/package`; running eager", flush=True)
|
| 150 |
+
return
|
| 151 |
+
|
|
|
|
| 152 |
patch_blocks(transformer, package_dir)
|
| 153 |
_LOADED.add(id(transformer))
|
| 154 |
print(f"[h3-aoti] compiled blocks in place (temb padded to {TEMB_ROWS} rows)", flush=True)
|
|
|
|
| 157 |
def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
|
| 158 |
"""Capture one block call out of a real request and export it with a dynamic sequence dimension.
|
| 159 |
|
| 160 |
+
Runs on the GPU, after the transformer has been quantized and moved there: a package compiled for one
|
| 161 |
+
quantization mode is meaningless for another.
|
| 162 |
"""
|
| 163 |
import torch
|
| 164 |
import spaces
|
|
|
|
| 168 |
transformer = h3.transformer_of(pipe)
|
| 169 |
blocks = getattr(transformer, BLOCK_CONTAINER)
|
| 170 |
|
| 171 |
+
# Keep the widest `temb` over a short real run rather than `spaces.aoti_capture`'s first call, which is the
|
| 172 |
+
# 1-row one — see `TEMB_ROWS`.
|
|
|
|
| 173 |
original_forward = blocks[0].forward
|
| 174 |
widest = {"args": (), "kwargs": {}, "rows": -1}
|
| 175 |
seen = []
|
|
|
|
| 198 |
raise RuntimeError("Nothing was captured — the transformer block was never called.")
|
| 199 |
print(f"[h3-aoti] temb rows seen: {sorted(set(seen))}; exporting with {TEMB_ROWS} (padded)", flush=True)
|
| 200 |
|
| 201 |
+
# `block(hidden_states, temb, adaln_indices, rotary_emb, attention_mask)`, `attention_mask` being `None` for the
|
| 202 |
+
# padless sequences these pipelines build. Only the sequence is dynamic: `torch.export` specializes size-1
|
| 203 |
+
# dimensions unconditionally, so a `Dim` on `temb`'s rows cannot be expressed at all.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
if AOTI_SHAPE == "dynamic":
|
| 205 |
sequence = torch.export.Dim("sequence", min=2048, max=262144)
|
| 206 |
dynamic_shapes = ({1: sequence}, None, {0: sequence}, ({0: sequence}, {0: sequence}), None)
|
|
|
|
| 208 |
else:
|
| 209 |
dynamic_shapes = None
|
| 210 |
|
|
|
|
| 211 |
args = (call.args[0], pad_temb(call.args[1]), *call.args[2:])
|
| 212 |
|
| 213 |
+
# Export the **live** block, non-strict. A shallow clone under non-strict tracing lifts every weight twice — once
|
| 214 |
+
# named, once as an anonymous `CONSTANT_TENSOR` aliasing it — and the loader binds by name, so the compiled block
|
| 215 |
+
# dereferences constants nobody set. The clone is only for flattening tensor-subclass parameters, which inductor's
|
| 216 |
+
# constant handling cannot wrap back into a `Parameter`, and it needs `strict=True`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
from spaces.zero.torch.aoti import _shallow_clone_module
|
| 218 |
from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
|
| 219 |
|
|
|
|
| 228 |
strict = False
|
| 229 |
print("[h3-aoti] plain parameters: exporting the live block, non-strict", flush=True)
|
| 230 |
|
| 231 |
+
# `torch.export` only gives a lifted tensor a real FQN when it is a registered parameter or buffer; a plain
|
| 232 |
+
# attribute becomes an anonymous constant the loader can never match. Only ever on the clone, since this
|
| 233 |
+
# re-registers attributes and the live block is what the eager path runs.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
if block is not blocks[0]:
|
| 235 |
try:
|
| 236 |
from spaces_constant_binding_patch import register_loose_tensors
|
|
|
|
| 244 |
try:
|
| 245 |
exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes, strict=strict)
|
| 246 |
except Exception as error:
|
|
|
|
|
|
|
| 247 |
if not strict:
|
| 248 |
raise
|
| 249 |
print(f"[h3-aoti] strict export failed ({type(error).__name__}: {error}); retrying non-strict", flush=True)
|
|
|
|
| 255 |
if anonymous:
|
| 256 |
print(
|
| 257 |
f"[h3-aoti] WARNING {len(anonymous)} constants lifted anonymously: {anonymous[:6]}. The loader binds by "
|
| 258 |
+
f"name, so `compile_and_save` writes the alias sidecar and `patch_blocks` raises rather than segfaulting.",
|
|
|
|
| 259 |
flush=True,
|
| 260 |
)
|
| 261 |
return exported
|
|
|
|
| 264 |
def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> Path:
|
| 265 |
"""Inductor-compile the exported block into `<destination>/package/submodules/transformer_blocks/package.pt2`.
|
| 266 |
|
| 267 |
+
That layout is what `aoti_load_from_package_dir` walks, resolving the submodule name to the transformer's
|
| 268 |
+
`transformer_blocks` `ModuleList` and patching every block in it with this one package.
|
|
|
|
| 269 |
"""
|
| 270 |
import spaces
|
| 271 |
|
|
|
|
| 273 |
print("[h3-aoti] inductor compile (minutes) ...", flush=True)
|
| 274 |
spaces.aoti_compile_and_save(package_dir, exported_program, submodule=BLOCK_CONTAINER)
|
| 275 |
|
| 276 |
+
# The compiled artifact drops a constant's FQN when the export lifted it anonymously; the `ExportedProgram` still
|
| 277 |
+
# has the real names, so record the mapping for the loader while it is available.
|
|
|
|
| 278 |
try:
|
| 279 |
from spaces_constant_binding_patch import write_constant_aliases
|
| 280 |
|
|
|
|
| 289 |
|
| 290 |
|
| 291 |
def upload(package_dir: str | os.PathLike[str], key: str) -> str:
|
| 292 |
+
"""Push the package under its `<width>/torch<X.Y>/sm<cc>/<shape>` key. CPU work — never inside GPU time."""
|
| 293 |
from huggingface_hub import HfApi
|
| 294 |
|
| 295 |
token = os.environ.get("HF_TOKEN")
|
h3_split_blocks.py
CHANGED
|
@@ -1,38 +1,15 @@
|
|
| 1 |
"""The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
|
| 2 |
|
| 3 |
-
MiniMax-H3 is
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
not fit on one 95 GiB card unquantized, so this module cuts that sequence in two at the `text_encoder` step, once per
|
| 14 |
-
partition:
|
| 15 |
-
|
| 16 |
-
* `MiniMaxH3ConditionerBlocks` = `[resize, text_encoder]` — loads `text_encoder` / `tokenizer` / `processor` only
|
| 17 |
-
(plus the `image_processor`, which is built from config and downloads nothing), and emits `prompt_embeds` +
|
| 18 |
-
`text_token_tags`, which is the whole wire format between the two halves.
|
| 19 |
-
* `MiniMaxH3GeneratorBlocks` = everything else — loads `transformer` / `vae` / `audio_vae` / the two schedulers
|
| 20 |
-
only, and takes `prompt_embeds` + `text_token_tags` as *inputs*.
|
| 21 |
-
* `MiniMaxH3Ref2VAConditionerBlocks` / `MiniMaxH3Ref2VAGeneratorBlocks` are the same cut through the `ref2va`
|
| 22 |
-
branch, so one conditioner Space serves both partitions out of the weights it already holds.
|
| 23 |
-
|
| 24 |
-
`resize` / `setup` run on both sides on purpose. They own no pretrained component (PIL, decoded media and arithmetic),
|
| 25 |
-
they resolve the canvas and prepare the keyframes or normalize the references — which the conditioner needs to build
|
| 26 |
-
its vision blocks and the generator needs to encode with the VAEs. Running them twice over the same inputs is
|
| 27 |
-
deterministic; both conditioner halves return the resolved `height` / `width` / `num_frames` anyway, so the caller
|
| 28 |
-
pins them explicitly on the generating half.
|
| 29 |
-
|
| 30 |
-
Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` /
|
| 31 |
-
`fl2va` frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives in the layout step on the
|
| 32 |
-
denoising side of the cut. `ref2va` still resolves its own frame count, but requires one to be passed.
|
| 33 |
-
|
| 34 |
-
Only *text* encoding is remote. `vae_encoder` / `reference_encoder` stay on the denoising side: they run the two
|
| 35 |
-
autoencoders, which the conditioner Space does not hold.
|
| 36 |
"""
|
| 37 |
|
| 38 |
from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
|
|
@@ -55,10 +32,7 @@ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
|
|
| 55 |
|
| 56 |
|
| 57 |
def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
|
| 58 |
-
"""The wire format of the split
|
| 59 |
-
|
| 60 |
-
`num_frames` is declared by the `ref2va` half alone: it is the one whose setup step resolves a frame count.
|
| 61 |
-
"""
|
| 62 |
return [
|
| 63 |
OutputParam.template("prompt_embeds"),
|
| 64 |
OutputParam("text_token_tags", description="The per-row modality tag of every row of `prompt_embeds`."),
|
|
@@ -121,12 +95,9 @@ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
|
|
| 121 |
class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
|
| 122 |
"""The conditioner half of a split `ref2va`: the resolved plan plus the Qwen3-VL read at its 50th layer.
|
| 123 |
|
| 124 |
-
Component for component this is `MiniMaxH3ConditionerBlocks`
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
modality, and a vision block per image and per merged video frame pair, so the references themselves have to
|
| 128 |
-
reach this half. An audio reference never does — it contributes its `"<Audio j>: "` label and nothing else — but
|
| 129 |
-
it is still part of the request here, because the setup step normalizes every soundtrack and validates the mix.
|
| 130 |
"""
|
| 131 |
|
| 132 |
model_name = "minimax-h3"
|
|
@@ -149,9 +120,8 @@ class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
|
|
| 149 |
class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
|
| 150 |
"""The denoising half of a split `ref2va`: the `ref2va` branch with its `text_encoder` step removed.
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
reference block's geometry in the packed layout comes from — so it stays here, next to the autoencoders.
|
| 155 |
"""
|
| 156 |
|
| 157 |
model_name = "minimax-h3"
|
|
|
|
| 1 |
"""The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
|
| 2 |
|
| 3 |
+
MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage, so `MiniMaxH3Blocks` is cut
|
| 4 |
+
at its `text_encoder` step: the 62.14 GiB Qwen3-VL runs in the conditioner Space, everything else in a generator
|
| 5 |
+
Space, and `prompt_embeds` + `text_token_tags` is the whole wire format between them.
|
| 6 |
+
|
| 7 |
+
`resize` / `setup` run on **both** sides: they own no pretrained component, and each half needs the canvas and the
|
| 8 |
+
prepared keyframes or normalized references. Both conditioner halves also return the resolved `height` / `width` /
|
| 9 |
+
`num_frames`, which the generating half pins rather than re-deriving.
|
| 10 |
+
|
| 11 |
+
Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` / `fl2va`
|
| 12 |
+
frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives on the denoising side of the cut.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
|
| 35 |
+
"""The wire format of the split. `num_frames` is declared by the `ref2va` half alone, whose setup resolves one."""
|
|
|
|
|
|
|
|
|
|
| 36 |
return [
|
| 37 |
OutputParam.template("prompt_embeds"),
|
| 38 |
OutputParam("text_token_tags", description="The per-row modality tag of every row of `prompt_embeds`."),
|
|
|
|
| 95 |
class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
|
| 96 |
"""The conditioner half of a split `ref2va`: the resolved plan plus the Qwen3-VL read at its 50th layer.
|
| 97 |
|
| 98 |
+
Component for component this is `MiniMaxH3ConditionerBlocks`, so one conditioner Space serves both partitions.
|
| 99 |
+
What differs is the presentation: `ref2va` prepends a label per reference and a vision block per image and per
|
| 100 |
+
merged video frame pair, so the references themselves have to reach this half.
|
|
|
|
|
|
|
|
|
|
| 101 |
"""
|
| 102 |
|
| 103 |
model_name = "minimax-h3"
|
|
|
|
| 120 |
class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
|
| 121 |
"""The denoising half of a split `ref2va`: the `ref2va` branch with its `text_encoder` step removed.
|
| 122 |
|
| 123 |
+
`reference_encoder` stays here, next to the two autoencoders it runs: its output shapes are where every reference
|
| 124 |
+
block's geometry in the packed layout comes from.
|
|
|
|
| 125 |
"""
|
| 126 |
|
| 127 |
model_name = "minimax-h3"
|
requirements.txt
CHANGED
|
@@ -5,23 +5,20 @@
|
|
| 5 |
# head — whenever the PR updates.
|
| 6 |
#
|
| 7 |
# 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
|
| 8 |
-
#
|
| 9 |
-
# Nothing is quantized in this deployment, so there is no `torchao`.
|
| 10 |
--extra-index-url https://download.pytorch.org/whl/cu130
|
| 11 |
diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
|
| 12 |
torch==2.11.0
|
| 13 |
torchvision==0.26.0
|
| 14 |
-
#
|
| 15 |
-
# patch count, so a different minor changes the conditioning.
|
| 16 |
transformers==5.8.0
|
| 17 |
accelerate==1.14.0
|
| 18 |
-
# diffusers pins <2
|
| 19 |
huggingface-hub==1.24.0
|
| 20 |
gradio==6.20.0
|
| 21 |
spaces==0.51.1
|
| 22 |
# No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
|
| 23 |
-
# transformers 5.8.0 at import.
|
| 24 |
-
# PyAV
|
| 25 |
av
|
| 26 |
pillow
|
| 27 |
numpy
|
|
|
|
| 5 |
# head — whenever the PR updates.
|
| 6 |
#
|
| 7 |
# 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
|
|
|
|
|
|
|
| 8 |
--extra-index-url https://download.pytorch.org/whl/cu130
|
| 9 |
diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
|
| 10 |
torch==2.11.0
|
| 11 |
torchvision==0.26.0
|
| 12 |
+
# The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
|
|
|
|
| 13 |
transformers==5.8.0
|
| 14 |
accelerate==1.14.0
|
| 15 |
+
# diffusers pins <2.
|
| 16 |
huggingface-hub==1.24.0
|
| 17 |
gradio==6.20.0
|
| 18 |
spaces==0.51.1
|
| 19 |
# No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
|
| 20 |
+
# transformers 5.8.0 at import.
|
| 21 |
+
# PyAV muxes the generated soundtrack onto the frames (`encode_video`).
|
| 22 |
av
|
| 23 |
pillow
|
| 24 |
numpy
|
spaces_constant_binding_patch.py
CHANGED
|
@@ -1,32 +1,12 @@
|
|
| 1 |
"""Bind AoTI constants that `torch.export` lifted anonymously.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
compiled_model.load_constants(constant_map, check_full_update=check_full_update, user_managed=True)
|
| 10 |
-
|
| 11 |
-
`torch.export` only gives a lifted tensor a real FQN when it was a registered parameter or buffer.
|
| 12 |
-
Anything reached through a plain python attribute is classified `CONSTANT_TENSOR` and the compiled
|
| 13 |
-
artifact names it `_tensor_constant<N>` — a name that can never appear in `state_dict()`. The
|
| 14 |
-
intersection above is then empty, the dict comprehension silently drops every weight, and the
|
| 15 |
-
compiled model runs against constants nobody ever set: a SIGSEGV rather than an error.
|
| 16 |
-
|
| 17 |
-
This module fixes both halves:
|
| 18 |
-
|
| 19 |
-
* `write_constant_aliases(...)` — compile side. Records the exact
|
| 20 |
-
`_tensor_constant<N> -> real.dotted.fqn` mapping, which the `ExportedProgram` knows even when the
|
| 21 |
-
compiled package does not, into a `constant_aliases.json` sidecar next to `package.pt2`.
|
| 22 |
-
|
| 23 |
-
* `apply_spaces_constant_binding_patch()` — load side. Monkeypatches `LazyAOTIModel.__call__` so it
|
| 24 |
-
(1) uses that sidecar when present, (2) otherwise falls back to matching anonymous constants
|
| 25 |
-
against the leftover `state_dict()` entries by dtype+shape read out of the package's own
|
| 26 |
-
`wrapper.cpp`, and (3) **raises** if the binding is not total instead of segfaulting later.
|
| 27 |
-
|
| 28 |
-
The load-side patch alone is enough to turn the crash into a clear diagnostic; with the sidecar it
|
| 29 |
-
also makes the package work.
|
| 30 |
"""
|
| 31 |
|
| 32 |
from __future__ import annotations
|
|
@@ -56,10 +36,7 @@ _DTYPES = {
|
|
| 56 |
def register_loose_tensors(module: torch.nn.Module, prefix: str = "") -> list[str]:
|
| 57 |
"""Re-register plain tensor attributes as buffers so `torch.export` gives them real FQNs.
|
| 58 |
|
| 59 |
-
|
| 60 |
-
and never the forward. Run it on the shallow clone right after
|
| 61 |
-
`unwrap_tensor_subclass_parameters`, immediately before `torch.export.export`. Returns the names
|
| 62 |
-
it re-registered, which is empty for a module that was already well-formed.
|
| 63 |
"""
|
| 64 |
registered = []
|
| 65 |
for name, value in list(vars(module).items()):
|
|
@@ -78,8 +55,8 @@ def register_loose_tensors(module: torch.nn.Module, prefix: str = "") -> list[st
|
|
| 78 |
def constant_aliases_from_exported_program(exported_program) -> dict[str, str]:
|
| 79 |
"""`{'_tensor_constant<N>': '<real dotted fqn>'}` for every anonymously lifted constant.
|
| 80 |
|
| 81 |
-
AOT Inductor numbers its
|
| 82 |
-
|
| 83 |
"""
|
| 84 |
targets = [
|
| 85 |
spec.target
|
|
@@ -137,13 +114,12 @@ def resolve_constant_map(
|
|
| 137 |
aliases=None,
|
| 138 |
allow_shape_fallback: bool = False,
|
| 139 |
):
|
| 140 |
-
"""Map every compiled constant FQN onto one of `weights`, or
|
| 141 |
constant_map = {name: weights[name] for name in constant_fqns if name in weights}
|
| 142 |
missing = [name for name in constant_fqns if name not in constant_map]
|
| 143 |
if not missing:
|
| 144 |
return constant_map, []
|
| 145 |
|
| 146 |
-
# 1. the exact mapping, if the compile side recorded one
|
| 147 |
aliases = aliases or {}
|
| 148 |
for name in list(missing):
|
| 149 |
target = aliases.get(name)
|
|
@@ -153,10 +129,9 @@ def resolve_constant_map(
|
|
| 153 |
if not missing or not allow_shape_fallback:
|
| 154 |
return constant_map, missing
|
| 155 |
|
| 156 |
-
#
|
| 157 |
-
#
|
| 158 |
-
#
|
| 159 |
-
# package's own `constants_info_` index is the only correct order to walk them in.
|
| 160 |
info = _package_constants_info(archive_file)
|
| 161 |
by_name = {entry.get("name"): entry for entry in info}
|
| 162 |
slot_index = {entry.get("name"): index for index, entry in enumerate(info)}
|
|
|
|
| 1 |
"""Bind AoTI constants that `torch.export` lifted anonymously.
|
| 2 |
|
| 3 |
+
`spaces.zero.torch.aoti.LazyAOTIModel` binds a package's constants by intersecting the module's `state_dict()` with
|
| 4 |
+
`compiled_model.get_constant_fqns()`, and keeps whatever it cannot match. `torch.export` only gives a lifted tensor a
|
| 5 |
+
real FQN when it was a registered parameter or buffer; anything else is named `_tensor_constant<N>`, which no
|
| 6 |
+
`state_dict()` can contain, so the compiled model runs against constants nobody set — a SIGSEGV rather than an error.
|
| 7 |
|
| 8 |
+
`write_constant_aliases` records the real names on the compile side; `apply_spaces_constant_binding_patch` uses that
|
| 9 |
+
sidecar on the load side, falls back to matching by dtype+shape, and raises if the binding is still not total.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 36 |
def register_loose_tensors(module: torch.nn.Module, prefix: str = "") -> list[str]:
|
| 37 |
"""Re-register plain tensor attributes as buffers so `torch.export` gives them real FQNs.
|
| 38 |
|
| 39 |
+
Run on the shallow clone, right before `torch.export.export`. Returns the names it re-registered.
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
registered = []
|
| 42 |
for name, value in list(vars(module).items()):
|
|
|
|
| 55 |
def constant_aliases_from_exported_program(exported_program) -> dict[str, str]:
|
| 56 |
"""`{'_tensor_constant<N>': '<real dotted fqn>'}` for every anonymously lifted constant.
|
| 57 |
|
| 58 |
+
AOT Inductor numbers its slots in the order the `CONSTANT_TENSOR` inputs appear in the graph signature, which
|
| 59 |
+
still carries each one's real FQN.
|
| 60 |
"""
|
| 61 |
targets = [
|
| 62 |
spec.target
|
|
|
|
| 114 |
aliases=None,
|
| 115 |
allow_shape_fallback: bool = False,
|
| 116 |
):
|
| 117 |
+
"""Map every compiled constant FQN onto one of `weights`, or report what is left over."""
|
| 118 |
constant_map = {name: weights[name] for name in constant_fqns if name in weights}
|
| 119 |
missing = [name for name in constant_fqns if name not in constant_map]
|
| 120 |
if not missing:
|
| 121 |
return constant_map, []
|
| 122 |
|
|
|
|
| 123 |
aliases = aliases or {}
|
| 124 |
for name in list(missing):
|
| 125 |
target = aliases.get(name)
|
|
|
|
| 129 |
if not missing or not allow_shape_fallback:
|
| 130 |
return constant_map, missing
|
| 131 |
|
| 132 |
+
# Match by dtype+shape against the unclaimed `state_dict()` entries, preserving each side's own order inside a
|
| 133 |
+
# (dtype, shape) group. `get_constant_fqns()` returns slots lexicographically (`_tensor_constant10` before
|
| 134 |
+
# `_tensor_constant2`), so the package's own `constants_info_` index is the only correct order to walk them in.
|
|
|
|
| 135 |
info = _package_constants_info(archive_file)
|
| 136 |
by_name = {entry.get("name"): entry for entry in info}
|
| 137 |
slot_index = {entry.get("name"): index for index, entry in enumerate(info)}
|