Spaces:
Running on Zero
Running on Zero
Commit ·
92693c1
1
Parent(s): 74a631a
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
- app.py +86 -156
- h3_aoti.py +72 -197
- h3_split_blocks.py +16 -46
- requirements.txt +5 -9
- spaces_constant_binding_patch.py +13 -38
__pycache__/app.cpython-310.pyc
ADDED
|
Binary file (22.1 kB). View file
|
|
|
app.py
CHANGED
|
@@ -1,18 +1,8 @@
|
|
| 1 |
-
"""MiniMax-H3 `ref2va`, split deployment —
|
| 2 |
|
| 3 |
-
This Space holds the `transformer_ref` partition
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
one calls over the gradio API for every request; what comes back is a safetensors file holding the two tensors the
|
| 7 |
-
denoiser needs, `prompt_embeds` and `text_token_tags`.
|
| 8 |
-
|
| 9 |
-
Why split at all: MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage, so an
|
| 10 |
-
unquantized single Space is impossible. Cut at the text-encoder step, this half pulls 77.3 GB (`transformer_ref/`
|
| 11 |
-
61.73 GiB + `vae/` 9.70 + `audio_vae/` 0.56) and the conditioner 66.7 GB, and neither is quantized.
|
| 12 |
-
|
| 13 |
-
The blockset is the `ref2va` branch of `MiniMaxH3Blocks` with its `text_encoder` step removed — see
|
| 14 |
-
`h3_split_blocks.py`. Only *text* encoding is remote: `reference_encoder` is the `ref2va` branch's own encoder step
|
| 15 |
-
and runs here, next to the two autoencoders it needs.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
@@ -23,32 +13,27 @@ import time
|
|
| 23 |
import traceback
|
| 24 |
from functools import cache
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
#
|
| 28 |
import spaces
|
| 29 |
import gradio as gr
|
| 30 |
|
| 31 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 32 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 33 |
# `lazy` moves all 72.16 GiB onto the card on the first GPU call and leaves it there; `offload` hands placement to
|
| 34 |
-
# `ComponentsManager.enable_auto_cpu_offload`
|
| 35 |
-
# deliberate — see `load_models`: the 150 GB storage quota, not the 95 GiB card, is what rules that out here.
|
| 36 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "lazy").lower()
|
| 37 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 38 |
# flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
|
| 39 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
| 40 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
| 41 |
-
# Bounds on what `get_duration` may
|
| 42 |
-
#
|
| 43 |
-
# "too many ZeroGPU credits allocated to running tasks", because the pool reserves the number it is given.
|
| 44 |
MIN_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MIN", "120"))
|
| 45 |
MAX_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MAX", "1500"))
|
| 46 |
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
# so the UI renders before `diffusers` is importable.
|
| 50 |
-
# Must stay identical to the conditioner's table: this Space forwards the *label* to the conditioner, so a canvas
|
| 51 |
-
# that half does not know is rejected there and surfaces as a failure here.
|
| 52 |
CANVASES = {
|
| 53 |
# 16:9
|
| 54 |
"960x544 · 16:9 fast": (544, 960),
|
|
@@ -73,41 +58,27 @@ CANVASES = {
|
|
| 73 |
"1536x672 · 21:9 full": (672, 1536),
|
| 74 |
}
|
| 75 |
DEFAULT_CANVAS = "960x544 · 16:9 fast"
|
| 76 |
-
# The examples carry their own canvas, and it is a full one. They are cached lazily and generated once, so what an
|
| 77 |
-
# example is worth is its quality rather than its latency; an interactive request starts on the fast canvas.
|
| 78 |
EXAMPLE_CANVAS, EXAMPLE_PORTRAIT_CANVAS = "1344x768 · 16:9 full", "768x1024 · 3:4 full"
|
| 79 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
MAX_UI_DURATION = 14
|
| 83 |
-
MIN_DURATION =
|
| 84 |
-
# A reference video shorter than 2 s gives the model almost no motion to read
|
| 85 |
MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
|
| 86 |
-
# `MINIMAX_H3_MAX_REFERENCE_IMAGES`
|
| 87 |
-
#
|
| 88 |
MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
|
| 89 |
|
| 90 |
-
#
|
| 91 |
-
#
|
| 92 |
-
|
| 93 |
-
#
|
| 94 |
-
#
|
| 95 |
-
# 544x544, S = 10693 -> 2.4 s/step
|
| 96 |
-
# 960x544, S = 18870 -> 4.4 s/step
|
| 97 |
-
#
|
| 98 |
-
# through `s = LINEAR * S + QUADRATIC * S**2` — linear for the matmuls, quadratic for the attention. Checked against
|
| 99 |
-
# two live `ref2va` requests on this Space, which is the regime the reference rows actually put it in:
|
| 100 |
-
#
|
| 101 |
-
# one 1344x768 image reference, S ~= 33232 -> 8.3 predicted, ~8.5 measured
|
| 102 |
-
# that image plus a 2.5 s video reference, S ~= 54039 -> 14.6 predicted, ~16.1 measured
|
| 103 |
-
#
|
| 104 |
-
# so the fit holds to about 10% three times past the canvas it was taken from, and `SAFETY` covers the rest.
|
| 105 |
-
STEP_LINEAR, STEP_QUADRATIC, SAFETY = 2.13e-4, 1.069e-9, 1.3
|
| 106 |
-
# The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call. Measured at ~45 s; every request
|
| 107 |
-
# has to carry it, because nothing on this side knows whether the worker it lands on is cold.
|
| 108 |
PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
|
| 109 |
AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
|
| 110 |
REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
def snap_frames(seconds: float) -> int:
|
|
@@ -118,6 +89,13 @@ def snap_frames(seconds: float) -> int:
|
|
| 118 |
return frames
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def video_latent_frames(num_frames: int) -> int:
|
| 122 |
"""`17 * n + 5` frames become `5 * n + 2` video latents."""
|
| 123 |
return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
|
|
@@ -132,10 +110,9 @@ def target_rows(height: int, width: int, num_frames: int) -> int:
|
|
| 132 |
def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
|
| 133 |
"""The rows the reference blocks add, from metadata alone — no decode.
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
to a `17 * n + 5` the VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
|
| 139 |
"""
|
| 140 |
from PIL import Image
|
| 141 |
|
|
@@ -161,7 +138,6 @@ def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
|
|
| 161 |
stream = container.streams.video[0]
|
| 162 |
source_height, source_width = stream.height, stream.width
|
| 163 |
canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
|
| 164 |
-
# Resampled onto 24 fps and capped at the generated length, then snapped down to `17 * n + 5`.
|
| 165 |
frames = min(round(video_seconds * FPS), num_frames)
|
| 166 |
snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
|
| 167 |
rows += (
|
|
@@ -176,22 +152,16 @@ def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
|
|
| 176 |
|
| 177 |
|
| 178 |
def get_duration(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, **_):
|
| 179 |
-
"""Seconds of GPU to reserve for one request
|
| 180 |
-
|
| 181 |
-
Takes the arguments of the `@spaces.GPU` function it decorates — and tolerates the `gr.Progress` `spaces`
|
| 182 |
-
injects — so it can price the request rather than reserve a flat ceiling for all of them.
|
| 183 |
-
|
| 184 |
-
The text rows are exact: `text_token_tags` is the conditioner's own answer, already on this side. The reference
|
| 185 |
-
and target rows come from `reference_rows` and `target_rows`.
|
| 186 |
-
"""
|
| 187 |
sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
|
| 188 |
height, width, num_frames
|
| 189 |
)
|
| 190 |
denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
|
| 191 |
-
# The two reference encoders
|
| 192 |
# they are handed rather than with the step count.
|
| 193 |
encode = 5 + reference_rows(references, num_frames) * 1e-3
|
| 194 |
-
decode =
|
| 195 |
total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10
|
| 196 |
duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
|
| 197 |
print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
|
|
@@ -204,22 +174,16 @@ LOAD_ERROR: str | None = None
|
|
| 204 |
|
| 205 |
|
| 206 |
def load_models() -> str | None:
|
| 207 |
-
"""Load the denoising half
|
| 208 |
-
|
| 209 |
-
`MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`,
|
| 210 |
-
|
| 211 |
-
`
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
Nothing is moved onto the card here, which is the one place this Space departs from the ZeroGPU idiom, and the
|
| 218 |
-
reason is storage rather than memory. `spaces`' startup `torch.pack()` writes every startup-resident CUDA tensor
|
| 219 |
-
to a **second copy on disk** and only deletes the downloaded originals afterwards; 77.3 GB of weights plus a
|
| 220 |
-
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
|
| 221 |
-
space left on device` out of `os.posix_fallocate`. Placement therefore happens on the first GPU call, where it
|
| 222 |
-
costs about 10 s of PCIe and then persists across every later request in the same worker.
|
| 223 |
"""
|
| 224 |
global PIPE, MANAGER, LOAD_ERROR
|
| 225 |
|
|
@@ -233,37 +197,24 @@ def load_models() -> str | None:
|
|
| 233 |
|
| 234 |
from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
|
| 235 |
|
|
|
|
| 236 |
manager = ComponentsManager()
|
| 237 |
blocks = MiniMaxH3Ref2VAGeneratorBlocks()
|
| 238 |
print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
| 239 |
pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
|
| 240 |
pipe.load_components(dtype=torch.bfloat16)
|
| 241 |
|
| 242 |
-
#
|
| 243 |
-
#
|
| 244 |
-
# `
|
| 245 |
-
#
|
| 246 |
-
# stamped then falls through to. Both VAEs carry `AttentionModuleMixin` attention with `_attention_backend =
|
| 247 |
-
# None`, so stamping only the transformer leaves them inheriting cuDNN — and they are float32, for which
|
| 248 |
-
# cuDNN has no kernel:
|
| 249 |
-
#
|
| 250 |
-
# RuntimeError: No available kernel. Aborting execution. # audio_vae pre_block, is_causal=True
|
| 251 |
-
#
|
| 252 |
-
# It is `ref2va` that exposes this. The keyframe half only ever *decodes* audio, and the audio VAE's
|
| 253 |
-
# attention is on its encoder side, so nothing reached it until a reference brought a soundtrack along.
|
| 254 |
-
# Stamping the VAEs first leaves both explicitly on `native`; the transformer then stamps itself and takes
|
| 255 |
-
# the global with it, which no longer matters to anyone.
|
| 256 |
pipe.vae.set_attention_backend("native")
|
| 257 |
pipe.audio_vae.set_attention_backend("native")
|
| 258 |
pipe.transformer_ref.set_attention_backend(ATTENTION)
|
| 259 |
|
| 260 |
-
# Still startup, still free: an AoTI package carries no weights and opens its
|
| 261 |
-
#
|
| 262 |
-
#
|
| 263 |
-
# It is the *same* package the `transformer/` partition runs, `bf16/torch2.11/sm120/dynamic`. Nothing about
|
| 264 |
-
# it is partition-specific: the two `config.json` files are identical field for field, and `LazyAOTIModel`
|
| 265 |
-
# binds each block's own live `state_dict()` by name on its first forward, so the compiled code carries no
|
| 266 |
-
# weights of either partition.
|
| 267 |
import h3_aoti
|
| 268 |
|
| 269 |
h3_aoti.maybe_load(pipe.transformer_ref)
|
|
@@ -286,9 +237,8 @@ def load_models() -> str | None:
|
|
| 286 |
def _arm_decode_hooks(pipe):
|
| 287 |
"""Make the offload hooks fire for the two VAEs.
|
| 288 |
|
| 289 |
-
`enable_auto_cpu_offload`
|
| 290 |
-
|
| 291 |
-
hook never runs and the VAE is still on the host when the latents arrive on the card.
|
| 292 |
"""
|
| 293 |
for name in ("vae", "audio_vae"):
|
| 294 |
module = getattr(pipe, name)
|
|
@@ -306,8 +256,8 @@ def _arm_decode_hooks(pipe):
|
|
| 306 |
|
| 307 |
@cache
|
| 308 |
def conditioner():
|
| 309 |
-
"""The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call,
|
| 310 |
-
|
| 311 |
from gradio_client import Client
|
| 312 |
|
| 313 |
return Client(CONDITIONER_SPACE)
|
|
@@ -331,10 +281,8 @@ def probe(path: str) -> tuple[float | None, float | None]:
|
|
| 331 |
def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
|
| 332 |
"""The `(kind, path)` references of a request, **in the order the model reads them**.
|
| 333 |
|
| 334 |
-
That order
|
| 335 |
-
|
| 336 |
-
request. Images first, then a standalone audio clip, then the video — the order the tabs are laid out in, so
|
| 337 |
-
what the UI shows is what the model is handed.
|
| 338 |
"""
|
| 339 |
ordered = [("image", path) for path in image_paths if path]
|
| 340 |
if audio_path:
|
|
@@ -345,11 +293,8 @@ def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
|
|
| 345 |
|
| 346 |
|
| 347 |
def build_references(references: list[tuple[str, str]]):
|
| 348 |
-
"""The `(kind, path)` references of a request as decoded reference dataclasses, in packed order.
|
| 349 |
-
|
| 350 |
-
One class per modality, each decoding its own file through `from_file`, which brings the rates along: a video its
|
| 351 |
-
own frame rate and its soundtrack, a clip its sample rate. The blocks themselves never open a media file.
|
| 352 |
-
"""
|
| 353 |
from diffusers.modular_pipelines.minimax_h3 import (
|
| 354 |
MiniMaxH3AudioReference,
|
| 355 |
MiniMaxH3ImageReference,
|
|
@@ -373,16 +318,13 @@ def audio_bearing(references: list[tuple[str, str]]) -> list[tuple[str, float]]:
|
|
| 373 |
|
| 374 |
|
| 375 |
def duration_controls(audio_path, video_path, match: bool):
|
| 376 |
-
"""Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out.
|
| 377 |
-
|
| 378 |
-
Only the audio and video slots matter here: an image reference never carries a waveform.
|
| 379 |
-
"""
|
| 380 |
try:
|
| 381 |
carried = audio_bearing(collect([], audio_path, video_path))
|
| 382 |
except Exception:
|
| 383 |
carried = []
|
| 384 |
-
# Exactly one soundtrack,
|
| 385 |
-
#
|
| 386 |
derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
|
| 387 |
return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
|
| 388 |
|
|
@@ -409,16 +351,11 @@ def check(prompt: str, references: list[tuple[str, str]]) -> None:
|
|
| 409 |
|
| 410 |
|
| 411 |
def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
|
| 412 |
-
"""
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
files this Space does, which is what keeps the two `setup` runs in agreement.
|
| 417 |
-
|
| 418 |
-
`rewrite_prompt` asks the conditioner to rewrite the request into MiniMax-H3's trained reference format with its
|
| 419 |
-
own Qwen3-VL, which is shown the references so it can name what each one contributes, and encode that instead. It
|
| 420 |
-
runs on the conditioner's booking, and this call happens before `_generate` books a card here, so `get_duration`
|
| 421 |
-
is unaffected.
|
| 422 |
"""
|
| 423 |
from gradio_client import handle_file
|
| 424 |
from safetensors import safe_open
|
|
@@ -440,18 +377,13 @@ def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
|
|
| 440 |
def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed):
|
| 441 |
"""The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
|
| 442 |
|
| 443 |
-
|
| 444 |
-
process boundary by pickling,
|
| 445 |
-
the
|
| 446 |
-
|
| 447 |
-
Only the three generated outputs come back, for the same reason: the full `PipelineState` still holds the packed
|
| 448 |
-
latents, the rotary grid and the row indices on the card.
|
| 449 |
"""
|
| 450 |
import torch
|
| 451 |
|
| 452 |
if PLACEMENT == "lazy":
|
| 453 |
-
# 72.16 GiB across PCIe on the first request of a worker, a no-op walk on every one after it. Startup
|
| 454 |
-
# placement is not an option here — see `load_models` — and this is what buys the offload-free denoise loop.
|
| 455 |
PIPE.to("cuda")
|
| 456 |
|
| 457 |
state = PIPE(
|
|
@@ -468,9 +400,9 @@ def _generate(prompt_embeds, text_token_tags, references, height, width, num_fra
|
|
| 468 |
|
| 469 |
|
| 470 |
def generate(
|
| 471 |
-
# The first four are the columns `gr.Examples` varies, and they lead the signature for that reason: an example
|
| 472 |
-
#
|
| 473 |
-
#
|
| 474 |
prompt,
|
| 475 |
image_1=None,
|
| 476 |
audio_path=None,
|
|
@@ -491,7 +423,7 @@ def generate(
|
|
| 491 |
upsample=False,
|
| 492 |
progress=gr.Progress(track_tqdm=True),
|
| 493 |
):
|
| 494 |
-
"""One request.
|
| 495 |
if LOAD_ERROR:
|
| 496 |
raise gr.Error(LOAD_ERROR)
|
| 497 |
if PIPE is None:
|
|
@@ -518,7 +450,7 @@ def generate(
|
|
| 518 |
raise
|
| 519 |
except Exception as error:
|
| 520 |
# gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
|
| 521 |
-
# that Space's logs.
|
| 522 |
traceback.print_exc()
|
| 523 |
raise gr.Error(
|
| 524 |
f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
|
|
@@ -557,8 +489,8 @@ INTRO = """# MiniMax-H3 Reference
|
|
| 557 |
|
| 558 |
<div align="center">
|
| 559 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a>
|
| 560 |
-
<a href="
|
| 561 |
-
<a href="https://
|
| 562 |
</div>
|
| 563 |
|
| 564 |
**MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
|
|
@@ -582,20 +514,19 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
|
|
| 582 |
value="The character walks through a neon-lit street in the rain, humming to themselves",
|
| 583 |
)
|
| 584 |
upsample = gr.Checkbox(label="Upsample prompt", value=False)
|
| 585 |
-
# One tab per modality, in the order the model reads them. A reference left in a tab that is not the
|
| 586 |
-
#
|
| 587 |
with gr.Tabs():
|
| 588 |
with gr.Tab("Images"):
|
| 589 |
-
# One `gr.Row`, so gradio splits the width evenly and wraps
|
| 590 |
-
#
|
| 591 |
with gr.Row():
|
| 592 |
images = [
|
| 593 |
gr.Image(
|
| 594 |
label="Subject, style or scene",
|
| 595 |
type="filepath",
|
| 596 |
min_width=180,
|
| 597 |
-
# Fixed, so a row that wraps to a single slot stays the
|
| 598 |
-
# instead of stretching to the width of the column.
|
| 599 |
height=210,
|
| 600 |
visible=index < OPEN_IMAGE_SLOTS,
|
| 601 |
)
|
|
@@ -639,8 +570,7 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
|
|
| 639 |
duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
|
| 640 |
)
|
| 641 |
|
| 642 |
-
# Same order as `generate`'s signature: the exampled five first, then the remaining image slots.
|
| 643 |
-
# last and defaults off, so a positional API client that predates it is unaffected.
|
| 644 |
request = [prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample]
|
| 645 |
|
| 646 |
gr.Examples(
|
|
|
|
| 1 |
+
"""MiniMax-H3 `ref2va`, split deployment — the denoising half.
|
| 2 |
|
| 3 |
+
This Space holds the `transformer_ref` partition and the two autoencoders, unquantized bfloat16. Text encoding runs in
|
| 4 |
+
[`qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner), which this one calls over the
|
| 5 |
+
gradio API for every request; `reference_encoder` stays here, next to the autoencoders it runs.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
|
|
| 13 |
import traceback
|
| 14 |
from functools import cache
|
| 15 |
|
| 16 |
+
# Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
|
| 17 |
+
# startup rather than on GPU time.
|
| 18 |
import spaces
|
| 19 |
import gradio as gr
|
| 20 |
|
| 21 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 22 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 23 |
# `lazy` moves all 72.16 GiB onto the card on the first GPU call and leaves it there; `offload` hands placement to
|
| 24 |
+
# `ComponentsManager.enable_auto_cpu_offload`. Startup placement is not an option here — see `load_models`.
|
|
|
|
| 25 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "lazy").lower()
|
| 26 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 27 |
# flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
|
| 28 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
| 29 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
| 30 |
+
# Bounds on what `get_duration` may reserve. The pool reserves whatever number it is given, so a flat ceiling for every
|
| 31 |
+
# request is what makes an account hit "too many ZeroGPU credits allocated to running tasks".
|
|
|
|
| 32 |
MIN_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MIN", "120"))
|
| 33 |
MAX_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MAX", "1500"))
|
| 34 |
|
| 35 |
+
# Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
|
| 36 |
+
# is rejected there and surfaces as a failure here.
|
|
|
|
|
|
|
|
|
|
| 37 |
CANVASES = {
|
| 38 |
# 16:9
|
| 39 |
"960x544 · 16:9 fast": (544, 960),
|
|
|
|
| 58 |
"1536x672 · 21:9 full": (672, 1536),
|
| 59 |
}
|
| 60 |
DEFAULT_CANVAS = "960x544 · 16:9 fast"
|
|
|
|
|
|
|
| 61 |
EXAMPLE_CANVAS, EXAMPLE_PORTRAIT_CANVAS = "1344x768 · 16:9 full", "768x1024 · 3:4 full"
|
| 62 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 63 |
+
# It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
|
| 64 |
+
# 15.083 s, and is refused. 14 is the last whole second that survives the snap.
|
| 65 |
MAX_UI_DURATION = 14
|
| 66 |
+
MIN_DURATION = 2
|
| 67 |
+
# A reference video shorter than 2 s gives the model almost no motion to read.
|
| 68 |
MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
|
| 69 |
+
# `MINIMAX_H3_MAX_REFERENCE_IMAGES`. The slots are built up front and revealed one at a time, because a demo asking
|
| 70 |
+
# for two subjects should not open with nine boxes.
|
| 71 |
MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
|
| 72 |
|
| 73 |
+
# Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
|
| 74 |
+
# matmuls, quadratic for the attention, against the AoTI block package this Space runs.
|
| 75 |
+
STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
|
| 76 |
+
# The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call; every request carries it, because
|
| 77 |
+
# nothing here knows whether the worker it lands on is cold.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
|
| 79 |
AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
|
| 80 |
REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
|
| 81 |
+
DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
|
| 82 |
|
| 83 |
|
| 84 |
def snap_frames(seconds: float) -> int:
|
|
|
|
| 89 |
return frames
|
| 90 |
|
| 91 |
|
| 92 |
+
def lower_duration_floor(seconds: float = MIN_DURATION) -> None:
|
| 93 |
+
"""Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
|
| 94 |
+
from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
|
| 95 |
+
|
| 96 |
+
MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
def video_latent_frames(num_frames: int) -> int:
|
| 100 |
"""`17 * n + 5` frames become `5 * n + 2` video latents."""
|
| 101 |
return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
|
|
|
|
| 110 |
def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
|
| 111 |
"""The rows the reference blocks add, from metadata alone — no decode.
|
| 112 |
|
| 113 |
+
An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on the canvas *its
|
| 114 |
+
own* aspect ratio resolves to, truncated to the generated frame count and snapped **down** to a `17 * n + 5` the
|
| 115 |
+
VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
|
|
|
|
| 116 |
"""
|
| 117 |
from PIL import Image
|
| 118 |
|
|
|
|
| 138 |
stream = container.streams.video[0]
|
| 139 |
source_height, source_width = stream.height, stream.width
|
| 140 |
canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
|
|
|
|
| 141 |
frames = min(round(video_seconds * FPS), num_frames)
|
| 142 |
snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
|
| 143 |
rows += (
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
def get_duration(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, **_):
|
| 155 |
+
"""Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
|
| 156 |
+
tolerates the `gr.Progress` `spaces` injects."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
|
| 158 |
height, width, num_frames
|
| 159 |
)
|
| 160 |
denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
|
| 161 |
+
# The two reference encoders ahead of the loop, and the two decoders plus the mux after it. Both scale with what
|
| 162 |
# they are handed rather than with the step count.
|
| 163 |
encode = 5 + reference_rows(references, num_frames) * 1e-3
|
| 164 |
+
decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
|
| 165 |
total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10
|
| 166 |
duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
|
| 167 |
print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
def load_models() -> str | None:
|
| 177 |
+
"""Load the denoising half at startup, but *not* onto the card.
|
| 178 |
+
|
| 179 |
+
`MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, the two schedulers and
|
| 180 |
+
`video_processor`, so `load_components` fetches exactly those subfolders — `text_encoder/` and the `transformer/`
|
| 181 |
+
partition are never touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a
|
| 182 |
+
bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
|
| 183 |
+
|
| 184 |
+
Nothing moves onto the card here, for storage rather than memory: `spaces`' startup `torch.pack()` writes every
|
| 185 |
+
startup-resident CUDA tensor to a second copy on disk, and 77.3 GB of weights plus its pack busts the 150 GB quota
|
| 186 |
+
(`OSError: [Errno 28] No space left on device` out of `os.posix_fallocate`, mid-pack).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
"""
|
| 188 |
global PIPE, MANAGER, LOAD_ERROR
|
| 189 |
|
|
|
|
| 197 |
|
| 198 |
from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
|
| 199 |
|
| 200 |
+
lower_duration_floor()
|
| 201 |
manager = ComponentsManager()
|
| 202 |
blocks = MiniMaxH3Ref2VAGeneratorBlocks()
|
| 203 |
print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
| 204 |
pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
|
| 205 |
pipe.load_components(dtype=torch.bfloat16)
|
| 206 |
|
| 207 |
+
# Both VAEs first, and explicitly. `set_attention_backend` also sets the registry's *global* backend, which
|
| 208 |
+
# every processor that was not stamped falls through to, and the float32 audio VAE has no cuDNN kernel:
|
| 209 |
+
# `RuntimeError: No available kernel. Aborting execution.` in its causal encoder attention, which only a
|
| 210 |
+
# reference soundtrack ever reaches.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
pipe.vae.set_attention_backend("native")
|
| 212 |
pipe.audio_vae.set_attention_backend("native")
|
| 213 |
pipe.transformer_ref.set_attention_backend(ATTENTION)
|
| 214 |
|
| 215 |
+
# Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
|
| 216 |
+
# worker. Off unless `H3_AOTI=1`. It is the *same* package the `transformer/` partition runs — the two configs
|
| 217 |
+
# are identical field for field and the compiled code carries no weights of either.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
import h3_aoti
|
| 219 |
|
| 220 |
h3_aoti.maybe_load(pipe.transformer_ref)
|
|
|
|
| 237 |
def _arm_decode_hooks(pipe):
|
| 238 |
"""Make the offload hooks fire for the two VAEs.
|
| 239 |
|
| 240 |
+
`enable_auto_cpu_offload` wraps `forward`, and the reference-encoder and decode blocks call `vae.encode/decode(...)`
|
| 241 |
+
directly, so the hook never runs and the VAE is still on the host when the latents arrive on the card.
|
|
|
|
| 242 |
"""
|
| 243 |
for name in ("vae", "audio_vae"):
|
| 244 |
module = getattr(pipe, name)
|
|
|
|
| 256 |
|
| 257 |
@cache
|
| 258 |
def conditioner():
|
| 259 |
+
"""The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
|
| 260 |
+
conditioner's booking is billed to whoever asked for the video."""
|
| 261 |
from gradio_client import Client
|
| 262 |
|
| 263 |
return Client(CONDITIONER_SPACE)
|
|
|
|
| 281 |
def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
|
| 282 |
"""The `(kind, path)` references of a request, **in the order the model reads them**.
|
| 283 |
|
| 284 |
+
That order numbers the labels of MiniMax-H3's prompt presentation and advances the shared audio/video rotary clock,
|
| 285 |
+
so the same references in a different order are a different request.
|
|
|
|
|
|
|
| 286 |
"""
|
| 287 |
ordered = [("image", path) for path in image_paths if path]
|
| 288 |
if audio_path:
|
|
|
|
| 293 |
|
| 294 |
|
| 295 |
def build_references(references: list[tuple[str, str]]):
|
| 296 |
+
"""The `(kind, path)` references of a request as decoded reference dataclasses, in packed order. `from_file` brings
|
| 297 |
+
the rates along: a video its own frame rate and soundtrack, a clip its sample rate."""
|
|
|
|
|
|
|
|
|
|
| 298 |
from diffusers.modular_pipelines.minimax_h3 import (
|
| 299 |
MiniMaxH3AudioReference,
|
| 300 |
MiniMaxH3ImageReference,
|
|
|
|
| 318 |
|
| 319 |
|
| 320 |
def duration_controls(audio_path, video_path, match: bool):
|
| 321 |
+
"""Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out."""
|
|
|
|
|
|
|
|
|
|
| 322 |
try:
|
| 323 |
carried = audio_bearing(collect([], audio_path, video_path))
|
| 324 |
except Exception:
|
| 325 |
carried = []
|
| 326 |
+
# Exactly one soundtrack, long enough to be a duration MiniMax-H3 generates; anything else is ambiguous or out of
|
| 327 |
+
# range and the slider stays.
|
| 328 |
derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
|
| 329 |
return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
|
| 330 |
|
|
|
|
| 351 |
|
| 352 |
|
| 353 |
def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
|
| 354 |
+
"""`/encode_ref2va` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with
|
| 355 |
+
the resolved `height` / `width` / `num_frames` in its metadata, plus the plan.
|
| 356 |
|
| 357 |
+
`canvas` is the label. `media` and `kinds` are parallel and ordered, and the references go over because `ref2va`'s
|
| 358 |
+
presentation puts a vision block in front of the prompt for every image and every merged video frame pair.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
"""
|
| 360 |
from gradio_client import handle_file
|
| 361 |
from safetensors import safe_open
|
|
|
|
| 377 |
def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed):
|
| 378 |
"""The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
|
| 379 |
|
| 380 |
+
References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
|
| 381 |
+
argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
|
| 382 |
+
the full `PipelineState` still holds the packed latents and the rotary grid on the card.
|
|
|
|
|
|
|
|
|
|
| 383 |
"""
|
| 384 |
import torch
|
| 385 |
|
| 386 |
if PLACEMENT == "lazy":
|
|
|
|
|
|
|
| 387 |
PIPE.to("cuda")
|
| 388 |
|
| 389 |
state = PIPE(
|
|
|
|
| 400 |
|
| 401 |
|
| 402 |
def generate(
|
| 403 |
+
# The first four are the columns `gr.Examples` varies, and they lead the signature for that reason: an example row
|
| 404 |
+
# is applied to `inputs` positionally. Every parameter has a default, which is what lets a four-column row call
|
| 405 |
+
# this at all, and `upsample` is last so a positional API client that predates it is unaffected.
|
| 406 |
prompt,
|
| 407 |
image_1=None,
|
| 408 |
audio_path=None,
|
|
|
|
| 423 |
upsample=False,
|
| 424 |
progress=gr.Progress(track_tqdm=True),
|
| 425 |
):
|
| 426 |
+
"""One request."""
|
| 427 |
if LOAD_ERROR:
|
| 428 |
raise gr.Error(LOAD_ERROR)
|
| 429 |
if PIPE is None:
|
|
|
|
| 450 |
raise
|
| 451 |
except Exception as error:
|
| 452 |
# gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
|
| 453 |
+
# that Space's logs.
|
| 454 |
traceback.print_exc()
|
| 455 |
raise gr.Error(
|
| 456 |
f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
|
|
|
|
| 489 |
|
| 490 |
<div align="center">
|
| 491 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a>
|
| 492 |
+
<a href="https://www.minimax.io/blog/minimax-h3"><strong>[ blog ]</strong></a>
|
| 493 |
+
<a href="https://huggingface.co/spaces/multimodalart/minimax-h3"><strong>[ text / image to video ]</strong></a>
|
| 494 |
</div>
|
| 495 |
|
| 496 |
**MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
|
|
|
|
| 514 |
value="The character walks through a neon-lit street in the rain, humming to themselves",
|
| 515 |
)
|
| 516 |
upsample = gr.Checkbox(label="Upsample prompt", value=False)
|
| 517 |
+
# One tab per modality, in the order the model reads them. A reference left in a tab that is not the open
|
| 518 |
+
# one is still part of the request.
|
| 519 |
with gr.Tabs():
|
| 520 |
with gr.Tab("Images"):
|
| 521 |
+
# One `gr.Row`, so gradio splits the width evenly and wraps at `min_width` rather than leaving a
|
| 522 |
+
# hole where a hidden slot used to be.
|
| 523 |
with gr.Row():
|
| 524 |
images = [
|
| 525 |
gr.Image(
|
| 526 |
label="Subject, style or scene",
|
| 527 |
type="filepath",
|
| 528 |
min_width=180,
|
| 529 |
+
# Fixed, so a row that wraps to a single slot stays the size of a full one.
|
|
|
|
| 530 |
height=210,
|
| 531 |
visible=index < OPEN_IMAGE_SLOTS,
|
| 532 |
)
|
|
|
|
| 570 |
duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
|
| 571 |
)
|
| 572 |
|
| 573 |
+
# Same order as `generate`'s signature: the exampled five first, then the remaining image slots.
|
|
|
|
| 574 |
request = [prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample]
|
| 575 |
|
| 576 |
gr.Examples(
|
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,27 +5,23 @@
|
|
| 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 |
-
# A reference soundtrack that is not already at the audio VAE's 32 kHz is resampled with `torchaudio`
|
| 15 |
-
#
|
| 16 |
-
# resample entirely. Both halves need it: the conditioner's `setup` step normalizes the same waveforms this one does.
|
| 17 |
torchaudio==2.11.0
|
| 18 |
-
#
|
| 19 |
-
# patch count, so a different minor changes the conditioning.
|
| 20 |
transformers==5.8.0
|
| 21 |
accelerate==1.14.0
|
| 22 |
-
# diffusers pins <2
|
| 23 |
huggingface-hub==1.24.0
|
| 24 |
gradio==6.20.0
|
| 25 |
spaces==0.51.1
|
| 26 |
# No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
|
| 27 |
# transformers 5.8.0 at import.
|
| 28 |
-
# PyAV
|
| 29 |
av
|
| 30 |
pillow
|
| 31 |
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 |
+
# A reference soundtrack that is not already at the audio VAE's 32 kHz is resampled with `torchaudio`; a 32 kHz one
|
| 13 |
+
# skips the resample entirely, so this is easy to miss.
|
|
|
|
| 14 |
torchaudio==2.11.0
|
| 15 |
+
# The Qwen3-VL processor decides the vision 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 decodes the reference media (`MiniMaxH3VideoReference.from_file`).
|
| 25 |
av
|
| 26 |
pillow
|
| 27 |
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)}
|