Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/en/api/loaders/lora.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ LoRA is a fast and lightweight training method that inserts and trains a signifi
- [`Flux2LoraLoaderMixin`] provides similar functions for [Flux2](https://huggingface.co/docs/diffusers/main/en/api/pipelines/flux2).
- [`ErnieImageLoraLoaderMixin`] provides similar functions for [Ernie-Image](https://huggingface.co/docs/diffusers/main/en/api/pipelines/ernie_image).
- [`LTX2LoraLoaderMixin`] provides similar functions for [Flux2](https://huggingface.co/docs/diffusers/main/en/api/pipelines/ltx2).
- [`MiniMaxH3LoraLoaderMixin`] provides similar functions for [MiniMax-H3](https://huggingface.co/docs/diffusers/main/en/api/pipelines/minimax_h3).
- [`LoraBaseMixin`] provides a base class with several utility methods to fuse, unfuse, unload, LoRAs and more.

> [!TIP]
Expand Down Expand Up @@ -157,6 +158,10 @@ LoRA is a fast and lightweight training method that inserts and trains a signifi

[[autodoc]] loaders.lora_pipeline.Krea2LoraLoaderMixin

## MiniMaxH3LoraLoaderMixin

[[autodoc]] loaders.lora_pipeline.MiniMaxH3LoraLoaderMixin

## LoraBaseMixin

[[autodoc]] loaders.lora_base.LoraBaseMixin
6 changes: 6 additions & 0 deletions docs/source/en/api/pipelines/minimax_h3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ specific language governing permissions and limitations under the License. -->

# MiniMax-H3

<div class="flex flex-wrap space-x-1">
<a href="https://huggingface.co/docs/diffusers/main/en/tutorials/using_peft_for_inference" target="_blank" rel="noopener">
<img alt="LoRA" src="https://img.shields.io/badge/LoRA-d8b4fe?style=flat"/>
</a>
</div>

MiniMax-H3 generates video and its soundtrack together. A single transformer denoises one packed sequence containing the text conditioning, conditioning media, and target video and audio latents. There is no separate vocoder and no audio post-hoc pass: video and audio come out of the same denoising loop.

You can find the original MiniMax-H3 checkpoints under the [MiniMaxAI](https://huggingface.co/MiniMaxAI) organization.
Expand Down
2 changes: 2 additions & 0 deletions src/diffusers/loaders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def text_encoder_attn_modules(text_encoder):
"Ideogram4LoraLoaderMixin",
"ErnieImageLoraLoaderMixin",
"CosmosLoraLoaderMixin",
"MiniMaxH3LoraLoaderMixin",
]
_import_structure["textual_inversion"] = ["TextualInversionLoaderMixin"]
_import_structure["ip_adapter"] = [
Expand Down Expand Up @@ -139,6 +140,7 @@ def text_encoder_attn_modules(text_encoder):
LTX2LoraLoaderMixin,
LTXVideoLoraLoaderMixin,
Lumina2LoraLoaderMixin,
MiniMaxH3LoraLoaderMixin,
Mochi1LoraLoaderMixin,
QwenImageLoraLoaderMixin,
SanaLoraLoaderMixin,
Expand Down
15 changes: 14 additions & 1 deletion src/diffusers/loaders/lora_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
set_weights_and_activate_adapters,
)
from ..utils.peft_utils import _create_lora_config
from ..utils.state_dict_utils import _load_sft_state_dict_metadata
from ..utils.state_dict_utils import _load_sft_file_metadata, _load_sft_state_dict_metadata


if is_transformers_available():
Expand Down Expand Up @@ -208,7 +208,15 @@ def _fetch_state_dict(
user_agent,
allow_pickle,
metadata=None,
return_file_metadata=False,
):
"""
`metadata` is diffusers' own LoRA adapter metadata, parsed out of the file's `__metadata__`. With
`return_file_metadata`, that `__metadata__` is returned in full as a third element. It is `None` whenever the
weights did not come from a safetensors file — a state dict passed in memory, or a pickled checkpoint — since there
is nowhere else for a header to live.
"""
file_metadata = None
model_file = None
if not isinstance(pretrained_model_name_or_path_or_dict, dict):
# Let's first try to load .safetensors weights
Expand Down Expand Up @@ -239,13 +247,16 @@ def _fetch_state_dict(
)
state_dict = safetensors.torch.load_file(model_file, device="cpu")
metadata = _load_sft_state_dict_metadata(model_file)
if return_file_metadata:
file_metadata = _load_sft_file_metadata(model_file)

except (IOError, safetensors.SafetensorError) as e:
if not allow_pickle:
raise e
# try loading non-safetensors weights
model_file = None
metadata = None
file_metadata = None
pass

if model_file is None:
Expand All @@ -270,6 +281,8 @@ def _fetch_state_dict(
else:
state_dict = pretrained_model_name_or_path_or_dict

if return_file_metadata:
return state_dict, metadata, file_metadata
return state_dict, metadata


Expand Down
164 changes: 164 additions & 0 deletions src/diffusers/loaders/lora_conversion_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3117,3 +3117,167 @@ def _convert_non_diffusers_ace_step_lora_to_diffusers(state_dict):
converted_state_dict[new_key] = state_dict.pop(key)

return converted_state_dict


def _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict):
"""Convert a non-diffusers MiniMax-H3 LoRA state dict onto `MiniMaxH3Transformer3DModel`'s module names.

Every known producer trains against the original checkpoint's module names — ai-toolkit under a `diffusion_model.`
prefix, the reference `generate.py` / ComfyUI checkpoints under no prefix at all, musubi-tuner under a flattened
`lora_unet_` one — so the prefix is optional and the module names are what identifies the format. Handles:

- `diffusion_model.` prefix removal, and bare `blocks.` / `token_refiner.` / `final_layer.` keys
- musubi-tuner's flattened `lora_unet_blocks_0_attn_qkv_proj` -> `blocks.0.attn.qkv_proj`
- `lora_down`/`lora_up` (kohya) -> `lora_A`/`lora_B`, with `.alpha` folded into the weights
- fused `attn.qkv_proj` -> split `to_q`/`to_k`/`to_v`; `attn.out_proj` -> `to_out.0`
- `mlp.fc1` -> `ff.net.0.proj` with its two output halves swapped, `mlp.fc2` -> `ff.net.2`
- `blocks.` -> `transformer_blocks.`, `token_refiner.blocks.` -> `token_refiner.refiner_blocks.`, and the
`final_layer.` / patch / condition / timestep projections onto their diffusers names

The result is prefixed with `transformer.`, the partition every published H3 LoRA is trained against;
`MiniMaxH3LoraLoaderMixin.load_lora_weights` is what redirects it to `transformer_ref` when asked.
"""
state_dict = {k.removeprefix("diffusion_model."): v for k, v in state_dict.items()}

# musubi-tuner (kohya sd-scripts) writes one flat module name per key under a `lora_unet_` prefix, with every `.`
# collapsed to `_`. H3's own module names contain underscores (`qkv_proj`, `token_refiner`, `final_layer`,
# `video_out`), so the dot path is recovered by matching the whole flattened name against the module vocabulary
# rather than by splitting on `_`.
if any(k.startswith("lora_unet_") for k in state_dict):
flattened_modules = [
(r"blocks_(\d+)_attn_(qkv|out)_proj", r"blocks.\1.attn.\2_proj"),
(r"blocks_(\d+)_mlp_fc([12])", r"blocks.\1.mlp.fc\2"),
(r"blocks_(\d+)_adaln_proj_linear", r"blocks.\1.adaln_proj.linear"),
(r"token_refiner_blocks_(\d+)_attn_(qkv|out)_proj", r"token_refiner.blocks.\1.attn.\2_proj"),
(r"token_refiner_blocks_(\d+)_mlp_fc([12])", r"token_refiner.blocks.\1.mlp.fc\2"),
(r"(video|audio)_patch_proj", r"\1_patch_proj"),
(r"condition_proj", "condition_proj"),
(r"time_embedder_proj_(in|out)", r"time_embedder.proj_\1"),
(r"final_layer_adaln_proj_linear", "final_layer.adaln_proj.linear"),
(r"final_layer_(video|audio)_out", r"final_layer.\1_out"),
]
unflattened = {}
for key, value in state_dict.items():
module, _, suffix = key.removeprefix("lora_unet_").partition(".")
dotted = None
for pattern, replacement in flattened_modules:
if re.fullmatch(pattern, module):
dotted = re.sub(pattern, replacement, module)
break
if dotted is None:
raise ValueError(
f"`{key}` does not name a MiniMax-H3 module in musubi-tuner's flattened `lora_unet_` layout."
)
unflattened[f"{dotted}.{suffix}"] = value
state_dict = unflattened

is_kohya = any(".lora_down.weight" in k for k in state_dict)
down_suffix = ".lora_down.weight" if is_kohya else ".lora_A.weight"
up_suffix = ".lora_up.weight" if is_kohya else ".lora_B.weight"

def pull(base):
"""Pop the (lora_A, lora_B) pair for a module path with any `.alpha` folded in, or None if absent."""
down_key = base + down_suffix
if down_key not in state_dict:
return None
down = state_dict.pop(down_key)
up = state_dict.pop(base + up_suffix)
alpha = state_dict.pop(base + ".alpha", None)
if alpha is not None:
# LoRA is scaled by `alpha / rank` in the forward pass; split the factor between down and up.
scale_down, scale_up = alpha.item() / down.shape[0], 1.0
while scale_down * 2 < scale_up:
scale_down *= 2
scale_up /= 2
down, up = down * scale_down, up * scale_up
return down, up

converted_state_dict = {}

# The projections outside the block stack. `final_layer.norm` and the `norm1`/`norm2`/`q_norm`/`k_norm` RMSNorms
# carry no LoRA-able Linear, so they have no entry.
standalone_renames = {
"video_patch_proj": "proj_in",
"audio_patch_proj": "audio_proj_in",
"condition_proj": "context_embedder",
"time_embedder.proj_in": "time_embedder.linear_1",
"time_embedder.proj_out": "time_embedder.linear_2",
"final_layer.adaln_proj.linear": "norm_out.linear",
"final_layer.video_out": "proj_out",
"final_layer.audio_out": "audio_proj_out",
}
for source, target in standalone_renames.items():
pair = pull(source)
if pair is not None:
down, up = pair
converted_state_dict[f"{target}.lora_A.weight"] = down
converted_state_dict[f"{target}.lora_B.weight"] = up

# The main stack and the text token refiner hold the same block layout, except that a refiner block has no AdaLN
# projection.
block_specs = [
(r"blocks\.(\d+)\.", "blocks", "transformer_blocks"),
(r"token_refiner\.blocks\.(\d+)\.", "token_refiner.blocks", "token_refiner.refiner_blocks"),
]
for pattern, source_prefix, target_prefix in block_specs:
num_layers = 0
for key in state_dict:
match = re.match(pattern, key)
if match:
num_layers = max(num_layers, int(match.group(1)) + 1)

for i in range(num_layers):
source = f"{source_prefix}.{i}"
target = f"{target_prefix}.{i}"

# Fused qkv -> split to_q / to_k / to_v (shared down/lora_A, chunk up/lora_B in thirds). Both producers
# consume the fused rows as `[q_all; k_all; v_all]`, so no per-head de-interleave is involved.
qkv = pull(f"{source}.attn.qkv_proj")
if qkv is not None:
down, up = qkv
if up.shape[0] % 3 != 0:
raise ValueError(
f"`{source}.attn.qkv_proj` has {up.shape[0]} output rows, which is not divisible by 3. "
"This is not a fused MiniMax-H3 QKV projection."
)
up_q, up_k, up_v = torch.chunk(up, 3, dim=0)
for proj, up_proj in (("to_q", up_q), ("to_k", up_k), ("to_v", up_v)):
converted_state_dict[f"{target}.attn.{proj}.lora_A.weight"] = down.clone()
converted_state_dict[f"{target}.attn.{proj}.lora_B.weight"] = up_proj.contiguous()

# `fc1` stays fused, as diffusers' `SwiGLU` also fuses its two projections, but the reference computes
# `fc2(silu(gate) * value)` from a fused `[gate; value]` while `SwiGLU` computes `value * silu(gate)` from
# a fused `[value; gate]`, so the two halves swap places. `lora_A` is untouched: the swap is a permutation
# of output rows, so it applies to `lora_B` alone.
fc1 = pull(f"{source}.mlp.fc1")
if fc1 is not None:
down, up = fc1
if up.shape[0] % 2 != 0:
raise ValueError(
f"`{source}.mlp.fc1` has {up.shape[0]} output rows, which is not even. This is not a fused "
"MiniMax-H3 SwiGLU projection."
)
up_gate, up_value = up.chunk(2, dim=0)
converted_state_dict[f"{target}.ff.net.0.proj.lora_A.weight"] = down
converted_state_dict[f"{target}.ff.net.0.proj.lora_B.weight"] = torch.cat(
[up_value, up_gate], dim=0
).contiguous()

for source_module, target_module in (
("attn.out_proj", "attn.to_out.0"),
("mlp.fc2", "ff.net.2"),
("adaln_proj.linear", "adaln_proj.linear"),
):
pair = pull(f"{source}.{source_module}")
if pair is not None:
down, up = pair
converted_state_dict[f"{target}.{target_module}.lora_A.weight"] = down
converted_state_dict[f"{target}.{target_module}.lora_B.weight"] = up

if len(state_dict) > 0:
raise ValueError(
f"`state_dict` should be empty at this point but has {sorted(state_dict.keys())}. "
"This may be an unsupported MiniMax-H3 LoRA layout."
)

return {f"transformer.{k}": v for k, v in converted_state_dict.items()}
Loading
Loading