akhaliq HF Staff commited on
Commit
d8b48ff
·
1 Parent(s): 7f2540d

Fold the 4-step Turbo LoRA into the transformer, default to 4 steps

Browse files

Downloads larryvrh/MiniMax-H3-Turbo-Lora at startup and merges W + B@A
into the bf16 weights, mirroring the diffusers key conversion (fused-QKV
thirds, SwiGLU gate/value swap, shared AdaLN row layout). Folded before
the AoTI package is patched in so compiled blocks carry the update.
H3_LORA / H3_LORA_REPO configure it, H3_LORA=off disables.

Files changed (3) hide show
  1. README.md +11 -0
  2. app.py +15 -4
  3. h3_lora.py +88 -0
README.md CHANGED
@@ -35,6 +35,15 @@ Space is therefore impossible, which is why quantized demos of it run NVFP4 or f
35
  Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
36
  exported at all.
37
 
 
 
 
 
 
 
 
 
 
38
  ## AoTI-compiled blocks
39
 
40
  With `H3_AOTI=1` the 50 repeated transformer blocks run from a compiled package,
@@ -157,6 +166,8 @@ one-time `PIPE.to("cuda")` is inside the first row's 339 s and does not reappear
157
  | `H3_ATTENTION` | `_native_cudnn` | cuDNN's fused kernel, 10–20% faster than the SDPA default and needs nothing installed. flash-attention 3 is sm90-only and this pool is sm120. |
158
  | `H3_GPU_DURATION` | `900` | Seconds per request; the pool applies a 1.5 duration factor. |
159
  | `H3_GPU_SIZE` | `xlarge` | ZeroGPU allocation size. `large` does not fit. |
 
 
160
 
161
  ## Whose GPU quota pays
162
 
 
35
  Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
36
  exported at all.
37
 
38
+ ## 4-step Turbo LoRA
39
+
40
+ The transformer runs with [`larryvrh/MiniMax-H3-Turbo-Lora`](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora)
41
+ folded into its bf16 weights at startup (`h3_lora.py`), so the default is **4 sampling steps** instead of 28 — the
42
+ LoRA's design point. The checkpoint is an early preview: sharper than the base model at 4 steps, but not a finished
43
+ run. The fold mirrors the diffusers key conversion exactly (fused-QKV thirds, the `SwiGLU` gate/value swap, the
44
+ shared AdaLN row layout) and happens before the AoTI package is patched in, so compiled blocks carry the update too.
45
+ `H3_LORA=minimax_h3_turbo_4step_ema.safetensors` picks the smoother time-averaged variant, `H3_LORA=off` disables.
46
+
47
  ## AoTI-compiled blocks
48
 
49
  With `H3_AOTI=1` the 50 repeated transformer blocks run from a compiled package,
 
166
  | `H3_ATTENTION` | `_native_cudnn` | cuDNN's fused kernel, 10–20% faster than the SDPA default and needs nothing installed. flash-attention 3 is sm90-only and this pool is sm120. |
167
  | `H3_GPU_DURATION` | `900` | Seconds per request; the pool applies a 1.5 duration factor. |
168
  | `H3_GPU_SIZE` | `xlarge` | ZeroGPU allocation size. `large` does not fit. |
169
+ | `H3_LORA` | `minimax_h3_turbo_4step.safetensors` | Turbo LoRA file folded into the transformer at startup. `off` disables. |
170
+ | `H3_LORA_REPO` | `larryvrh/MiniMax-H3-Turbo-Lora` | Hub repo the LoRA is fetched from. |
171
 
172
  ## Whose GPU quota pays
173
 
app.py CHANGED
@@ -73,6 +73,7 @@ PIPE = None
73
  MANAGER = None
74
  LOAD_ERROR: str | None = None
75
  LOADED_IN: float | None = None
 
76
 
77
 
78
  def status() -> str:
@@ -84,7 +85,8 @@ def status() -> str:
84
 
85
  return (
86
  f"Ready · transformer + VAEs **bfloat16, unquantized** · placement `{PLACEMENT}` · attention `{ATTENTION}` · "
87
- f"{h3_aoti.status()} · loaded in {LOADED_IN:.0f}s · conditioner `{CONDITIONER_SPACE}`"
 
88
  )
89
 
90
 
@@ -96,7 +98,7 @@ def load_models() -> str | None:
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
 
101
  if PIPE is not None or LOAD_ERROR is not None:
102
  return LOAD_ERROR
@@ -114,6 +116,15 @@ def load_models() -> str | None:
114
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
115
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
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
@@ -237,7 +248,7 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
237
  return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
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)
@@ -370,7 +381,7 @@ with gr.Blocks(title="MiniMax-H3") as demo:
370
  with gr.Accordion("Advanced options", open=False):
371
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
372
  duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
373
- steps = gr.Slider(label="Steps", minimum=10, maximum=40, step=1, value=28)
374
  seed = gr.Number(label="Seed", value=42, precision=0)
375
 
376
  with gr.Column():
 
73
  MANAGER = None
74
  LOAD_ERROR: str | None = None
75
  LOADED_IN: float | None = None
76
+ LORA_STATUS: str | None = None
77
 
78
 
79
  def status() -> str:
 
85
 
86
  return (
87
  f"Ready · transformer + VAEs **bfloat16, unquantized** · placement `{PLACEMENT}` · attention `{ATTENTION}` · "
88
+ f"{h3_aoti.status()} · {LORA_STATUS or 'no LoRA'} · loaded in {LOADED_IN:.0f}s · "
89
+ f"conditioner `{CONDITIONER_SPACE}`"
90
  )
91
 
92
 
 
98
  Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio VAE decodes
99
  the soundtrack roughly 20 dB too quiet.
100
  """
101
+ global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
102
 
103
  if PIPE is not None or LOAD_ERROR is not None:
104
  return LOAD_ERROR
 
116
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
117
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
118
  pipe.load_components(dtype=torch.bfloat16)
119
+
120
+ # Fold the 4-step Turbo LoRA into the bf16 weights before AoTI packages the blocks, so the compiled forward
121
+ # reads weights that already carry the update. `H3_LORA=off` disables.
122
+ import h3_lora
123
+
124
+ LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
125
+ if LORA_STATUS:
126
+ print(f"[gen] {LORA_STATUS}", flush=True)
127
+
128
  pipe.transformer.set_attention_backend(ATTENTION)
129
 
130
  # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
 
248
  return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
249
 
250
 
251
+ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=4, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
252
  """One request. `upsample` is last and defaults off, so a positional API client that predates it is unaffected."""
253
  if LOAD_ERROR:
254
  raise gr.Error(LOAD_ERROR)
 
381
  with gr.Accordion("Advanced options", open=False):
382
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
383
  duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
384
+ steps = gr.Slider(label="Steps", minimum=2, maximum=40, step=1, value=4)
385
  seed = gr.Number(label="Seed", value=42, precision=0)
386
 
387
  with gr.Column():
h3_lora.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fold the MiniMax-H3 Turbo LoRA (`larryvrh/MiniMax-H3-Turbo-Lora`) into the diffusers transformer.
2
+
3
+ The LoRA ships against the *reference* (ComfyUI) module tree — `blocks.N.attn.qkv_proj`, `blocks.N.mlp.fc1`,
4
+ `token_refiner.blocks.N`, `final_layer.adaln_proj.linear` — with `alpha == rank`, so the update is exactly
5
+ `W + lora_B @ lora_A`. The diffusers checkpoint is the same weights under different names and two layout transforms
6
+ (see `scripts/convert_minimax_h3_to_diffusers.py` in huggingface/diffusers#14371), so each delta gets the same
7
+ transform the base weight got:
8
+
9
+ * fused `attn.qkv_proj` rows are `[q_all; k_all; v_all]` in both in-memory layouts -> split into contiguous thirds
10
+ onto `attn.to_q` / `to_k` / `to_v`;
11
+ * fused `mlp.fc1` is `[gate; value]` while diffusers' `SwiGLU` fuses `[value; gate]` -> swap the halves onto
12
+ `ff.net.0.proj`;
13
+ * `mlp.fc2` -> `ff.net.2`, `attn.out_proj` -> `attn.to_out.0`, `blocks.` -> `transformer_blocks.`,
14
+ `token_refiner.blocks.` -> `token_refiner.refiner_blocks.`, `final_layer.adaln_proj.linear` -> `norm_out.linear`;
15
+ * the `adaln_proj.linear` modulation tables share the `[timestep][modality][param]` row layout in both trees, so
16
+ they map name-for-name with no reordering.
17
+
18
+ The delta is folded into the bf16 weights rather than applied as a runtime wrapper for one reason: the AoTI block
19
+ package (`h3_aoti`) reads each block's live weights, and a wrapper module would be invisible to it. The fold computes
20
+ `lora_B @ lora_A` in float32 and rounds once on the way back into bf16.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+
27
+ import torch
28
+
29
+ LORA_REPO = os.environ.get("H3_LORA_REPO", "larryvrh/MiniMax-H3-Turbo-Lora")
30
+ # The trained weights; the `_ema` variant is an immature time-averaged snapshot at this checkpoint. `off` disables.
31
+ LORA_FILE = os.environ.get("H3_LORA", "minimax_h3_turbo_4step.safetensors")
32
+
33
+
34
+ def _delta_targets(name: str, delta: torch.Tensor, inner_dim: int) -> list[tuple[str, torch.Tensor]]:
35
+ """Map one reference-tree LoRA base name and its `lora_B @ lora_A` delta onto diffusers parameter key(s)."""
36
+ if name.startswith("token_refiner.blocks."):
37
+ target = name.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
38
+ elif name.startswith("blocks."):
39
+ target = name.replace("blocks.", "transformer_blocks.", 1)
40
+ else:
41
+ target = name
42
+ target = target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
43
+
44
+ if target.endswith(".attn.qkv_proj"):
45
+ prefix = target.removesuffix("qkv_proj")
46
+ return [
47
+ (f"{prefix}to_{kind}.weight", part.contiguous())
48
+ for kind, part in zip(("q", "k", "v"), delta.split(inner_dim, dim=0))
49
+ ]
50
+ if target.endswith(".mlp.fc1"):
51
+ gate, value = delta.chunk(2, dim=0)
52
+ return [(target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous())]
53
+ if target.endswith(".mlp.fc2"):
54
+ return [(target.replace(".mlp.fc2", ".ff.net.2") + ".weight", delta)]
55
+ if target.endswith(".attn.out_proj"):
56
+ return [(target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", delta)]
57
+ # `adaln_proj.linear` (block-level and the final `norm_out.linear`): identical row layout on both sides.
58
+ return [(target + ".weight", delta)]
59
+
60
+
61
+ def apply_lora(transformer) -> str | None:
62
+ """Fold the configured Turbo LoRA into `transformer` in place. Returns a status line, or `None` when disabled."""
63
+ if LORA_FILE.lower() in ("", "off", "none"):
64
+ return None
65
+
66
+ from huggingface_hub import hf_hub_download
67
+ from safetensors.torch import load_file
68
+
69
+ path = hf_hub_download(LORA_REPO, LORA_FILE)
70
+ lora = load_file(path)
71
+ bases = sorted({key.rsplit(".lora_", 1)[0] for key in lora})
72
+
73
+ config = transformer.config
74
+ inner_dim = config.num_attention_heads * config.attention_head_dim
75
+ params = dict(transformer.named_parameters())
76
+
77
+ folded = 0
78
+ for name in bases:
79
+ a = lora[f"{name}.lora_A.weight"].float()
80
+ b = lora[f"{name}.lora_B.weight"].float()
81
+ delta = b @ a # alpha == rank, so the scale is 1
82
+ for key, converted in _delta_targets(name, delta, inner_dim):
83
+ param = params.get(key)
84
+ if param is None:
85
+ raise KeyError(f"LoRA target `{key}` (from `{name}`) not found in the transformer")
86
+ param.data = (param.data.float() + converted).to(param.dtype)
87
+ folded += 1
88
+ return f"LoRA `{LORA_REPO}/{LORA_FILE}` folded into {folded} weights ({len(bases)} modules)"