Spaces:
Running on Zero
Running on Zero
Fold the 4-step Turbo LoRA into the transformer, default to 4 steps
Browse filesDownloads 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.
- README.md +11 -0
- app.py +15 -4
- 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 ·
|
|
|
|
| 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=
|
| 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=
|
| 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)"
|