multimodalart HF Staff commited on
Commit
92693c1
·
1 Parent(s): 74a631a

Price bookings against the AoTI blocks, generate from 2 s again, cross-link the demos

Browse files

get_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 ADDED
Binary file (22.1 kB). View file
 
app.py CHANGED
@@ -1,18 +1,8 @@
1
- """MiniMax-H3 `ref2va`, split deployment — **the denoising half**.
2
 
3
- This Space holds the `transformer_ref` partition of the checkpoint and the two autoencoders, **unquantized
4
- bfloat16**, and nothing else. The 62.14 GiB Qwen3-VL conditioner lives in its own Space,
5
- [`qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner), which this
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
- # First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
27
- # 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
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` instead. Neither puts anything on the card at *startup*, which is
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 ask the pool to reserve. A request that runs out of GPU time is a total loss, so
42
- # the estimate is deliberately generous but a *fixed* 900 s ceiling for every request is what makes the account hit
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
- # MiniMax-H3's own canvases, i.e. `resolve_canvas_size` from
48
- # `diffusers.modular_pipelines.minimax_h3.modular_pipeline` evaluated for the six released aspect ratios. Hardcoded
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
- # 15 s is the checkpoint's ceiling, but it is the *snapped* frame count that has to hold for it: 15 s is 360 frames,
81
- # which rounds up to 362, i.e. 15.083 s, and is refused. 14 is the last whole second that survives the snap.
82
  MAX_UI_DURATION = 14
83
- MIN_DURATION = 5
84
- # A reference video shorter than 2 s gives the model almost no motion to read, and 15 s is the checkpoint's ceiling.
85
  MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
86
- # `MINIMAX_H3_MAX_REFERENCE_IMAGES`, hardcoded so the UI renders before `diffusers` is importable. The slots are all
87
- # built up front and revealed one at a time, because a demo asking for two subjects should not open with nine boxes.
88
  MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
89
 
90
- # --- What a request costs, for `get_duration` -------------------------------------------------------------------
91
- #
92
- # MiniMax-H3 attends over one packed sequence, so the cost of a step is a function of its length `S` alone. Fitted
93
- # on the `t2va` half (`minimax-h3`, AoTI on, same pool and same silicon) over two canvases at 124 frames:
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
- This mirrors what `MiniMaxH3Ref2VASetupStep` and the reference encoder will do, closely enough to size a GPU
136
- reservation with. An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on
137
- the canvas *its own* aspect ratio resolves to, truncated to the generated frame count and then snapped **down**
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, from the packed sequence it is about to build.
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, ahead of the loop, and the two decoders plus the mux after it. Both scale with what
192
  # they are handed rather than with the step count.
193
  encode = 5 + reference_rows(references, num_frames) * 1e-3
194
- decode = 15 + 25 * (height * width * num_frames) / (960 * 544 * 124)
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. At **startup**, but *not* onto the card.
208
-
209
- `MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, `scheduler`, `audio_scheduler`
210
- and `video_processor` as its pretrained components (plus an `image_processor` built from config), so
211
- `load_components` fetches exactly those subfolders out of the shared
212
- `modular_model_index.json` `text_encoder/` and the `transformer/` partition are never touched.
213
-
214
- Both autoencoders carry `_keep_in_fp32_modules` over every module, so the `dtype` below is refused for them and
215
- they stay float32: a bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
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
- # Pin the two autoencoders to torch SDPA *before* the transformer takes cuDNN, and in that order.
243
- #
244
- # `set_attention_backend` does two things: it stamps the backend onto every attention processor of the model
245
- # it is called on, and it sets the registry's **global** active backend, which every processor that was not
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 compiled archive lazily inside
261
- # the GPU worker, so pointing the 50-block stack at it is CPU work. Off unless `H3_AOTI=1`.
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` installs accelerate hooks, which wrap `forward`. The reference-encoder and decode
290
- blocks call `components.vae.encode/decode(...)` and `components.audio_vae.encode/decode(...)` directly, so the
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, off
310
- gradio's `LocalContext`, so the conditioner's booking is billed to the user who asked for the video."""
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 is semantic rather than cosmetic: it numbers the labels of MiniMax-H3's prompt presentation and it
335
- advances the shared audio/video rotary clock, so the same references in a different order are a different
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, and one long enough to be a duration MiniMax-H3 generates. Anything else and the
385
- # request is ambiguous or out of range, so the slider stays and nothing is derived.
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
- """Ask the conditioner Space for `prompt_embeds` + `text_token_tags`. Off this Space's GPU time entirely.
 
413
 
414
- The references go over with the request: `ref2va`'s presentation puts a vision block in front of the prompt for
415
- every image and every merged video frame pair, so the conditioner has to see them. It decodes the very same
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
- The references are built here rather than handed over already decoded. A `@spaces.GPU` argument crosses a
444
- process boundary by pickling, and a 5 s 1344x768 reference video is 370 MB of frames once PyAV has expanded it;
445
- the file path is a few bytes and the decode is CPU work either way.
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
- # row is applied to `inputs` positionally, so the exampled components have to be the leading parameters. Every
473
- # parameter has a default, which is what lets a four-column row call this at all.
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. `upsample` is appended last and defaults off, so an existing API client is untouched by it."""
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. Print what did come back here, name the Space, and say where the rest of it is.
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> &nbsp;
560
- <a href="PAPER_URL_PLACEHOLDER"><strong>[ paper ]</strong></a> &nbsp;
561
- <a href="https://www.minimax.io"><strong>[ project ]</strong></a>
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
- # open one is still part of the request — the tabs lay the slots out, they do not choose between them.
587
  with gr.Tabs():
588
  with gr.Tab("Images"):
589
- # One `gr.Row`, so gradio splits the width evenly and wraps once the slots hit `min_width`
590
- # rather than leaving a hole where a hidden slot used to be.
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 same size as a full one
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. `upsample` is
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> &nbsp;
492
+ <a href="https://www.minimax.io/blog/minimax-h3"><strong>[ blog ]</strong></a> &nbsp;
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: compile the repeated transformer block once, reuse the package forever.
2
-
3
- Shared byte-identically by every MiniMax-H3 Space. A Space only ever calls `maybe_load()`; the compile path runs from
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
- # `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
70
- # a dynamic sequence dimension exports and compiles cleanly (measured on an rtx-pro-6000 Job, torch 2.11). It has to be
71
- # dynamic to be useful at all — `build_packed_sequence` pads nothing, so
72
- # `S = num_text_tokens + condition_rows + audio_rows + video_rows` moves with the *prompt* as well as the canvas, and a
73
- # static package would only ever serve the one prompt length it was captured from.
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
- # The 50-deep stack that is the whole cost of a step. `MiniMaxH3TokenRefinerBlock` is also in `_repeated_blocks` but
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` is `(num_distinct_timesteps, time_embed_dim)` and the block
89
- # gathers from a `3 * num_distinct_timesteps` table with `adaln_indices = timestep_indices * 3 + token_tag`, so the
90
- # table's height is part of the compiled shape. It is not constant at runtime: at step 0 the video and audio streams
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>` an AoTI package is valid for exactly one of each."""
144
- import torch
 
145
 
146
- torch_version = ".".join(torch.__version__.split(".")[:2])
147
- major, minor = torch.cuda.get_device_capability()
 
 
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
- This is `spaces.aoti_load_from_package_dir` with two changes, both forced by how this Space runs.
163
-
164
- *Weights are read on the first forward, not at patch time.* `spaces.aoti_patch` snapshots `state_dict()` when it
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 intersecting `state_dict()` with `get_constant_fqns()` and
183
- # silently keeps whatever it does not match, so a package whose constants were lifted anonymously
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. Once, and safe to call at **startup**.
214
 
215
- Nothing here touches a GPU: the download is CPU work and the `.pt2` archive is not opened until the first forward,
216
- which happens inside the `@spaces.GPU` call. Proven on the pool: `bf16/torch2.11/sm120/dynamic` loads at startup,
217
- patches all 50 blocks, and generates. The repo is public, so no token is passed for it.
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
- local = snapshot_download(
228
- repo_id=AOTI_REPO,
229
- repo_type=AOTI_REPO_TYPE,
230
- allow_patterns=f"{key}/package/*",
231
- )
232
  package_dir = Path(local) / key / "package"
233
  if not package_dir.is_dir():
234
- raise RuntimeError(
235
- f"No AoTI package at `{AOTI_REPO}:{key}/package`. Run the debug Space's Compile (AoTI) tab on this card "
236
- f"with this `H3_QUANT`, or set `H3_AOTI=0`."
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: the export traces the quantized module,
247
- and a package compiled for one quantization mode is meaningless for another.
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
- # Record every call the block receives over a short real run and keep the widest `temb`, rather than
258
- # `spaces.aoti_capture`'s first-call-then-abort. The first call is the unrepresentative one: see `TEMB_ROWS`.
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
- # hidden_states (1, S, hidden)
290
- # temb (num_distinct_timesteps, time_embed_dim)
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
- # WHICH MODULE, AND WHICH EXPORT MODE. This is the whole difference between a working package and a SIGSEGV.
309
- #
310
- # `spaces.aoti_patch` prepares the load side by shallow-cloning the module and flattening any tensor subclass, and
311
- # the received wisdom is to do the identical thing before exporting so both sides derive the same constant FQNs.
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 tensor
348
- # reached through a plain attribute becomes an anonymous constant the loader can never match against
349
- # `state_dict()`. Re-registering such tensors as buffers is numerics-preserving it changes how a tensor is
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 this package will not bind them; `compile_and_save` writes the alias sidecar and "
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: it resolves the submodule name to the transformer's
391
- `transformer_blocks` `ModuleList` and, because a `ModuleList` is iterable, patches every one of the 50 blocks with
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 keeps a constant's dtype, shape and slot index but drops its FQN when the
401
- # export lifted it anonymously. The `ExportedProgram` still has the real names, so record the
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 `<quant>/torch<X.Y>/sm<cc>/<shape>` key. CPU work — never inside GPU time."""
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 modular-only, and the whole model is one `MiniMaxH3Blocks` sequence whose branches are picked per
4
- request and per `workflow=` from the inputs:
5
-
6
- before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
7
-
8
- where `before_encode`, `text_encoder`, `vae_encoder` and `denoise` each switch on `references` (the `ref2va` workflow)
9
- versus the keyframe inputs (`t2va` / `fl2va`), and `denoise` is itself `prepare_layout -> prepare_latents ->
10
- set_timesteps -> denoise` against `transformer` or `transformer_ref`.
11
-
12
- The conditioner (a 62.14 GiB Qwen3-VL) and the denoiser (a 61.73 GiB transformer plus ~20.5 GiB of float32 VAEs) do
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, plus the plan the caller pins on the generating half.
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` `text_encoder`, `tokenizer`, `processor` which
125
- is what lets one conditioner Space serve both partitions of the checkpoint out of the weights it already holds.
126
- What differs is the presentation the Qwen3-VL is shown: `ref2va` prepends a label per reference, numbered per
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
- Only the text-encoder step is dropped. `reference_encoder` is this half's own encoder it runs the video VAE
153
- over the image and video references and the audio VAE over the soundtracks, and its output shapes are where every
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`, which is the
15
- # only thing in the `ref2va` path that needs it — and it is easy to miss, because a 32 kHz reference skips the
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
- # Pinned to the version the MiniMax-H3 parity work was verified against: the Qwen3-VL processor decides the vision
19
- # patch count, so a different minor changes the conditioning.
20
  transformers==5.8.0
21
  accelerate==1.14.0
22
- # diffusers pins <2; 1.24.0 is the version the parity work ran on.
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: decoding the reference media, i.e. `MiniMaxH3VideoReference.from_file` / `MiniMaxH3AudioReference.from_file`.
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
- Problem
4
- -------
5
- `spaces.zero.torch.aoti.LazyAOTIModel` binds a compiled package's constants **by name**::
 
6
 
7
- constant_fqns = compiled_model.get_constant_fqns()
8
- constant_map = {name: tensor for name, tensor in weights.items() if name in constant_fqns}
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
- Model-agnostic and numerics-preserving: it changes how a tensor is *registered*, never the tensor
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 `_tensor_constant<N>` slots in the order the `CONSTANT_TENSOR` inputs
82
- appear in the export graph signature, and the signature still carries each one's real FQN.
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 explain why it cannot."""
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
- # 2. otherwise match by dtype+shape against the state_dict entries nobody claimed, preserving
157
- # each side's own order inside a (dtype, shape) group. `get_constant_fqns()` returns the
158
- # slots in *lexicographic* order (`_tensor_constant10` before `_tensor_constant2`), so the
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)}