multimodalart HF Staff commited on
Commit
3a016a8
·
1 Parent(s): c4170c1

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 (14.3 kB). View file
 
__pycache__/h3_aoti.cpython-310.pyc ADDED
Binary file (11.1 kB). View file
 
__pycache__/h3_split_blocks.cpython-310.pyc ADDED
Binary file (6.82 kB). View file
 
__pycache__/spaces_constant_binding_patch.cpython-310.pyc ADDED
Binary file (8.67 kB). View file
 
app.py CHANGED
@@ -1,5 +1,4 @@
1
- """MiniMax-H3, split deployment — denoising
2
- """
3
 
4
  from __future__ import annotations
5
 
@@ -9,23 +8,22 @@ import time
9
  import traceback
10
  from functools import cache
11
 
12
- # First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
13
- # 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
14
  import spaces
15
  import gradio as gr
16
 
17
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
18
  CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
19
- # `lazy` moves all 72.16 GiB onto the card on the first GPU call and leaves it there; `offload` hands placement to
20
- # `ComponentsManager.enable_auto_cpu_offload` instead. Neither puts anything on the card at *startup*, which is
21
- # deliberate — see `load_models`: the 150 GB storage quota, not the 95 GiB card, is what rules that out here.
22
  PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
23
  # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
24
  ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
25
- GPU_DURATION = int(os.environ.get("H3_GPU_DURATION", "900"))
26
  GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
27
- ON_SPACES = bool(os.environ.get("SPACE_ID"))
28
 
 
 
29
  CANVASES = {
30
  # 16:9
31
  "960x544 · 16:9 fast": (544, 960),
@@ -51,9 +49,9 @@ CANVASES = {
51
  }
52
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
53
  FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
54
- # MiniMax-H3 generates 5 to 15 seconds, and it is the snapped frame count that has to hold for the ceiling: 15 s is
55
- # 360 frames, which rounds up to 362, i.e. 15.083 s, and is refused.
56
- MIN_UI_DURATION, MAX_UI_DURATION = 5, 14
57
 
58
 
59
  def snap_frames(seconds: float) -> int:
@@ -64,6 +62,13 @@ def snap_frames(seconds: float) -> int:
64
  return frames
65
 
66
 
 
 
 
 
 
 
 
67
  PIPE = None
68
  MANAGER = None
69
  LOAD_ERROR: str | None = None
@@ -84,24 +89,12 @@ def status() -> str:
84
 
85
 
86
  def load_models() -> str | None:
87
- """Load the denoising half. At **startup**, but *not* onto the card.
88
-
89
- `MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, `scheduler`, `audio_scheduler` and
90
- `video_processor` as its pretrained components (plus an `image_processor` built from config), so
91
- `load_components` fetches exactly those subfolders out of the shared `modular_model_index.json`
92
- `text_encoder/` and `transformer_ref/` are never touched.
93
-
94
- Both autoencoders carry `_keep_in_fp32_modules` over every module, so the `dtype` below is refused for them and
95
- they stay float32: a bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
96
-
97
- Nothing is moved onto the card here, which is the one place this Space departs from the ZeroGPU idiom, and the
98
- reason is storage rather than memory. `spaces`' startup `torch.pack()` writes every startup-resident CUDA tensor
99
- to a **second copy on disk** and only deletes the downloaded originals afterwards; 77.3 GB of weights plus a
100
- 77.3 GB pack is 154.6 GB against a 150 GB quota, and the Space is evicted mid-pack with `OSError: [Errno 28] No
101
- space left on device` out of `os.posix_fallocate`. Deleting the shards first does not help either: the pack's own
102
- cleanup walks the still-open mappings and `lstat`s them, so an unlinked blob turns into `FileNotFoundError:
103
- ... (deleted)`. Placement therefore happens on the first GPU call, where it costs about 10 s of PCIe and then
104
- persists across every later request in the same worker.
105
  """
106
  global PIPE, MANAGER, LOAD_ERROR, LOADED_IN
107
 
@@ -115,6 +108,7 @@ def load_models() -> str | None:
115
 
116
  from h3_split_blocks import MiniMaxH3GeneratorBlocks
117
 
 
118
  manager = ComponentsManager()
119
  blocks = MiniMaxH3GeneratorBlocks()
120
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
@@ -122,19 +116,16 @@ def load_models() -> str | None:
122
  pipe.load_components(dtype=torch.bfloat16)
123
  pipe.transformer.set_attention_backend(ATTENTION)
124
 
125
- # Still startup, still free: an AoTI package carries no weights and opens its compiled archive lazily inside
126
- # the GPU worker, so pointing the 50-block stack at it is CPU work. Off unless `H3_AOTI=1`.
127
  import h3_aoti
128
 
129
  h3_aoti.maybe_load(pipe.transformer)
130
 
131
  if PLACEMENT == "pack":
132
- # Idiomatic ZeroGPU startup placement, scoped to the transformer only. `spaces` packs every
133
- # startup-resident CUDA tensor into a second on-disk copy; packing all 77.3 GB (transformer + fp32
134
- # VAEs) busts the 150 GB storage quota (77.3 + 77.3 + shards), but the 61.7 GB transformer alone
135
- # packs to ~123 GB total and fits. The VAEs (~10 GB) take the lazy path on first GPU call, ~2 s.
136
- # With AoTI the packed transformer pairs with the precompiled blocks: no placement, no compile,
137
- # first request runs at steady state.
138
  pipe.transformer.to("cuda")
139
 
140
  if PLACEMENT == "offload":
@@ -153,9 +144,8 @@ def load_models() -> str | None:
153
  def _arm_decode_hooks(pipe):
154
  """Make the offload hooks fire for the two VAEs.
155
 
156
- `enable_auto_cpu_offload` installs accelerate hooks, which wrap `forward`. The decode blocks call
157
- `components.vae.decode(...)` and `components.audio_vae.decode(...)` directly, so the hook never runs and the VAE
158
- is still on the host when the latents arrive on the card.
159
  """
160
  for name in ("vae", "audio_vae"):
161
  module = getattr(pipe, name)
@@ -172,20 +162,16 @@ def _arm_decode_hooks(pipe):
172
 
173
  @cache
174
  def conditioner():
175
- """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, off
176
- gradio's `LocalContext`, so the conditioner's booking is billed to the user who asked for the video."""
177
  from gradio_client import Client
178
 
179
  return Client(CONDITIONER_SPACE)
180
 
181
 
182
  def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
183
- """Ask the conditioner Space for `prompt_embeds` + `text_token_tags`. Off this Space's GPU time entirely.
184
-
185
- `rewrite_prompt` asks the conditioner to rewrite the request into MiniMax-H3's trained format with its own
186
- Qwen3-VL and encode that, handing the rewrite back under the plan's `refined_prompt`. It runs on the conditioner's
187
- booking, and this call happens before `_generate` books a card here, so `get_duration` is unaffected.
188
- """
189
  from gradio_client import handle_file
190
  from safetensors import safe_open
191
 
@@ -203,37 +189,37 @@ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewri
203
  return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
204
 
205
 
206
- # Fitted on live probes (5 configs spanning canvas, duration and steps; max residual 3.7 s):
207
- # gpu_seconds = A + B * steps * tokens + C * steps * tokens^2, where tokens is the packed video row count.
208
- # PLACEMENT_ALLOWANCE covers the one-time 72 GiB lazy .to("cuda") a cold worker pays inside its first call.
209
- _DUR_A, _DUR_B, _DUR_C = -6.023, 2.0877e-4, 2.1221e-9
210
- _PLACEMENT_ALLOWANCE, _PAD = 12, 10 # pack mode: only the ~10 GB VAEs move on a cold worker
 
 
211
 
212
 
213
  def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, *a, **k):
214
- latent_frames = (int(num_frames) - 5) // 17 * 5 + 2
215
- patches = (int(height) // 32) * (int(width) // 32)
216
- tokens = latent_frames * patches
217
- tokens += (int(image is not None) + int(last_image is not None)) * patches
218
- st = int(steps) * tokens
219
- compute = _DUR_A + _DUR_B * st + _DUR_C * st * tokens
220
- return max(60, int(compute) + _PLACEMENT_ALLOWANCE + _PAD)
221
 
222
 
223
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
224
  def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
225
  """The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
226
 
227
- Only the three generated outputs come back. A `@spaces.GPU` return crosses a process boundary by pickling, and
228
  the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
229
  """
230
  import torch
231
 
232
  if PLACEMENT == "lazy":
233
- # 72.16 GiB across PCIe on the first request of a worker, a no-op walk on every one after it.
234
  PIPE.to("cuda")
235
  elif PLACEMENT == "pack":
236
- # Transformer was packed at startup; only the ~10 GB of fp32 VAEs walk across on a cold worker.
237
  PIPE.vae.to("cuda")
238
  PIPE.audio_vae.to("cuda")
239
 
@@ -252,7 +238,7 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
252
 
253
 
254
  def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
255
- """One request. `upsample` is last and defaults off, so a positional API client is unaffected by it."""
256
  if LOAD_ERROR:
257
  raise gr.Error(LOAD_ERROR)
258
  if PIPE is None:
@@ -275,9 +261,9 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
275
  height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
276
  refined = plan.get("refined_prompt") or ""
277
 
278
- # EXIF-transposed and in RGB, the same way the conditioner prepares it: the conditioning latents encoded here have
279
- # to be of the image the conditioner looked at.
280
  def keyframe(path):
 
 
281
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
282
 
283
  progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
@@ -310,10 +296,9 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
310
  return path, report, refined, gr.update(visible=bool(refined))
311
 
312
 
313
-
314
  def _fit_keyframe(image_path, current_canvas):
315
- """Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's
316
- smallest (fastest) canvas, unless the user already picked a matching ratio."""
317
  if not image_path:
318
  return gr.update(), gr.update()
319
  from PIL import Image as _Image
@@ -336,16 +321,15 @@ def _fit_keyframe(image_path, current_canvas):
336
  target = w / h
337
  if abs(img.width / img.height - target) <= 1e-3:
338
  return gr.update(), gr.update(value=label)
339
- if True:
340
- if img.width / img.height > target:
341
- new_w = int(img.height * target)
342
- left = (img.width - new_w) // 2
343
- img = img.crop((left, 0, left + new_w, img.height))
344
- else:
345
- new_h = int(img.width / target)
346
- top = (img.height - new_h) // 2
347
- img = img.crop((0, top, img.width, top + new_h))
348
- img.save(image_path)
349
  return gr.update(value=image_path), gr.update(value=label)
350
 
351
 
@@ -355,8 +339,8 @@ INTRO = """# MiniMax-H3
355
 
356
  <div align="center">
357
  <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a> &nbsp;
358
- <a href="PAPER_URL_PLACEHOLDER"><strong>[ paper ]</strong></a> &nbsp;
359
- <a href="https://www.minimax.io"><strong>[ project ]</strong></a>
360
  </div>
361
 
362
  **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
@@ -413,8 +397,6 @@ with gr.Blocks(title="MiniMax-H3") as demo:
413
  cache_mode="lazy",
414
  )
415
 
416
- # `upsample` is last and defaults off, so a positional API client that predates it is unaffected; so is the
417
- # output order, with the upsampled prompt appended after the video and the report.
418
  run.click(
419
  generate,
420
  [prompt, image, last_image, canvas, duration, steps, seed, upsample],
 
1
+ """MiniMax-H3 `t2va` / `fl2va`, split deployment — the denoising half."""
 
2
 
3
  from __future__ import annotations
4
 
 
8
  import traceback
9
  from functools import cache
10
 
11
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
12
+ # startup rather than on GPU time.
13
  import spaces
14
  import gradio as gr
15
 
16
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
17
  CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
18
+ # `pack` places the transformer at startup, `lazy` moves everything on the first GPU call, `offload` hands placement to
19
+ # `ComponentsManager.enable_auto_cpu_offload`.
 
20
  PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
21
  # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
22
  ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
 
23
  GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
 
24
 
25
+ # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
26
+ # is rejected there and surfaces as a failure here.
27
  CANVASES = {
28
  # 16:9
29
  "960x544 · 16:9 fast": (544, 960),
 
49
  }
50
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
51
  FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
52
+ # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
53
+ # 15.083 s, and is refused.
54
+ MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
55
 
56
 
57
  def snap_frames(seconds: float) -> int:
 
62
  return frames
63
 
64
 
65
+ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
66
+ """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
67
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
68
+
69
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
70
+
71
+
72
  PIPE = None
73
  MANAGER = None
74
  LOAD_ERROR: str | None = None
 
89
 
90
 
91
  def load_models() -> str | None:
92
+ """Load the denoising half at startup.
93
+
94
+ `MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, the two schedulers and `video_processor`,
95
+ so `load_components` fetches exactly those subfolders `text_encoder/` and `transformer_ref/` are never touched.
96
+ Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio VAE decodes
97
+ the soundtrack roughly 20 dB too quiet.
 
 
 
 
 
 
 
 
 
 
 
 
98
  """
99
  global PIPE, MANAGER, LOAD_ERROR, LOADED_IN
100
 
 
108
 
109
  from h3_split_blocks import MiniMaxH3GeneratorBlocks
110
 
111
+ lower_duration_floor()
112
  manager = ComponentsManager()
113
  blocks = MiniMaxH3GeneratorBlocks()
114
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
 
116
  pipe.load_components(dtype=torch.bfloat16)
117
  pipe.transformer.set_attention_backend(ATTENTION)
118
 
119
+ # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
120
+ # worker. Off unless `H3_AOTI=1`.
121
  import h3_aoti
122
 
123
  h3_aoti.maybe_load(pipe.transformer)
124
 
125
  if PLACEMENT == "pack":
126
+ # Scoped to the transformer. `spaces` packs every startup-resident CUDA tensor into a second on-disk copy,
127
+ # and packing all 77.3 GB busts the 150 GB storage quota; the 61.7 GB transformer alone fits. The ~10 GB of
128
+ # fp32 VAEs move on the first GPU call instead.
 
 
 
129
  pipe.transformer.to("cuda")
130
 
131
  if PLACEMENT == "offload":
 
144
  def _arm_decode_hooks(pipe):
145
  """Make the offload hooks fire for the two VAEs.
146
 
147
+ `enable_auto_cpu_offload` wraps `forward`, and the decode blocks call `vae.decode(...)` directly, so the hook
148
+ never runs and the VAE is still on the host when the latents arrive on the card.
 
149
  """
150
  for name in ("vae", "audio_vae"):
151
  module = getattr(pipe, name)
 
162
 
163
  @cache
164
  def conditioner():
165
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
166
+ conditioner's booking is billed to whoever asked for the video."""
167
  from gradio_client import Client
168
 
169
  return Client(CONDITIONER_SPACE)
170
 
171
 
172
  def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
173
+ """`/encode` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with the
174
+ resolved `height` / `width` / `num_frames` in its metadata, plus the plan. `canvas` is the label."""
 
 
 
 
175
  from gradio_client import handle_file
176
  from safetensors import safe_open
177
 
 
189
  return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
190
 
191
 
192
+ # Seconds of GPU one request needs, from the packed video rows it is about to denoise: linear in the rows for the
193
+ # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
194
+ _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
195
+ # The two resident decoders and the mux, which scale with the output rather than with the step count.
196
+ _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
197
+ # `pack` mode: only the ~10 GB of VAEs move on a cold worker.
198
+ _PLACEMENT_ALLOWANCE, _PAD = 12, 10
199
 
200
 
201
  def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, *a, **k):
202
+ height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
203
+ latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
204
+ patches = (height // 32) * (width // 32)
205
+ rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
206
+ denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
207
+ decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
208
+ return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
209
 
210
 
211
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
212
  def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
213
  """The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
214
 
215
+ Only the three generated outputs come back a `@spaces.GPU` return crosses a process boundary by pickling, and
216
  the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
217
  """
218
  import torch
219
 
220
  if PLACEMENT == "lazy":
 
221
  PIPE.to("cuda")
222
  elif PLACEMENT == "pack":
 
223
  PIPE.vae.to("cuda")
224
  PIPE.audio_vae.to("cuda")
225
 
 
238
 
239
 
240
  def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
241
+ """One request. `upsample` is last and defaults off, so a positional API client that predates it is unaffected."""
242
  if LOAD_ERROR:
243
  raise gr.Error(LOAD_ERROR)
244
  if PIPE is None:
 
261
  height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
262
  refined = plan.get("refined_prompt") or ""
263
 
 
 
264
  def keyframe(path):
265
+ # The conditioning latents encoded here have to be of the image the conditioner looked at, which it prepares
266
+ # exactly this way.
267
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
268
 
269
  progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
 
296
  return path, report, refined, gr.update(visible=bool(refined))
297
 
298
 
 
299
  def _fit_keyframe(image_path, current_canvas):
300
+ """Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's smallest
301
+ (fastest) canvas, unless the user already picked a matching ratio."""
302
  if not image_path:
303
  return gr.update(), gr.update()
304
  from PIL import Image as _Image
 
321
  target = w / h
322
  if abs(img.width / img.height - target) <= 1e-3:
323
  return gr.update(), gr.update(value=label)
324
+ if img.width / img.height > target:
325
+ new_w = int(img.height * target)
326
+ left = (img.width - new_w) // 2
327
+ img = img.crop((left, 0, left + new_w, img.height))
328
+ else:
329
+ new_h = int(img.width / target)
330
+ top = (img.height - new_h) // 2
331
+ img = img.crop((0, top, img.width, top + new_h))
332
+ img.save(image_path)
 
333
  return gr.update(value=image_path), gr.update(value=label)
334
 
335
 
 
339
 
340
  <div align="center">
341
  <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3"><strong>[ model ]</strong></a> &nbsp;
342
+ <a href="https://www.minimax.io/blog/minimax-h3"><strong>[ blog ]</strong></a> &nbsp;
343
+ <a href="https://huggingface.co/spaces/multimodalart/minimax-h3-reference"><strong>[ reference to video ]</strong></a>
344
  </div>
345
 
346
  **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
 
397
  cache_mode="lazy",
398
  )
399
 
 
 
400
  run.click(
401
  generate,
402
  [prompt, image, last_image, canvas, duration, steps, seed, upsample],
h3_aoti.py CHANGED
@@ -1,59 +1,6 @@
1
- """ZeroGPU AoTI for MiniMax-H3: 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,23 +5,20 @@
5
  # head — whenever the PR updates.
6
  #
7
  # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
8
- #
9
- # Nothing is quantized in this deployment, so there is no `torchao`.
10
  --extra-index-url https://download.pytorch.org/whl/cu130
11
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
12
  torch==2.11.0
13
  torchvision==0.26.0
14
- # Pinned to the version the MiniMax-H3 parity work was verified against: the Qwen3-VL processor decides the vision
15
- # patch count, so a different minor changes the conditioning.
16
  transformers==5.8.0
17
  accelerate==1.14.0
18
- # diffusers pins <2; 1.24.0 is the version the parity work ran on.
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. cuDNN attention (`_native_cudnn`) is faster than the SDPA default and needs nothing.
24
- # PyAV: muxing the generated soundtrack onto the frames (`encode_video`).
25
  av
26
  pillow
27
  numpy
 
5
  # head — whenever the PR updates.
6
  #
7
  # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
 
 
8
  --extra-index-url https://download.pytorch.org/whl/cu130
9
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
10
  torch==2.11.0
11
  torchvision==0.26.0
12
+ # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
 
13
  transformers==5.8.0
14
  accelerate==1.14.0
15
+ # diffusers pins <2.
16
  huggingface-hub==1.24.0
17
  gradio==6.20.0
18
  spaces==0.51.1
19
  # No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
20
+ # transformers 5.8.0 at import.
21
+ # PyAV muxes the generated soundtrack onto the frames (`encode_video`).
22
  av
23
  pillow
24
  numpy
spaces_constant_binding_patch.py CHANGED
@@ -1,32 +1,12 @@
1
  """Bind AoTI constants that `torch.export` lifted anonymously.
2
 
3
- 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)}