multimodalart HF Staff commited on
Commit
5f82d02
·
verified ·
1 Parent(s): 6d6e37f

Wire the AoTI load path (env-gated) and the sub-768p canvases

Browse files
Files changed (1) hide show
  1. h3_aoti.py +431 -0
h3_aoti.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 `diffusers-internal-dev/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
60
+
61
+ import os
62
+ from pathlib import Path
63
+
64
+ AOTI = os.environ.get("H3_AOTI", "0") == "1"
65
+ AOTI_REPO = os.environ.get("H3_AOTI_REPO", "diffusers-internal-dev/minimax-h3-aoti")
66
+ AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "dataset")
67
+ # `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
68
+ # a dynamic sequence dimension exports and compiles cleanly (measured on an rtx-pro-6000 Job, torch 2.11). It has to be
69
+ # dynamic to be useful at all — `build_packed_sequence` pads nothing, so
70
+ # `S = num_text_tokens + condition_rows + audio_rows + video_rows` moves with the *prompt* as well as the canvas, and a
71
+ # static package would only ever serve the one prompt length it was captured from.
72
+ #
73
+ # A `HxWxF` value instead pins the artifact to one static shape. That is the fallback for a width whose dynamic export
74
+ # is refused, which is what the NVFP4 attempt hit: export rejected the dimension and offered only the affine
75
+ # `S = 128 * k - 34` it had derived from that one capture — an offset that is a property of one canvas *and* one prompt
76
+ # rather than of the model, so a package built that way serves almost nothing. The 128 is the alignment torchao's
77
+ # scaled matmuls want, though that has not been re-verified since the bfloat16 path was proven, and a static package is
78
+ # only worth building for a width that has been shown to need one.
79
+ AOTI_SHAPE = os.environ.get("H3_AOTI_SHAPE", "dynamic")
80
+ AOTI_DURATION = int(os.environ.get("H3_AOTI_DURATION", "1500"))
81
+
82
+ # The 50-deep stack that is the whole cost of a step. `MiniMaxH3TokenRefinerBlock` is also in `_repeated_blocks` but
83
+ # runs a handful of text rows a couple of times per step, so it is left eager.
84
+ BLOCK_CONTAINER = "transformer_blocks"
85
+
86
+ # Height of the AdaLN table baked into the package. `temb` is `(num_distinct_timesteps, time_embed_dim)` and the block
87
+ # gathers from a `3 * num_distinct_timesteps` table with `adaln_indices = timestep_indices * 3 + token_tag`, so the
88
+ # table's height is part of the compiled shape. It is not constant at runtime: at step 0 the video and audio streams
89
+ # share a noise level and `temb` has a single row, and from step 1 their sigma schedules diverge and it grows one.
90
+ # Exporting whatever the first call happened to show bakes in a 3-row table and the later steps then walk off it:
91
+ #
92
+ # Assertion `index out of bounds: 0 <= tmp22 < 3` failed
93
+ #
94
+ # A dynamic dimension is the wrong tool — `torch.export` specializes size-1 dimensions unconditionally, so a `Dim`
95
+ # taken from a 2-row capture carries a `>= 2` guard that step 0 violates. Instead `temb` is padded to a fixed height
96
+ # on both sides of the compile. Rows past the live ones are never gathered, so the output is unchanged, and the shape
97
+ # becomes a constant. Must match the `H3_AOTI_TEMB_ROWS` the package was compiled with.
98
+ #
99
+ # 4 is what the published bfloat16 packages were built with, and the padding is *validated* rather than assumed: the
100
+ # 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
101
+ # against eager. Two streams at two noise levels is the realistic maximum, so 4 is loose on purpose, and the cost is
102
+ # one slightly taller AdaLN projection per block against the block's own 70 TFLOP.
103
+ TEMB_ROWS = int(os.environ.get("H3_AOTI_TEMB_ROWS", "4"))
104
+
105
+ _LOADED: set[int] = set()
106
+
107
+
108
+ def pad_temb(temb, rows: int = TEMB_ROWS):
109
+ """Grow `temb` to exactly `rows` timestep rows by repeating its last one."""
110
+ present = temb.shape[0]
111
+ if present == rows:
112
+ return temb
113
+ if present > rows:
114
+ raise RuntimeError(
115
+ f"{present} distinct timesteps, but this AoTI package holds at most {rows}. "
116
+ f"Recompile with H3_AOTI_TEMB_ROWS>={present}."
117
+ )
118
+ import torch
119
+
120
+ return torch.cat([temb, temb[-1:].expand(rows - present, *temb.shape[1:])], dim=0)
121
+
122
+
123
+ def width() -> str:
124
+ """Which transformer these artifacts belong to: `bf16`, `fp8`, `nvfp4`, ...
125
+
126
+ `H3_WIDTH` wins, so a Space that has no `h3_core` — the unquantized split deployment is two standalone Spaces —
127
+ can use this module by setting one variable. Otherwise it comes from `h3_core`, which derives it from `H3_QUANT`
128
+ or from the pre-quantized repository's suffix.
129
+ """
130
+ if explicit := os.environ.get("H3_WIDTH"):
131
+ return explicit.lower()
132
+ try:
133
+ import h3_core
134
+
135
+ return h3_core.WIDTH
136
+ except Exception:
137
+ return "bf16"
138
+
139
+
140
+ def artifact_key() -> str:
141
+ """`<width>/torch<X.Y>/sm<cc>/<shape>` — an AoTI package is valid for exactly one of each."""
142
+ import torch
143
+
144
+ torch_version = ".".join(torch.__version__.split(".")[:2])
145
+ major, minor = torch.cuda.get_device_capability()
146
+ return f"{width()}/torch{torch_version}/sm{major}{minor}/{AOTI_SHAPE}"
147
+
148
+
149
+ def status() -> str:
150
+ return (
151
+ f"AoTI **on** · `{AOTI_REPO}` ({AOTI_REPO_TYPE}) · shape `{AOTI_SHAPE}`"
152
+ if AOTI
153
+ else "AoTI **off** (`H3_AOTI=1` to load compiled blocks)"
154
+ )
155
+
156
+
157
+ def patch_blocks(transformer, package_dir) -> None:
158
+ """Point all 50 blocks at the one compiled package, binding each block's own weights on its first call.
159
+
160
+ This is `spaces.aoti_load_from_package_dir` with two changes, both forced by how this Space runs.
161
+
162
+ *Weights are read on the first forward, not at patch time.* `spaces.aoti_patch` snapshots `state_dict()` when it
163
+ patches, and `maybe_load` runs at startup, while the components are still on the host — `place()` only moves them
164
+ on the first request, because ZeroGPU cannot pack a startup-resident `Float8Tensor` (it packs CUDA tensors with
165
+ `aten.empty_like(..., pin_memory=True)`, which the subclass does not implement). `Module.to` rebinds `param.data`
166
+ to a fresh CUDA tensor, so a snapshot taken at startup keeps pointing at the host copies and the compiled block
167
+ would run against host memory. Reading the state dict on the first call instead picks it up wherever it now is.
168
+
169
+ *`temb` is padded on the way in*, to the fixed height the package was exported with — see `TEMB_ROWS`.
170
+
171
+ The clone-and-flatten here is `spaces.aoti_patch`'s own preparation, kept because a quantized width needs it: the
172
+ names it produces are the FQNs the package's constants were derived from. For an unquantized block it is a no-op
173
+ and the resulting names are exactly `blocks[0].state_dict()`, which is what `export_block` exported — the two
174
+ sides agree either way. What must *not* be mirrored is the clone on the export side under non-strict tracing; see
175
+ `export_block` for why that is the difference between a working package and a SIGSEGV.
176
+ """
177
+ from spaces.zero.torch.aoti import LazyAOTIModel, _shallow_clone_module
178
+ from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
179
+
180
+ # `LazyAOTIModel` binds constants by intersecting `state_dict()` with `get_constant_fqns()` and
181
+ # silently keeps whatever it does not match, so a package whose constants were lifted anonymously
182
+ # binds nothing and the compiled block then dereferences constants nobody set — a SIGSEGV. This patch
183
+ # resolves those names through the `constant_aliases.json` the compile side writes, and **raises** a
184
+ # readable error if it still cannot. Purely protective: with a well-formed package it changes nothing,
185
+ # which is why a missing sidecar module is a warning rather than a failure.
186
+ try:
187
+ from spaces_constant_binding_patch import apply_spaces_constant_binding_patch
188
+
189
+ apply_spaces_constant_binding_patch()
190
+ except ImportError:
191
+ print("[h3-aoti] spaces_constant_binding_patch.py is missing; an unbindable constant would segfault", flush=True)
192
+
193
+ model = LazyAOTIModel(Path(package_dir) / "submodules" / BLOCK_CONTAINER / "package.pt2")
194
+
195
+ for block in getattr(transformer, BLOCK_CONTAINER):
196
+ bound: dict = {}
197
+
198
+ def forward(hidden_states, temb, *rest, _block=block, _bound=bound):
199
+ first = not _bound
200
+ if first:
201
+ clone = _shallow_clone_module(_block)
202
+ unwrap_tensor_subclass_parameters(clone)
203
+ _bound["weights"] = clone.state_dict()
204
+ return model(_bound["weights"], first, hidden_states, pad_temb(temb), *rest)
205
+
206
+ block.forward = forward
207
+ print(f"[h3-aoti] {len(getattr(transformer, BLOCK_CONTAINER))} blocks patched (temb padded to {TEMB_ROWS})", flush=True)
208
+
209
+
210
+ def maybe_load(transformer) -> None:
211
+ """Patch the block stack with its compiled package. Once, and safe to call at **startup**.
212
+
213
+ Nothing here touches a GPU: the download is CPU work and the `.pt2` archive is not opened until the first forward,
214
+ which happens inside the `@spaces.GPU` call. Proven on the pool — `diffusers-internal-dev/minimax-h3-generator-aoti`
215
+ loads `bf16/torch2.11/sm120/dynamic` at startup, patches all 50 blocks, and generates.
216
+ """
217
+ if not AOTI or id(transformer) in _LOADED:
218
+ return
219
+
220
+ import spaces
221
+ from huggingface_hub import snapshot_download
222
+
223
+ key = artifact_key()
224
+ print(f"[h3-aoti] loading {AOTI_REPO}:{key} ...", flush=True)
225
+ local = snapshot_download(
226
+ repo_id=AOTI_REPO,
227
+ repo_type=AOTI_REPO_TYPE,
228
+ allow_patterns=f"{key}/package/*",
229
+ token=os.environ.get("HF_TOKEN"),
230
+ )
231
+ package_dir = Path(local) / key / "package"
232
+ if not package_dir.is_dir():
233
+ raise RuntimeError(
234
+ f"No AoTI package at `{AOTI_REPO}:{key}/package`. Run the debug Space's Compile (AoTI) tab on this card "
235
+ f"with this `H3_QUANT`, or set `H3_AOTI=0`."
236
+ )
237
+ patch_blocks(transformer, package_dir)
238
+ _LOADED.add(id(transformer))
239
+ print(f"[h3-aoti] compiled blocks in place (temb padded to {TEMB_ROWS} rows)", flush=True)
240
+
241
+
242
+ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
243
+ """Capture one block call out of a real request and export it with a dynamic sequence dimension.
244
+
245
+ Runs on the GPU, after the transformer has been quantized and moved there: the export traces the quantized module,
246
+ and a package compiled for one quantization mode is meaningless for another.
247
+ """
248
+ import torch
249
+ import spaces
250
+
251
+ import h3_core as h3
252
+
253
+ transformer = h3.transformer_of(pipe)
254
+ blocks = getattr(transformer, BLOCK_CONTAINER)
255
+
256
+ # Record every call the block receives over a short real run and keep the widest `temb`, rather than
257
+ # `spaces.aoti_capture`'s first-call-then-abort. The first call is the unrepresentative one: see `TEMB_ROWS`.
258
+ # Text encoding and packing run either way, which is the point — these are the real inputs.
259
+ original_forward = blocks[0].forward
260
+ widest = {"args": (), "kwargs": {}, "rows": -1}
261
+ seen = []
262
+
263
+ def recording(*args, **kwargs):
264
+ rows = int(args[1].shape[0]) if len(args) > 1 and hasattr(args[1], "shape") else -1
265
+ seen.append(rows)
266
+ if rows > widest["rows"]:
267
+ widest.update(args=args, kwargs=kwargs, rows=rows)
268
+ return original_forward(*args, **kwargs)
269
+
270
+ blocks[0].forward = recording
271
+ try:
272
+ pipe(
273
+ prompt=prompt,
274
+ height=height,
275
+ width=width,
276
+ num_frames=num_frames,
277
+ num_inference_steps=int(os.environ.get("H3_AOTI_CAPTURE_STEPS", "4")),
278
+ generator=torch.Generator("cpu").manual_seed(42),
279
+ )
280
+ finally:
281
+ blocks[0].forward = original_forward
282
+ call = type("Captured", (), widest)
283
+ if not call.args:
284
+ raise RuntimeError("Nothing was captured — the transformer block was never called.")
285
+ print(f"[h3-aoti] temb rows seen: {sorted(set(seen))}; exporting with {TEMB_ROWS} (padded)", flush=True)
286
+
287
+ # `block(hidden_states, temb, adaln_indices, rotary_emb, attention_mask)`:
288
+ # hidden_states (1, S, hidden)
289
+ # temb (num_distinct_timesteps, time_embed_dim)
290
+ # adaln_indices (S,)
291
+ # rotary_emb ((S, dim), (S, dim))
292
+ # attention_mask None for a padless sequence, which is what these pipelines build
293
+ #
294
+ # Only the sequence is asked for. `temb`'s row count is held constant by padding instead (see `TEMB_ROWS`), which
295
+ # is both cheaper to reason about and the only thing that works: `torch.export` specializes size-1 dimensions
296
+ # unconditionally, so a `Dim` on a dimension that is 1 at step 0 cannot be expressed at all.
297
+ if AOTI_SHAPE == "dynamic":
298
+ sequence = torch.export.Dim("sequence", min=2048, max=262144)
299
+ dynamic_shapes = ({1: sequence}, None, {0: sequence}, ({0: sequence}, {0: sequence}), None)
300
+ dynamic_shapes = dynamic_shapes[: len(call.args)]
301
+ else:
302
+ dynamic_shapes = None
303
+
304
+ # `temb` to its fixed height, so the AdaLN table the package bakes in is the one `maybe_load` will feed it.
305
+ args = (call.args[0], pad_temb(call.args[1]), *call.args[2:])
306
+
307
+ # WHICH MODULE, AND WHICH EXPORT MODE. This is the whole difference between a working package and a SIGSEGV.
308
+ #
309
+ # `spaces.aoti_patch` prepares the load side by shallow-cloning the module and flattening any tensor subclass, and
310
+ # the received wisdom is to do the identical thing before exporting so both sides derive the same constant FQNs.
311
+ # For a subclass that is genuinely necessary: inductor's constant handling wraps a constant back into
312
+ # `torch.nn.Parameter`, which rejects a non-floating dtype ("Only Tensors of floating point and complex dtype can
313
+ # require gradients"), so `Float8Tensor` / `NVFP4Tensor` parameters have to be flattened first.
314
+ #
315
+ # But a shallow clone exported in `torch.export`'s **default non-strict** mode duplicates every weight: the same
316
+ # tensor comes out once as a named `PARAMETER` and again as an anonymous `lifted_tensor_<N>` `CONSTANT_TENSOR`,
317
+ # 12 of each for this block, 1.2 GiB of them, `data_ptr()` proving the two sets alias. `LazyAOTIModel` binds by
318
+ # name, so the anonymous half binds to nothing and the compiled block dereferences constants nobody set. That is
319
+ # the crash that stalled this work, blamed first on accelerate's offload hooks and then on torchao's subclasses;
320
+ # it is neither. Measured on an rtx-pro-6000 Job at full size, torch 2.11, plain bfloat16, no subclass anywhere:
321
+ #
322
+ # live block, non-strict 12 PARAMETER, 0 CONSTANT_TENSOR <- what the shipped bf16 package used
323
+ # live block, strict 12 PARAMETER, 0 CONSTANT_TENSOR
324
+ # shallow clone, non-strict 12 PARAMETER, 12 CONSTANT_TENSOR <- the bug
325
+ # shallow clone, strict 12 PARAMETER, 0 CONSTANT_TENSOR
326
+ #
327
+ # So: export the **live block** whenever it has no subclass parameters to flatten, which is every unquantized
328
+ # width and, per the fp8 investigation, quantized ones whose weights are still registered parameters. Only fall
329
+ # back to the clone when flattening is actually needed, and then in `strict` mode, which is also clean. The clone
330
+ # is safe to skip for the live-block path precisely because there is nothing to unwrap: `state_dict()` names are
331
+ # then identical on both sides by construction.
332
+ from spaces.zero.torch.aoti import _shallow_clone_module
333
+ from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
334
+
335
+ subclassed = sorted({type(p).__name__ for p in blocks[0].parameters()} - {"Parameter"})
336
+ if subclassed:
337
+ block = _shallow_clone_module(blocks[0])
338
+ unwrap_tensor_subclass_parameters(block)
339
+ strict = True
340
+ print(f"[h3-aoti] tensor-subclass parameters {subclassed}: exporting a flattened clone, strict=True", flush=True)
341
+ else:
342
+ block = blocks[0]
343
+ strict = False
344
+ print("[h3-aoti] plain parameters: exporting the live block, non-strict", flush=True)
345
+
346
+ # `torch.export` only gives a lifted tensor a real FQN when it is a registered parameter or buffer; a tensor
347
+ # reached through a plain attribute becomes an anonymous constant the loader can never match against
348
+ # `state_dict()`. Re-registering such tensors as buffers is numerics-preserving — it changes how a tensor is
349
+ # registered, never the tensor and never the forward. A well-formed block has none, and this returns empty.
350
+ # Only ever on the clone: `register_loose_tensors` *re-registers* attributes, so running it on the live block would
351
+ # mutate the model the eager path uses. A `MiniMaxH3TransformerBlock` has no loose tensor attributes, so this is
352
+ # empty in practice and the live-block export needs nothing; if that ever changes, the warning below catches it.
353
+ if block is not blocks[0]:
354
+ try:
355
+ from spaces_constant_binding_patch import register_loose_tensors
356
+
357
+ if loose := register_loose_tensors(block):
358
+ print(f"[h3-aoti] re-registered {len(loose)} loose tensors as buffers: {loose[:6]}", flush=True)
359
+ except ImportError:
360
+ pass
361
+
362
+ print(f"[h3-aoti] exporting {type(blocks[0]).__name__}, shapes={AOTI_SHAPE}, strict={strict} ...", flush=True)
363
+ try:
364
+ exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes, strict=strict)
365
+ except Exception as error:
366
+ # Dynamo refuses some modules it cannot trace. Non-strict is still worth attempting, with the duplication
367
+ # reported loudly below rather than left to segfault at load time.
368
+ if not strict:
369
+ raise
370
+ print(f"[h3-aoti] strict export failed ({type(error).__name__}: {error}); retrying non-strict", flush=True)
371
+ exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes)
372
+
373
+ anonymous = [
374
+ spec.target for spec in exported.graph_signature.input_specs if spec.kind.name == "CONSTANT_TENSOR"
375
+ ]
376
+ if anonymous:
377
+ print(
378
+ f"[h3-aoti] WARNING {len(anonymous)} constants lifted anonymously: {anonymous[:6]}. The loader binds by "
379
+ f"name, so this package will not bind them; `compile_and_save` writes the alias sidecar and "
380
+ f"`patch_blocks` raises rather than letting it segfault.",
381
+ flush=True,
382
+ )
383
+ return exported
384
+
385
+
386
+ def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> Path:
387
+ """Inductor-compile the exported block into `<destination>/package/submodules/transformer_blocks/package.pt2`.
388
+
389
+ That layout is what `aoti_load_from_package_dir` walks: it resolves the submodule name to the transformer's
390
+ `transformer_blocks` `ModuleList` and, because a `ModuleList` is iterable, patches every one of the 50 blocks with
391
+ this single package.
392
+ """
393
+ import spaces
394
+
395
+ package_dir = Path(destination) / "package"
396
+ print("[h3-aoti] inductor compile (minutes) ...", flush=True)
397
+ spaces.aoti_compile_and_save(package_dir, exported_program, submodule=BLOCK_CONTAINER)
398
+
399
+ # The compiled artifact keeps a constant's dtype, shape and slot index but drops its FQN when the
400
+ # export lifted it anonymously. The `ExportedProgram` still has the real names, so record the
401
+ # mapping next to the package while it is still available; the loader reads it back.
402
+ try:
403
+ from spaces_constant_binding_patch import write_constant_aliases
404
+
405
+ if sidecar := write_constant_aliases(package_dir, exported_program, submodule=BLOCK_CONTAINER):
406
+ print(f"[h3-aoti] constant alias sidecar written: {sidecar.name}", flush=True)
407
+ except ImportError:
408
+ pass
409
+
410
+ files = sorted(str(path.relative_to(package_dir)) for path in package_dir.rglob("*") if path.is_file())
411
+ print(f"[h3-aoti] package written: {files}", flush=True)
412
+ return package_dir
413
+
414
+
415
+ def upload(package_dir: str | os.PathLike[str], key: str) -> str:
416
+ """Push the package under its `<quant>/torch<X.Y>/sm<cc>/<shape>` key. CPU work — never inside GPU time."""
417
+ from huggingface_hub import HfApi
418
+
419
+ token = os.environ.get("HF_TOKEN")
420
+ if not token:
421
+ raise RuntimeError("`HF_TOKEN` is needed to push the AoTI package.")
422
+ api = HfApi(token=token)
423
+ api.create_repo(repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, private=True, exist_ok=True)
424
+ api.upload_folder(
425
+ folder_path=str(package_dir),
426
+ path_in_repo=f"{key}/package",
427
+ repo_id=AOTI_REPO,
428
+ repo_type=AOTI_REPO_TYPE,
429
+ commit_message=f"AoTI package for {key}",
430
+ )
431
+ return f"https://huggingface.co/{'datasets/' if AOTI_REPO_TYPE == 'dataset' else ''}{AOTI_REPO}/tree/main/{key}"