From 288276da7bb988600bc938e90308b63226e3c663 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 6 Aug 2026 16:32:35 +0000 Subject: [PATCH 01/11] Add LoRA support for MiniMax-H3 --- docs/source/en/api/loaders/lora.md | 5 + docs/source/en/api/pipelines/minimax_h3.md | 41 +++ src/diffusers/loaders/__init__.py | 2 + .../loaders/lora_conversion_utils.py | 131 ++++++++ src/diffusers/loaders/lora_pipeline.py | 301 ++++++++++++++++++ .../minimax_h3/modular_pipeline.py | 3 +- .../test_modular_pipeline_minimax_h3.py | 268 ++++++++++++++++ 7 files changed, 750 insertions(+), 1 deletion(-) diff --git a/docs/source/en/api/loaders/lora.md b/docs/source/en/api/loaders/lora.md index 03592a2cd5cf..4722d082822c 100644 --- a/docs/source/en/api/loaders/lora.md +++ b/docs/source/en/api/loaders/lora.md @@ -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] @@ -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 diff --git a/docs/source/en/api/pipelines/minimax_h3.md b/docs/source/en/api/pipelines/minimax_h3.md index 5e027004b825..88a62432a89e 100644 --- a/docs/source/en/api/pipelines/minimax_h3.md +++ b/docs/source/en/api/pipelines/minimax_h3.md @@ -328,6 +328,47 @@ results = pipe( ) ``` +## LoRA + +`pipe.load_lora_weights` accepts the diffusers/PEFT format and the two formats real MiniMax-H3 LoRAs actually ship in — [ostris/ai-toolkit](https://github.com/ostris/ai-toolkit)'s `diffusion_model.`-prefixed output and unprefixed original-checkpoint keys — converting the latter two onto the transformer's module names, splitting the fused `attn.qkv_proj` into `to_q` / `to_k` / `to_v` and swapping the two halves of the fused SwiGLU projection. + +```py +import torch +from diffusers import ModularPipeline + +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3", workflow="t2va") +pipe.load_components(dtype=torch.bfloat16) +pipe.to("cuda") + +pipe.load_lora_weights("some-user/some-minimax-h3-lora", weight_name="lora.safetensors", adapter_name="style") +results = pipe( + prompt="A jazz trio plays in a dim basement club", + num_frames=124, + num_inference_steps=8, + attention_kwargs={"scale": 0.8}, + output=["videos", "audio", "sampling_rate"], +) +``` + +`attention_kwargs={"scale": ...}` sets the LoRA scale for that one call. `pipe.set_adapters`, `pipe.fuse_lora`, `pipe.unload_lora_weights` and `pipe.delete_adapters` work as they do everywhere else, and reach both partitions. + +**Which partition a LoRA belongs to is not in the file.** The two transformer partitions are separately trained checkpoints with identical module names, so a LoRA trained against one loads without error into the other and silently degrades the output. Neither known producer records the partition, so: + +- A pipeline that loaded only one partition — every `workflow=` pipeline — is unambiguous, and the LoRA goes there. +- With both partitions loaded, the LoRA goes into `transformer` and a warning names the alternative. Pass `load_into_transformer_ref=True` to target `transformer_ref` instead. +- [`~loaders.MiniMaxH3LoraLoaderMixin.save_lora_weights`] takes `transformer_lora_layers` and `transformer_ref_lora_layers` and prefixes each accordingly, which is the only way to publish an H3 LoRA that records its partition. Prefer it. + +Two things to know about third-party H3 LoRAs: + +- **LoRAs trained against a pruned checkpoint do not load.** Pruned MiniMax-H3 releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide input instead of `time_embed_dim`. A LoRA trained on one carries `adaln_proj.linear.lora_A` of the wrong width and fails with a size mismatch — the update lives in a different space and cannot be mapped onto the released checkpoint. Train against an unpruned checkpoint, or drop the `adaln_proj` keys. +- **Alpha is synthesized, not guessed.** These files carry no alpha information and apply as `W + lora_B @ lora_A`. Mixed-rank adapters are loaded with `alpha == rank` per module so the effective scale is exactly 1.0. + +The model-level path stays available for a raw PEFT-format state dict, and is the escape hatch when the pipeline object is not to hand: + +```py +pipe.transformer.load_lora_adapter(state_dict, prefix=None) +``` + ## MiniMaxH3ModularPipeline [[autodoc]] MiniMaxH3ModularPipeline diff --git a/src/diffusers/loaders/__init__.py b/src/diffusers/loaders/__init__.py index 1c6693bd0c08..828744386453 100644 --- a/src/diffusers/loaders/__init__.py +++ b/src/diffusers/loaders/__init__.py @@ -91,6 +91,7 @@ def text_encoder_attn_modules(text_encoder): "Ideogram4LoraLoaderMixin", "ErnieImageLoraLoaderMixin", "CosmosLoraLoaderMixin", + "MiniMaxH3LoraLoaderMixin", ] _import_structure["textual_inversion"] = ["TextualInversionLoaderMixin"] _import_structure["ip_adapter"] = [ @@ -139,6 +140,7 @@ def text_encoder_attn_modules(text_encoder): LTX2LoraLoaderMixin, LTXVideoLoraLoaderMixin, Lumina2LoraLoaderMixin, + MiniMaxH3LoraLoaderMixin, Mochi1LoraLoaderMixin, QwenImageLoraLoaderMixin, SanaLoraLoaderMixin, diff --git a/src/diffusers/loaders/lora_conversion_utils.py b/src/diffusers/loaders/lora_conversion_utils.py index 07e3351685e8..20396686a63b 100644 --- a/src/diffusers/loaders/lora_conversion_utils.py +++ b/src/diffusers/loaders/lora_conversion_utils.py @@ -3122,3 +3122,134 @@ 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. + + Both known producers train 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 — 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 + - `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()} + + 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()} diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index 8de23d81528c..1a7ec43dd019 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -21,6 +21,7 @@ from ..utils import ( USE_PEFT_BACKEND, deprecate, + get_peft_kwargs, get_submodule_by_name, is_bitsandbytes_available, is_gguf_available, @@ -56,6 +57,7 @@ _convert_non_diffusers_ltx2_lora_to_diffusers, _convert_non_diffusers_ltxv_lora_to_diffusers, _convert_non_diffusers_lumina2_lora_to_diffusers, + _convert_non_diffusers_minimax_h3_lora_to_diffusers, _convert_non_diffusers_qwen_lora_to_diffusers, _convert_non_diffusers_wan_lora_to_diffusers, _convert_non_diffusers_z_image_lora_to_diffusers, @@ -81,6 +83,8 @@ UNET_NAME = "unet" TRANSFORMER_NAME = "transformer" LTX2_CONNECTOR_NAME = "connectors" +# MiniMax-H3 ships two independently trained DiT partitions in one repository, under two component names. +MINIMAX_H3_TRANSFORMER_REF_NAME = "transformer_ref" _MODULE_NAME_TO_ATTRIBUTE_MAP_FLUX = {"x_embedder": "in_channels"} @@ -7041,6 +7045,303 @@ def unfuse_lora(self, components: list[str] = ["transformer"], **kwargs): super().unfuse_lora(components=components, **kwargs) +class MiniMaxH3LoraLoaderMixin(LoraBaseMixin): + r""" + Load LoRA layers into [`MiniMaxH3Transformer3DModel`]. Specific to [`MiniMaxH3ModularPipeline`]. + + MiniMax-H3 holds two independently trained DiT partitions in one repository — `transformer/` for the `t2va` and + `fl2va` workflows, `transformer_ref/` for `ref2va` — and a workflow loads only its own. The two are separate + checkpoints with nothing tied between them, and their module names are identical, so a LoRA trained against one + loads without error into the other and silently produces garbage. Nothing in a published H3 LoRA records which + partition it was trained against, so the routing is explicit: a converted state dict targets `transformer.`, and + `transformer_ref` is reached either by a `transformer_ref.`-prefixed file (what `save_lora_weights` writes) or by + passing `load_into_transformer_ref=True`. + """ + + _lora_loadable_modules = ["transformer", "transformer_ref"] + transformer_name = TRANSFORMER_NAME + transformer_ref_name = MINIMAX_H3_TRANSFORMER_REF_NAME + + @classmethod + @validate_hf_hub_args + def lora_state_dict( + cls, + pretrained_model_name_or_path_or_dict: str | dict[str, torch.Tensor], + **kwargs, + ): + r""" + See [`~loaders.StableDiffusionLoraLoaderMixin.lora_state_dict`] for more details. + """ + cache_dir = kwargs.pop("cache_dir", None) + force_download = kwargs.pop("force_download", False) + proxies = kwargs.pop("proxies", None) + local_files_only = kwargs.pop("local_files_only", None) + token = kwargs.pop("token", None) + revision = kwargs.pop("revision", None) + subfolder = kwargs.pop("subfolder", None) + weight_name = kwargs.pop("weight_name", None) + use_safetensors = kwargs.pop("use_safetensors", None) + return_lora_metadata = kwargs.pop("return_lora_metadata", False) + + allow_pickle = False + if use_safetensors is None: + use_safetensors = True + allow_pickle = True + + user_agent = {"file_type": "attn_procs_weights", "framework": "pytorch"} + + state_dict, metadata = _fetch_state_dict( + pretrained_model_name_or_path_or_dict=pretrained_model_name_or_path_or_dict, + weight_name=weight_name, + use_safetensors=use_safetensors, + local_files_only=local_files_only, + cache_dir=cache_dir, + force_download=force_download, + proxies=proxies, + token=token, + revision=revision, + subfolder=subfolder, + user_agent=user_agent, + allow_pickle=allow_pickle, + ) + + is_dora_scale_present = any("dora_scale" in k for k in state_dict) + if is_dora_scale_present: + warn_msg = "It seems like you are using a DoRA checkpoint that is not compatible in Diffusers at the moment. So, we are going to filter out the keys associated to 'dora_scale` from the state dict. If you think this is a mistake please open an issue https://github.com/huggingface/diffusers/issues/new." + logger.warning(warn_msg) + state_dict = {k: v for k, v in state_dict.items() if "dora_scale" not in k} + + # ai-toolkit writes the original checkpoint's module names under a `diffusion_model.` prefix, while the + # reference `generate.py` / ComfyUI checkpoints carry no prefix at all, so the module names are what + # identifies a non-diffusers file. + is_non_diffusers_format = any( + k.startswith(("diffusion_model.", "blocks.", "token_refiner.", "final_layer.")) for k in state_dict + ) + if is_non_diffusers_format: + state_dict = _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict) + + # Every published MiniMax-H3 LoRA is alpha-less and applies as `W + lora_B @ lora_A`, i.e. at an effective + # scale of 1.0 *per module*. `get_peft_kwargs` reads `lora_alpha` off whichever rank it happens to see first + # and never re-derives it, so a mixed-rank adapter — which the public turbo LoRA is, rank 64 for attention + # and FFN against rank 16 for the AdaLN projections — has one of its two rank groups silently scaled by + # `alpha / r`. The `LoraConfig` is therefore built here with `alpha == rank` everywhere and passed on as + # metadata, which `load_lora_adapter` uses in place of `get_peft_kwargs`. Keying this off the absence of + # alpha information rather than off the conversion is deliberate: the same file also circulates + # pre-converted to diffusers keys, and that copy needs the same treatment. + if metadata is None and not any(k.endswith(".alpha") for k in state_dict): + metadata = {} + for prefix in (cls.transformer_name, cls.transformer_ref_name): + component_state_dict = { + k.removeprefix(f"{prefix}."): v for k, v in state_dict.items() if k.startswith(f"{prefix}.") + } + # `^` anchors each pattern to a full module name, as `load_lora_adapter` does for the ranks it derives. + rank = {f"^{k}": v.shape[1] for k, v in component_state_dict.items() if "lora_B" in k and v.ndim > 1} + if not rank: + continue + lora_config_kwargs = get_peft_kwargs( + rank, network_alpha_dict=None, peft_state_dict=component_state_dict, is_unet=False + ) + # The same fix-up `PeftAdapterMixin.load_lora_adapter` applies to SAI control LoRAs. + lora_config_kwargs["lora_alpha"] = lora_config_kwargs["r"] + lora_config_kwargs["alpha_pattern"] = lora_config_kwargs["rank_pattern"] + metadata.update(_pack_dict_with_prefix(lora_config_kwargs, prefix)) + metadata = metadata or None + + out = (state_dict, metadata) if return_lora_metadata else state_dict + return out + + def load_lora_weights( + self, + pretrained_model_name_or_path_or_dict: str | dict[str, torch.Tensor], + adapter_name: str | None = None, + hotswap: bool = False, + load_into_transformer_ref: bool = False, + **kwargs, + ): + """ + Load LoRA layers into `transformer` or, with `load_into_transformer_ref=True`, into `transformer_ref`. See + [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_weights`] for more details. + + Args: + load_into_transformer_ref (`bool`, defaults to `False`): + Load the `transformer.`-prefixed layers into the `transformer_ref` partition — the one the `ref2va` + workflow denoises with — instead of `transformer`. Only needed when both partitions are loaded: a + pipeline that holds `transformer_ref` alone routes there on its own. + """ + if not USE_PEFT_BACKEND: + raise ValueError("PEFT backend is required for this method.") + + low_cpu_mem_usage = kwargs.pop("low_cpu_mem_usage", _LOW_CPU_MEM_USAGE_DEFAULT_LORA) + + # if a dict is passed, copy it instead of modifying it inplace + if isinstance(pretrained_model_name_or_path_or_dict, dict): + pretrained_model_name_or_path_or_dict = pretrained_model_name_or_path_or_dict.copy() + + kwargs["return_lora_metadata"] = True + state_dict, metadata = self.lora_state_dict(pretrained_model_name_or_path_or_dict, **kwargs) + + is_correct_format = all("lora" in key for key in state_dict.keys()) + if not is_correct_format: + raise ValueError("Invalid LoRA checkpoint. Make sure all LoRA param names contain `'lora'` substring.") + + # A workflow loads only its own partition, so `getattr(..., None)` — never the `hasattr` ternary the + # single-denoiser mixins use — is what tells the two apart. + transformer = getattr(self, self.transformer_name, None) + transformer_ref = getattr(self, self.transformer_ref_name, None) + + transformer_state_dict = {k: v for k, v in state_dict.items() if k.startswith(f"{self.transformer_name}.")} + transformer_ref_state_dict = { + k: v for k, v in state_dict.items() if k.startswith(f"{self.transformer_ref_name}.") + } + + if transformer is None and transformer_ref is None: + logger.warning( + f"No denoiser to load the LoRA into: this pipeline holds neither `{self.transformer_name}` nor " + f"`{self.transformer_ref_name}`. Skipping." + ) + return + + if transformer_state_dict: + # `transformer.`-prefixed layers go to `transformer_ref` when the caller asks for it, and also when + # `transformer_ref` is the only partition present — which is what `workflow="ref2va"` loads. + into_ref = load_into_transformer_ref or transformer is None + if into_ref and transformer_ref is None: + raise ValueError( + f"`load_into_transformer_ref=True` needs a `{self.transformer_ref_name}` component, which this " + 'pipeline does not have. Load it with `workflow="ref2va"`, or drop the argument to load into ' + f"`{self.transformer_name}`." + ) + if not into_ref and transformer_ref is not None and not transformer_ref_state_dict: + logger.warning( + f"Both MiniMax-H3 partitions are loaded and this LoRA does not say which one it was trained " + f"against, so it is going into `{self.transformer_name}` — the partition every published H3 LoRA " + f"so far targets. Pass `load_into_transformer_ref=True` for the `{self.transformer_ref_name}` " + "partition instead." + ) + self.load_lora_into_transformer( + transformer_state_dict, + transformer=transformer_ref if into_ref else transformer, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=self, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + prefix=self.transformer_name, + ) + + if transformer_ref_state_dict: + if transformer_ref is None: + raise ValueError( + f"This LoRA has `{self.transformer_ref_name}.`-prefixed layers but the pipeline does not hold a " + f'`{self.transformer_ref_name}` component. Load it with `workflow="ref2va"`.' + ) + self.load_lora_into_transformer( + transformer_ref_state_dict, + transformer=transformer_ref, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=self, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + prefix=self.transformer_ref_name, + ) + + @classmethod + def load_lora_into_transformer( + cls, + state_dict, + transformer, + adapter_name=None, + _pipeline=None, + low_cpu_mem_usage=False, + hotswap: bool = False, + metadata=None, + prefix: str = "transformer", + ): + """ + See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more details. + """ + logger.info(f"Loading {prefix}.") + transformer.load_lora_adapter( + state_dict, + network_alphas=None, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=_pipeline, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + prefix=prefix, + ) + + @classmethod + def save_lora_weights( + cls, + save_directory: str | os.PathLike, + transformer_lora_layers: dict[str, torch.nn.Module | torch.Tensor] = None, + transformer_ref_lora_layers: dict[str, torch.nn.Module | torch.Tensor] = None, + is_main_process: bool = True, + weight_name: str = None, + save_function: Callable = None, + safe_serialization: bool = True, + transformer_lora_adapter_metadata: dict | None = None, + transformer_ref_lora_adapter_metadata: dict | None = None, + ): + r""" + Save the LoRA layers of one or both MiniMax-H3 partitions. Which partition a LoRA belongs to is not recoverable + from its keys, so this is the only way to publish an H3 LoRA that records it. See + [`~loaders.StableDiffusionLoraLoaderMixin.save_lora_weights`] for more information. + """ + lora_layers = {} + lora_metadata = {} + + if transformer_lora_layers: + lora_layers[cls.transformer_name] = transformer_lora_layers + lora_metadata[cls.transformer_name] = transformer_lora_adapter_metadata + if transformer_ref_lora_layers: + lora_layers[cls.transformer_ref_name] = transformer_ref_lora_layers + lora_metadata[cls.transformer_ref_name] = transformer_ref_lora_adapter_metadata + + if not lora_layers: + raise ValueError( + "You must pass at least one of `transformer_lora_layers` or `transformer_ref_lora_layers`." + ) + + cls._save_lora_weights( + save_directory=save_directory, + lora_layers=lora_layers, + lora_metadata=lora_metadata, + is_main_process=is_main_process, + weight_name=weight_name, + save_function=save_function, + safe_serialization=safe_serialization, + ) + + def fuse_lora( + self, + components: list[str] = ["transformer", "transformer_ref"], + lora_scale: float = 1.0, + safe_fusing: bool = False, + adapter_names: list[str] | None = None, + **kwargs, + ): + r""" + See [`~loaders.StableDiffusionLoraLoaderMixin.fuse_lora`] for more details. + """ + super().fuse_lora( + components=components, + lora_scale=lora_scale, + safe_fusing=safe_fusing, + adapter_names=adapter_names, + **kwargs, + ) + + def unfuse_lora(self, components: list[str] = ["transformer", "transformer_ref"], **kwargs): + r""" + See [`~loaders.StableDiffusionLoraLoaderMixin.unfuse_lora`] for more details. + """ + super().unfuse_lora(components=components, **kwargs) + + class LoraLoaderMixin(StableDiffusionLoraLoaderMixin): def __init__(self, *args, **kwargs): deprecation_message = "LoraLoaderMixin is deprecated and this will be removed in a future version. Please use `StableDiffusionLoraLoaderMixin`, instead." diff --git a/src/diffusers/modular_pipelines/minimax_h3/modular_pipeline.py b/src/diffusers/modular_pipelines/minimax_h3/modular_pipeline.py index d39c84b7e3b3..b0f75c64af1d 100644 --- a/src/diffusers/modular_pipelines/minimax_h3/modular_pipeline.py +++ b/src/diffusers/modular_pipelines/minimax_h3/modular_pipeline.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ...loaders import MiniMaxH3LoraLoaderMixin from ...utils import logging from ..modular_pipeline import ModularPipeline @@ -146,7 +147,7 @@ def audio_latent_num_frames( return int(round(num_frames / fps * latents_per_second)) -class MiniMaxH3ModularPipeline(ModularPipeline): +class MiniMaxH3ModularPipeline(ModularPipeline, MiniMaxH3LoraLoaderMixin): """ A ModularPipeline for joint video + audio generation with MiniMax-H3: the `t2va` (text only) and `fl2va` (first and/or last keyframe) workflows against the `transformer/` checkpoint partition, and the `ref2va` (omni-reference) diff --git a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py index 366cb366b220..de7876ae9ba3 100644 --- a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py +++ b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import tempfile + import numpy as np import pytest import torch @@ -34,10 +36,16 @@ MiniMaxH3TextEncoderStep, ) from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MINIMAX_H3_FPS +from diffusers.utils import is_peft_available +from ...testing_utils import require_peft_backend from ..test_modular_pipelines_common import ModularPipelineTesterMixin +if is_peft_available(): + from peft.utils import get_peft_model_state_dict + + # The blocks every workflow of [`MiniMaxH3Blocks`] runs, in order. A keyframe adds the canvas block and the one # block that encodes it; whether it anchors the first or the last frame is a matter of the packed layout, not of # which blocks run, so `fl2va` covers both. @@ -394,6 +402,241 @@ def test_check_inputs(self, overrides, message): with pytest.raises(ValueError, match=message): pipe(**inputs) + def get_dummy_lora_state_dict(self, prefix="diffusion_model.", rank=4, adaln_rank=None): + r""" + A LoRA in the layout both real-world producers emit: the *original* checkpoint's module names, fused + `attn.qkv_proj` and `mlp.fc1`, and no `.alpha`. ai-toolkit prefixes them with `diffusion_model.`; the one + public H3 LoRA carries no prefix at all, which `prefix=""` reproduces. `adaln_rank` makes the file mixed-rank, + as that LoRA is. + """ + transformer = self.get_pipeline().transformer + config = transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + adaln_rank = adaln_rank or rank + + state_dict = {} + for source, in_features, out_features, module_rank in [ + ("blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), + ("blocks.0.attn.out_proj", inner, hidden, rank), + ("blocks.0.mlp.fc1", hidden, 2 * config.ffn_dim, rank), + ("blocks.0.mlp.fc2", config.ffn_dim, hidden, rank), + ("blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), + ("token_refiner.blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), + ("final_layer.adaln_proj.linear", config.time_embed_dim, 2 * hidden, adaln_rank), + ]: + state_dict[f"{prefix}{source}.lora_A.weight"] = torch.randn(module_rank, in_features) + state_dict[f"{prefix}{source}.lora_B.weight"] = torch.randn(out_features, module_rank) + return state_dict + + def test_lora_state_dict_conversion(self): + r"""The original module names map onto the diffusers ones, fused projections split, `mlp.fc1` halves swap.""" + state_dict = self.get_dummy_lora_state_dict() + rank = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight"].shape[0] + fused_up = state_dict["diffusion_model.blocks.0.mlp.fc1.lora_B.weight"] + qkv_up = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_B.weight"] + + converted = self.pipeline_class.lora_state_dict(state_dict) + + assert "transformer.transformer_blocks.0.attn.to_q.lora_A.weight" in converted + assert "transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight" in converted + assert "transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight" in converted + assert "transformer.transformer_blocks.0.ff.net.2.lora_B.weight" in converted + assert "transformer.transformer_blocks.0.adaln_proj.linear.lora_A.weight" in converted + assert "transformer.token_refiner.refiner_blocks.0.attn.to_v.lora_B.weight" in converted + assert "transformer.norm_out.linear.lora_B.weight" in converted + assert not any("qkv_proj" in key or "fc1" in key or "final_layer" in key for key in converted) + + # The fused QKV splits into three row blocks that share `lora_A`. + inner = qkv_up.shape[0] // 3 + for index, projection in enumerate(["to_q", "to_k", "to_v"]): + prefix = f"transformer.transformer_blocks.0.attn.{projection}" + assert torch.equal(converted[f"{prefix}.lora_B.weight"], qkv_up[index * inner : (index + 1) * inner]) + assert torch.equal( + converted[f"{prefix}.lora_A.weight"], + converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], + ) + + # `mlp.fc1` is `[gate; value]` and `SwiGLU.proj` is `[value; gate]`, so `lora_B`'s halves swap and `lora_A` + # is untouched. A key-name-only assertion would pass with the swap missing, which is a silent quality bug. + ffn_dim = fused_up.shape[0] // 2 + swapped = converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"] + assert torch.equal(swapped, torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]])) + assert torch.equal( + converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight"], + state_dict["diffusion_model.blocks.0.mlp.fc1.lora_A.weight"], + ) + assert all(value.shape[1] == rank or value.shape[0] == rank for value in converted.values()) + + def test_lora_state_dict_conversion_without_a_prefix(self): + r"""The one public H3 LoRA has no prefix at all, so the module names are what identifies the format.""" + converted = self.pipeline_class.lora_state_dict(self.get_dummy_lora_state_dict(prefix="")) + + assert "transformer.transformer_blocks.0.attn.to_k.lora_B.weight" in converted + assert all(key.startswith("transformer.") for key in converted) + + def test_lora_state_dict_conversion_raises_on_an_unknown_module(self): + state_dict = self.get_dummy_lora_state_dict() + state_dict["diffusion_model.blocks.0.not_a_module.lora_A.weight"] = torch.randn(4, 8) + state_dict["diffusion_model.blocks.0.not_a_module.lora_B.weight"] = torch.randn(8, 4) + + with pytest.raises(ValueError, match="not_a_module"): + self.pipeline_class.lora_state_dict(state_dict) + + def test_lora_state_dict_synthesizes_unit_scale_metadata(self): + r""" + A non-diffusers H3 LoRA has no alpha information and applies as `W + lora_B @ lora_A`. `get_peft_kwargs` reads + `lora_alpha` off the first rank it sees and never re-derives it, so a mixed-rank file — which the public turbo + LoRA is — would have its majority-rank modules scaled by `alpha / r`. The converted metadata pins + `alpha == rank` for every module instead. + """ + state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) + + _, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) + + assert metadata["transformer.r"] == 8 + assert metadata["transformer.lora_alpha"] == 8 + assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] + assert set(metadata["transformer.rank_pattern"].values()) == {2} + assert "^norm_out.linear" in metadata["transformer.rank_pattern"] + + def get_dummy_diffusers_lora_state_dict(self, prefix="transformer", rank=8, adaln_rank=2): + r""" + The same adapter already converted to diffusers keys and republished — which is how the public turbo LoRA also + circulates. Mixed-rank, still alpha-less, so it needs the same treatment as the original layout even though no + conversion runs. + """ + transformer = self.get_pipeline().transformer + config = transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + + state_dict = {} + for module, in_features, out_features, module_rank in [ + ("transformer_blocks.0.attn.to_q", hidden, inner, rank), + ("transformer_blocks.0.attn.to_out.0", inner, hidden, rank), + ("transformer_blocks.0.ff.net.0.proj", hidden, 2 * config.ffn_dim, rank), + ("transformer_blocks.0.ff.net.2", config.ffn_dim, hidden, rank), + ("transformer_blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), + ("norm_out.linear", config.time_embed_dim, 2 * hidden, adaln_rank), + ]: + state_dict[f"{prefix}.{module}.lora_A.weight"] = torch.randn(module_rank, in_features) + state_dict[f"{prefix}.{module}.lora_B.weight"] = torch.randn(out_features, module_rank) + return state_dict + + @require_peft_backend + @pytest.mark.parametrize("prefix", ["transformer", "transformer_ref"], ids=["transformer", "transformer_ref"]) + def test_load_lora_weights_diffusers_format_mixed_rank(self, prefix): + r""" + A mixed-rank, alpha-less adapter already in diffusers keys bypasses the converter, so the alpha handling cannot + live there: without it `get_peft_kwargs` takes `lora_alpha` from whichever rank it sees first and one of the + two rank groups is applied at `alpha / r`. + """ + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(prefix=prefix), adapter_name="dummy") + + component = getattr(pipe, prefix) + injected = [module for module in component.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 6 + assert {module.scaling["dummy"] for module in injected} == {1.0} + for module in injected: + assert module.lora_alpha["dummy"] == module.r["dummy"] + assert component.transformer_blocks[0].attn.to_q.r["dummy"] == 8 + assert component.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 + assert component.norm_out.linear.r["dummy"] == 2 + + def test_lora_state_dict_respects_existing_metadata(self): + r"""A file that carries diffusers' own `lora_adapter_metadata` must not have it overwritten.""" + pipe = self.get_pipeline() + pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy") + layers = get_peft_model_state_dict(pipe.transformer, adapter_name="dummy") + saved_metadata = {"r": 8, "lora_alpha": 8, "rank_pattern": {}, "alpha_pattern": {}, "target_modules": ["x"]} + + with tempfile.TemporaryDirectory() as tmpdir: + self.pipeline_class.save_lora_weights( + tmpdir, transformer_lora_layers=layers, transformer_lora_adapter_metadata=saved_metadata + ) + _, metadata = self.pipeline_class.lora_state_dict(tmpdir, return_lora_metadata=True) + + assert metadata["transformer.target_modules"] == ["x"] + + @require_peft_backend + @pytest.mark.parametrize("prefix", ["diffusion_model.", ""], ids=["ai_toolkit", "unprefixed"]) + def test_load_lora_weights(self, prefix): + r""" + A mixed-rank, alpha-less file — the public turbo LoRA's shape — has to reach every module at its own rank and + at an effective scale of exactly 1.0. + """ + pipe = self.get_pipeline() + + pipe.load_lora_weights( + self.get_dummy_lora_state_dict(prefix=prefix, rank=8, adaln_rank=2), adapter_name="dummy" + ) + + assert "dummy" in pipe.transformer.peft_config + # Both partitions are loaded here and the file does not say which one it targets, so only `transformer` gets it. + assert "dummy" not in getattr(pipe.transformer_ref, "peft_config", {}) + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + # 3 split qkv + to_out.0 + the two ff Linears + adaln, the refiner's 3 split qkv, and norm_out + assert len(injected) == 11 + assert {module.scaling["dummy"] for module in injected} == {1.0} + for module in injected: + assert module.lora_A["dummy"].weight.shape[0] == module.r["dummy"] + assert module.lora_alpha["dummy"] == module.r["dummy"] + assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 + assert pipe.transformer.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 + assert pipe.transformer.norm_out.linear.r["dummy"] == 2 + + @require_peft_backend + def test_load_lora_weights_into_transformer_ref(self): + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) + + assert "dummy" in pipe.transformer_ref.peft_config + assert "dummy" not in getattr(pipe.transformer, "peft_config", {}) + + @require_peft_backend + def test_save_load_lora_weights_round_trip(self): + r""" + `save_lora_weights` is the only mechanism that records which partition a LoRA belongs to, so the round trip + has to preserve it. + """ + pipe = self.get_pipeline() + pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) + layers = get_peft_model_state_dict(pipe.transformer_ref, adapter_name="dummy") + + with tempfile.TemporaryDirectory() as tmpdir: + self.pipeline_class.save_lora_weights(tmpdir, transformer_ref_lora_layers=layers) + reloaded = self.pipeline_class.lora_state_dict(tmpdir) + + assert reloaded + assert all(key.startswith("transformer_ref.") for key in reloaded) + + fresh = self.get_pipeline() + fresh.load_lora_weights(reloaded, adapter_name="dummy") + assert "dummy" in fresh.transformer_ref.peft_config + assert "dummy" not in getattr(fresh.transformer, "peft_config", {}) + + @require_peft_backend + def test_lora_scale_is_applied(self): + r"""`attention_kwargs={"scale": ...}` reaches the adapter through the transformer's `apply_lora_scale`.""" + pipe = self.get_pipeline() + inputs = self.get_dummy_inputs() + + without_lora = pipe(**inputs, output="videos") + + pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy") + inputs = self.get_dummy_inputs() + with_lora = pipe(**inputs, attention_kwargs={"scale": 1.0}, output="videos") + inputs = self.get_dummy_inputs() + zero_scale = pipe(**inputs, attention_kwargs={"scale": 0.0}, output="videos") + + assert not np.allclose(without_lora, with_lora, atol=1e-4) + assert np.allclose(without_lora, zero_scale, atol=1e-4) + class TestMiniMaxH3Ref2VAModularPipelineFast(ModularPipelineTesterMixin): """The `ref2va` requests of [`MiniMaxH3Blocks`]: a prompt and an ordered list of references.""" @@ -740,6 +983,31 @@ def test_check_inputs_references(self, references, message): with pytest.raises(ValueError, match=message): pipe(**inputs) + @require_peft_backend + def test_load_lora_weights_routes_to_the_only_partition(self): + r""" + `workflow="ref2va"` loads `transformer_ref` and no `transformer`, and nothing in a published H3 LoRA says which + partition it targets, so the one partition that is present is the unambiguous destination. + """ + pipe = self.pipeline_blocks_class().get_workflow("ref2va").init_pipeline(self.pretrained_model_name_or_path) + pipe.load_components(dtype=torch.float32) + assert getattr(pipe, "transformer", None) is None + + state_dict = TestMiniMaxH3ModularPipelineFast().get_dummy_lora_state_dict() + pipe.load_lora_weights(state_dict, adapter_name="dummy") + + assert "dummy" in pipe.transformer_ref.peft_config + + @require_peft_backend + def test_load_lora_weights_raises_without_the_requested_partition(self): + pipe = self.pipeline_blocks_class().get_workflow("t2va").init_pipeline(self.pretrained_model_name_or_path) + pipe.load_components(dtype=torch.float32) + assert getattr(pipe, "transformer_ref", None) is None + + state_dict = TestMiniMaxH3ModularPipelineFast().get_dummy_lora_state_dict() + with pytest.raises(ValueError, match="load_into_transformer_ref"): + pipe.load_lora_weights(state_dict, load_into_transformer_ref=True) + class TestMiniMaxH3Reference: """ From 5f7af435265bc9436348f70b3fa7b913561ff158 Mon Sep 17 00:00:00 2001 From: apolinario Date: Fri, 7 Aug 2026 18:06:35 +0000 Subject: [PATCH 02/11] Address review: Copied-from loader, LoRA badge, docstring caveats, drop generic scale test --- docs/source/en/api/pipelines/minimax_h3.md | 47 ++---------- src/diffusers/loaders/lora_pipeline.py | 74 +++++++++++++------ .../test_modular_pipeline_minimax_h3.py | 17 ----- 3 files changed, 56 insertions(+), 82 deletions(-) diff --git a/docs/source/en/api/pipelines/minimax_h3.md b/docs/source/en/api/pipelines/minimax_h3.md index 88a62432a89e..e6e001baf8b4 100644 --- a/docs/source/en/api/pipelines/minimax_h3.md +++ b/docs/source/en/api/pipelines/minimax_h3.md @@ -11,6 +11,12 @@ specific language governing permissions and limitations under the License. --> # MiniMax-H3 + + > [!TIP] > MiniMax-H3 is not part of a diffusers release yet. Install diffusers from the pull request to use it: @@ -328,47 +334,6 @@ results = pipe( ) ``` -## LoRA - -`pipe.load_lora_weights` accepts the diffusers/PEFT format and the two formats real MiniMax-H3 LoRAs actually ship in — [ostris/ai-toolkit](https://github.com/ostris/ai-toolkit)'s `diffusion_model.`-prefixed output and unprefixed original-checkpoint keys — converting the latter two onto the transformer's module names, splitting the fused `attn.qkv_proj` into `to_q` / `to_k` / `to_v` and swapping the two halves of the fused SwiGLU projection. - -```py -import torch -from diffusers import ModularPipeline - -pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3", workflow="t2va") -pipe.load_components(dtype=torch.bfloat16) -pipe.to("cuda") - -pipe.load_lora_weights("some-user/some-minimax-h3-lora", weight_name="lora.safetensors", adapter_name="style") -results = pipe( - prompt="A jazz trio plays in a dim basement club", - num_frames=124, - num_inference_steps=8, - attention_kwargs={"scale": 0.8}, - output=["videos", "audio", "sampling_rate"], -) -``` - -`attention_kwargs={"scale": ...}` sets the LoRA scale for that one call. `pipe.set_adapters`, `pipe.fuse_lora`, `pipe.unload_lora_weights` and `pipe.delete_adapters` work as they do everywhere else, and reach both partitions. - -**Which partition a LoRA belongs to is not in the file.** The two transformer partitions are separately trained checkpoints with identical module names, so a LoRA trained against one loads without error into the other and silently degrades the output. Neither known producer records the partition, so: - -- A pipeline that loaded only one partition — every `workflow=` pipeline — is unambiguous, and the LoRA goes there. -- With both partitions loaded, the LoRA goes into `transformer` and a warning names the alternative. Pass `load_into_transformer_ref=True` to target `transformer_ref` instead. -- [`~loaders.MiniMaxH3LoraLoaderMixin.save_lora_weights`] takes `transformer_lora_layers` and `transformer_ref_lora_layers` and prefixes each accordingly, which is the only way to publish an H3 LoRA that records its partition. Prefer it. - -Two things to know about third-party H3 LoRAs: - -- **LoRAs trained against a pruned checkpoint do not load.** Pruned MiniMax-H3 releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide input instead of `time_embed_dim`. A LoRA trained on one carries `adaln_proj.linear.lora_A` of the wrong width and fails with a size mismatch — the update lives in a different space and cannot be mapped onto the released checkpoint. Train against an unpruned checkpoint, or drop the `adaln_proj` keys. -- **Alpha is synthesized, not guessed.** These files carry no alpha information and apply as `W + lora_B @ lora_A`. Mixed-rank adapters are loaded with `alpha == rank` per module so the effective scale is exactly 1.0. - -The model-level path stays available for a raw PEFT-format state dict, and is the escape hatch when the pipeline object is not to hand: - -```py -pipe.transformer.load_lora_adapter(state_dict, prefix=None) -``` - ## MiniMaxH3ModularPipeline [[autodoc]] MiniMaxH3ModularPipeline diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index 1a7ec43dd019..b092a5c18f1e 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7056,6 +7056,14 @@ class MiniMaxH3LoraLoaderMixin(LoraBaseMixin): partition it was trained against, so the routing is explicit: a converted state dict targets `transformer.`, and `transformer_ref` is reached either by a `transformer_ref.`-prefixed file (what `save_lora_weights` writes) or by passing `load_into_transformer_ref=True`. + + Two things to know about third-party H3 LoRAs. LoRAs trained against a *pruned* checkpoint do not load: pruned + releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide + input instead of `time_embed_dim`, and the update cannot be mapped onto the released checkpoint — loading fails + with a size mismatch naming the module. And published H3 LoRAs carry no alpha information while applying as + `W + lora_B @ lora_A`, so mixed-rank files are loaded with `alpha == rank` per module (effective scale exactly + 1.0); their updates are also small enough relative to the base weights that [`~MiniMaxH3LoraLoaderMixin.fuse_lora`] + into bfloat16 discards most of the update — prefer the default unfused path. """ _lora_loadable_modules = ["transformer", "transformer_ref"] @@ -7120,14 +7128,15 @@ def lora_state_dict( if is_non_diffusers_format: state_dict = _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict) - # Every published MiniMax-H3 LoRA is alpha-less and applies as `W + lora_B @ lora_A`, i.e. at an effective - # scale of 1.0 *per module*. `get_peft_kwargs` reads `lora_alpha` off whichever rank it happens to see first - # and never re-derives it, so a mixed-rank adapter — which the public turbo LoRA is, rank 64 for attention - # and FFN against rank 16 for the AdaLN projections — has one of its two rank groups silently scaled by - # `alpha / r`. The `LoraConfig` is therefore built here with `alpha == rank` everywhere and passed on as - # metadata, which `load_lora_adapter` uses in place of `get_peft_kwargs`. Keying this off the absence of - # alpha information rather than off the conversion is deliberate: the same file also circulates - # pre-converted to diffusers keys, and that copy needs the same treatment. + # Every published MiniMax-H3 LoRA is alpha-less, MIXED-RANK (rank 64 on attention and FFN modules, rank 16 + # on the AdaLN projections) and applies as `W + lora_B @ lora_A`, i.e. at an effective scale of 1.0 *per + # module*. `get_peft_kwargs` reads `lora_alpha` off whichever rank it happens to see first and never + # re-derives it, so one of the two rank groups would be silently scaled by `alpha / r`. The `LoraConfig` is + # therefore built here with `alpha == rank` everywhere and passed on as metadata, which `load_lora_adapter` + # uses in place of its own `get_peft_kwargs` inference — that inference recovers everything else (ranks, + # target modules), just not this alpha correction. Keying the synthesis off the absence of alpha information + # rather than off the conversion is deliberate: the same file also circulates pre-converted to diffusers + # keys, and that copy needs the same treatment. if metadata is None and not any(k.endswith(".alpha") for k in state_dict): metadata = {} for prefix in (cls.transformer_name, cls.transformer_ref_name): @@ -7218,16 +7227,28 @@ def load_lora_weights( f"so far targets. Pass `load_into_transformer_ref=True` for the `{self.transformer_ref_name}` " "partition instead." ) - self.load_lora_into_transformer( - transformer_state_dict, - transformer=transformer_ref if into_ref else transformer, - adapter_name=adapter_name, - metadata=metadata, - _pipeline=self, - low_cpu_mem_usage=low_cpu_mem_usage, - hotswap=hotswap, - prefix=self.transformer_name, - ) + if into_ref: + # `transformer.`-prefixed layers into the other partition, so the prefix and the target differ. + transformer_ref.load_lora_adapter( + transformer_state_dict, + prefix=self.transformer_name, + network_alphas=None, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=self, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + ) + else: + self.load_lora_into_transformer( + transformer_state_dict, + transformer=transformer, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=self, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + ) if transformer_ref_state_dict: if transformer_ref is None: @@ -7235,18 +7256,19 @@ def load_lora_weights( f"This LoRA has `{self.transformer_ref_name}.`-prefixed layers but the pipeline does not hold a " f'`{self.transformer_ref_name}` component. Load it with `workflow="ref2va"`.' ) - self.load_lora_into_transformer( + transformer_ref.load_lora_adapter( transformer_ref_state_dict, - transformer=transformer_ref, + prefix=self.transformer_ref_name, + network_alphas=None, adapter_name=adapter_name, metadata=metadata, _pipeline=self, low_cpu_mem_usage=low_cpu_mem_usage, hotswap=hotswap, - prefix=self.transformer_ref_name, ) @classmethod + # Copied from diffusers.loaders.lora_pipeline.SD3LoraLoaderMixin.load_lora_into_transformer with SD3Transformer2DModel->MiniMaxH3Transformer3DModel def load_lora_into_transformer( cls, state_dict, @@ -7256,12 +7278,17 @@ def load_lora_into_transformer( low_cpu_mem_usage=False, hotswap: bool = False, metadata=None, - prefix: str = "transformer", ): """ See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more details. """ - logger.info(f"Loading {prefix}.") + if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): + raise ValueError( + "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." + ) + + # Load the layers corresponding to transformer. + logger.info(f"Loading {cls.transformer_name}.") transformer.load_lora_adapter( state_dict, network_alphas=None, @@ -7270,7 +7297,6 @@ def load_lora_into_transformer( _pipeline=_pipeline, low_cpu_mem_usage=low_cpu_mem_usage, hotswap=hotswap, - prefix=prefix, ) @classmethod diff --git a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py index de7876ae9ba3..8fe121e1e1cc 100644 --- a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py +++ b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py @@ -620,23 +620,6 @@ def test_save_load_lora_weights_round_trip(self): assert "dummy" in fresh.transformer_ref.peft_config assert "dummy" not in getattr(fresh.transformer, "peft_config", {}) - @require_peft_backend - def test_lora_scale_is_applied(self): - r"""`attention_kwargs={"scale": ...}` reaches the adapter through the transformer's `apply_lora_scale`.""" - pipe = self.get_pipeline() - inputs = self.get_dummy_inputs() - - without_lora = pipe(**inputs, output="videos") - - pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy") - inputs = self.get_dummy_inputs() - with_lora = pipe(**inputs, attention_kwargs={"scale": 1.0}, output="videos") - inputs = self.get_dummy_inputs() - zero_scale = pipe(**inputs, attention_kwargs={"scale": 0.0}, output="videos") - - assert not np.allclose(without_lora, with_lora, atol=1e-4) - assert np.allclose(without_lora, zero_scale, atol=1e-4) - class TestMiniMaxH3Ref2VAModularPipelineFast(ModularPipelineTesterMixin): """The `ref2va` requests of [`MiniMaxH3Blocks`]: a prompt and an ordered list of references.""" From fb355fbc75cb6cb646a8c420c7a00467930d19b0 Mon Sep 17 00:00:00 2001 From: apolinario Date: Fri, 7 Aug 2026 19:04:38 +0000 Subject: [PATCH 03/11] Move the MiniMax-H3 LoRA tests to tests/lora --- tests/lora/test_lora_layers_minimax_h3.py | 284 ++++++++++++++++++ .../test_modular_pipeline_minimax_h3.py | 250 --------------- 2 files changed, 284 insertions(+), 250 deletions(-) create mode 100644 tests/lora/test_lora_layers_minimax_h3.py diff --git a/tests/lora/test_lora_layers_minimax_h3.py b/tests/lora/test_lora_layers_minimax_h3.py new file mode 100644 index 000000000000..d5fae696ff2d --- /dev/null +++ b/tests/lora/test_lora_layers_minimax_h3.py @@ -0,0 +1,284 @@ +# Copyright 2026 HuggingFace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import tempfile + +import pytest +import torch + +from diffusers.modular_pipelines import MiniMaxH3Blocks, MiniMaxH3ModularPipeline +from diffusers.utils import is_peft_available + +from ..testing_utils import require_peft_backend + + +if is_peft_available(): + from peft.utils import get_peft_model_state_dict + + +@require_peft_backend +class TestMiniMaxH3LoraLayers: + """ + The MiniMax-H3 LoRA surface that is specific to this model and its two checkpoint partitions: the conversion of + the two circulating non-diffusers formats (fused projections, original module names, no alpha keys), the + `alpha == rank` metadata synthesis for alpha-less files, and the routing between the `transformer` and + `transformer_ref` partitions. Generic LoRA behavior is not tested here. + """ + + pipeline_class = MiniMaxH3ModularPipeline + pipeline_blocks_class = MiniMaxH3Blocks + pretrained_model_name_or_path = "hf-internal-testing/tiny-minimax-h3-modular-pipe" + + def get_pipeline(self): + pipeline = self.pipeline_blocks_class().init_pipeline(self.pretrained_model_name_or_path) + pipeline.load_components(dtype=torch.float32) + pipeline.set_progress_bar_config(disable=None) + return pipeline + + def get_dummy_lora_state_dict(self, prefix="diffusion_model.", rank=4, adaln_rank=None): + r""" + A LoRA in the layout both real-world producers emit: the *original* checkpoint's module names, fused + `attn.qkv_proj` and `mlp.fc1`, and no `.alpha`. ai-toolkit prefixes them with `diffusion_model.`; the one + public H3 LoRA carries no prefix at all, which `prefix=""` reproduces. `adaln_rank` makes the file mixed-rank, + as that LoRA is. + """ + transformer = self.get_pipeline().transformer + config = transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + adaln_rank = adaln_rank or rank + + state_dict = {} + for source, in_features, out_features, module_rank in [ + ("blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), + ("blocks.0.attn.out_proj", inner, hidden, rank), + ("blocks.0.mlp.fc1", hidden, 2 * config.ffn_dim, rank), + ("blocks.0.mlp.fc2", config.ffn_dim, hidden, rank), + ("blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), + ("token_refiner.blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), + ("final_layer.adaln_proj.linear", config.time_embed_dim, 2 * hidden, adaln_rank), + ]: + state_dict[f"{prefix}{source}.lora_A.weight"] = torch.randn(module_rank, in_features) + state_dict[f"{prefix}{source}.lora_B.weight"] = torch.randn(out_features, module_rank) + return state_dict + + def test_lora_state_dict_conversion(self): + r"""The original module names map onto the diffusers ones, fused projections split, `mlp.fc1` halves swap.""" + state_dict = self.get_dummy_lora_state_dict() + rank = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight"].shape[0] + fused_up = state_dict["diffusion_model.blocks.0.mlp.fc1.lora_B.weight"] + qkv_up = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_B.weight"] + + converted = self.pipeline_class.lora_state_dict(state_dict) + + assert "transformer.transformer_blocks.0.attn.to_q.lora_A.weight" in converted + assert "transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight" in converted + assert "transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight" in converted + assert "transformer.transformer_blocks.0.ff.net.2.lora_B.weight" in converted + assert "transformer.transformer_blocks.0.adaln_proj.linear.lora_A.weight" in converted + assert "transformer.token_refiner.refiner_blocks.0.attn.to_v.lora_B.weight" in converted + assert "transformer.norm_out.linear.lora_B.weight" in converted + assert not any("qkv_proj" in key or "fc1" in key or "final_layer" in key for key in converted) + + # The fused QKV splits into three row blocks that share `lora_A`. + inner = qkv_up.shape[0] // 3 + for index, projection in enumerate(["to_q", "to_k", "to_v"]): + prefix = f"transformer.transformer_blocks.0.attn.{projection}" + assert torch.equal(converted[f"{prefix}.lora_B.weight"], qkv_up[index * inner : (index + 1) * inner]) + assert torch.equal( + converted[f"{prefix}.lora_A.weight"], + converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], + ) + + # `mlp.fc1` is `[gate; value]` and `SwiGLU.proj` is `[value; gate]`, so `lora_B`'s halves swap and `lora_A` + # is untouched. A key-name-only assertion would pass with the swap missing, which is a silent quality bug. + ffn_dim = fused_up.shape[0] // 2 + swapped = converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"] + assert torch.equal(swapped, torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]])) + assert torch.equal( + converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight"], + state_dict["diffusion_model.blocks.0.mlp.fc1.lora_A.weight"], + ) + assert all(value.shape[1] == rank or value.shape[0] == rank for value in converted.values()) + + def test_lora_state_dict_conversion_without_a_prefix(self): + r"""The one public H3 LoRA has no prefix at all, so the module names are what identifies the format.""" + converted = self.pipeline_class.lora_state_dict(self.get_dummy_lora_state_dict(prefix="")) + + assert "transformer.transformer_blocks.0.attn.to_k.lora_B.weight" in converted + assert all(key.startswith("transformer.") for key in converted) + + def test_lora_state_dict_conversion_raises_on_an_unknown_module(self): + state_dict = self.get_dummy_lora_state_dict() + state_dict["diffusion_model.blocks.0.not_a_module.lora_A.weight"] = torch.randn(4, 8) + state_dict["diffusion_model.blocks.0.not_a_module.lora_B.weight"] = torch.randn(8, 4) + + with pytest.raises(ValueError, match="not_a_module"): + self.pipeline_class.lora_state_dict(state_dict) + + def test_lora_state_dict_synthesizes_unit_scale_metadata(self): + r""" + A non-diffusers H3 LoRA has no alpha information and applies as `W + lora_B @ lora_A`. `get_peft_kwargs` reads + `lora_alpha` off the first rank it sees and never re-derives it, so a mixed-rank file — which the public turbo + LoRA is — would have its majority-rank modules scaled by `alpha / r`. The converted metadata pins + `alpha == rank` for every module instead. + """ + state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) + + _, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) + + assert metadata["transformer.r"] == 8 + assert metadata["transformer.lora_alpha"] == 8 + assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] + assert set(metadata["transformer.rank_pattern"].values()) == {2} + assert "^norm_out.linear" in metadata["transformer.rank_pattern"] + + def get_dummy_diffusers_lora_state_dict(self, prefix="transformer", rank=8, adaln_rank=2): + r""" + The same adapter already converted to diffusers keys and republished — which is how the public turbo LoRA also + circulates. Mixed-rank, still alpha-less, so it needs the same treatment as the original layout even though no + conversion runs. + """ + transformer = self.get_pipeline().transformer + config = transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + + state_dict = {} + for module, in_features, out_features, module_rank in [ + ("transformer_blocks.0.attn.to_q", hidden, inner, rank), + ("transformer_blocks.0.attn.to_out.0", inner, hidden, rank), + ("transformer_blocks.0.ff.net.0.proj", hidden, 2 * config.ffn_dim, rank), + ("transformer_blocks.0.ff.net.2", config.ffn_dim, hidden, rank), + ("transformer_blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), + ("norm_out.linear", config.time_embed_dim, 2 * hidden, adaln_rank), + ]: + state_dict[f"{prefix}.{module}.lora_A.weight"] = torch.randn(module_rank, in_features) + state_dict[f"{prefix}.{module}.lora_B.weight"] = torch.randn(out_features, module_rank) + return state_dict + + @pytest.mark.parametrize("prefix", ["transformer", "transformer_ref"], ids=["transformer", "transformer_ref"]) + def test_load_lora_weights_diffusers_format_mixed_rank(self, prefix): + r""" + A mixed-rank, alpha-less adapter already in diffusers keys bypasses the converter, so the alpha handling cannot + live there: without it `get_peft_kwargs` takes `lora_alpha` from whichever rank it sees first and one of the + two rank groups is applied at `alpha / r`. + """ + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(prefix=prefix), adapter_name="dummy") + + component = getattr(pipe, prefix) + injected = [module for module in component.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 6 + assert {module.scaling["dummy"] for module in injected} == {1.0} + for module in injected: + assert module.lora_alpha["dummy"] == module.r["dummy"] + assert component.transformer_blocks[0].attn.to_q.r["dummy"] == 8 + assert component.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 + assert component.norm_out.linear.r["dummy"] == 2 + + def test_lora_state_dict_respects_existing_metadata(self): + r"""A file that carries diffusers' own `lora_adapter_metadata` must not have it overwritten.""" + pipe = self.get_pipeline() + pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy") + layers = get_peft_model_state_dict(pipe.transformer, adapter_name="dummy") + saved_metadata = {"r": 8, "lora_alpha": 8, "rank_pattern": {}, "alpha_pattern": {}, "target_modules": ["x"]} + + with tempfile.TemporaryDirectory() as tmpdir: + self.pipeline_class.save_lora_weights( + tmpdir, transformer_lora_layers=layers, transformer_lora_adapter_metadata=saved_metadata + ) + _, metadata = self.pipeline_class.lora_state_dict(tmpdir, return_lora_metadata=True) + + assert metadata["transformer.target_modules"] == ["x"] + + @pytest.mark.parametrize("prefix", ["diffusion_model.", ""], ids=["ai_toolkit", "unprefixed"]) + def test_load_lora_weights(self, prefix): + r""" + A mixed-rank, alpha-less file — the public turbo LoRA's shape — has to reach every module at its own rank and + at an effective scale of exactly 1.0. + """ + pipe = self.get_pipeline() + + pipe.load_lora_weights( + self.get_dummy_lora_state_dict(prefix=prefix, rank=8, adaln_rank=2), adapter_name="dummy" + ) + + assert "dummy" in pipe.transformer.peft_config + # Both partitions are loaded here and the file does not say which one it targets, so only `transformer` gets it. + assert "dummy" not in getattr(pipe.transformer_ref, "peft_config", {}) + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + # 3 split qkv + to_out.0 + the two ff Linears + adaln, the refiner's 3 split qkv, and norm_out + assert len(injected) == 11 + assert {module.scaling["dummy"] for module in injected} == {1.0} + for module in injected: + assert module.lora_A["dummy"].weight.shape[0] == module.r["dummy"] + assert module.lora_alpha["dummy"] == module.r["dummy"] + assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 + assert pipe.transformer.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 + assert pipe.transformer.norm_out.linear.r["dummy"] == 2 + + def test_load_lora_weights_into_transformer_ref(self): + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) + + assert "dummy" in pipe.transformer_ref.peft_config + assert "dummy" not in getattr(pipe.transformer, "peft_config", {}) + + def test_save_load_lora_weights_round_trip(self): + r""" + `save_lora_weights` is the only mechanism that records which partition a LoRA belongs to, so the round trip + has to preserve it. + """ + pipe = self.get_pipeline() + pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) + layers = get_peft_model_state_dict(pipe.transformer_ref, adapter_name="dummy") + + with tempfile.TemporaryDirectory() as tmpdir: + self.pipeline_class.save_lora_weights(tmpdir, transformer_ref_lora_layers=layers) + reloaded = self.pipeline_class.lora_state_dict(tmpdir) + + assert reloaded + assert all(key.startswith("transformer_ref.") for key in reloaded) + + fresh = self.get_pipeline() + fresh.load_lora_weights(reloaded, adapter_name="dummy") + assert "dummy" in fresh.transformer_ref.peft_config + assert "dummy" not in getattr(fresh.transformer, "peft_config", {}) + + def test_load_lora_weights_routes_to_the_only_partition(self): + r""" + `workflow="ref2va"` loads `transformer_ref` and no `transformer`, and nothing in a published H3 LoRA says which + partition it targets, so the one partition that is present is the unambiguous destination. + """ + pipe = self.pipeline_blocks_class().get_workflow("ref2va").init_pipeline(self.pretrained_model_name_or_path) + pipe.load_components(dtype=torch.float32) + assert getattr(pipe, "transformer", None) is None + + state_dict = self.get_dummy_lora_state_dict() + pipe.load_lora_weights(state_dict, adapter_name="dummy") + + assert "dummy" in pipe.transformer_ref.peft_config + + def test_load_lora_weights_raises_without_the_requested_partition(self): + pipe = self.pipeline_blocks_class().get_workflow("t2va").init_pipeline(self.pretrained_model_name_or_path) + pipe.load_components(dtype=torch.float32) + assert getattr(pipe, "transformer_ref", None) is None + + state_dict = self.get_dummy_lora_state_dict() + with pytest.raises(ValueError, match="load_into_transformer_ref"): + pipe.load_lora_weights(state_dict, load_into_transformer_ref=True) diff --git a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py index 8fe121e1e1cc..82b3cb5bd174 100644 --- a/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py +++ b/tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import tempfile import numpy as np import pytest @@ -36,16 +35,10 @@ MiniMaxH3TextEncoderStep, ) from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MINIMAX_H3_FPS -from diffusers.utils import is_peft_available -from ...testing_utils import require_peft_backend from ..test_modular_pipelines_common import ModularPipelineTesterMixin -if is_peft_available(): - from peft.utils import get_peft_model_state_dict - - # The blocks every workflow of [`MiniMaxH3Blocks`] runs, in order. A keyframe adds the canvas block and the one # block that encodes it; whether it anchors the first or the last frame is a matter of the packed layout, not of # which blocks run, so `fl2va` covers both. @@ -402,224 +395,6 @@ def test_check_inputs(self, overrides, message): with pytest.raises(ValueError, match=message): pipe(**inputs) - def get_dummy_lora_state_dict(self, prefix="diffusion_model.", rank=4, adaln_rank=None): - r""" - A LoRA in the layout both real-world producers emit: the *original* checkpoint's module names, fused - `attn.qkv_proj` and `mlp.fc1`, and no `.alpha`. ai-toolkit prefixes them with `diffusion_model.`; the one - public H3 LoRA carries no prefix at all, which `prefix=""` reproduces. `adaln_rank` makes the file mixed-rank, - as that LoRA is. - """ - transformer = self.get_pipeline().transformer - config = transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - adaln_rank = adaln_rank or rank - - state_dict = {} - for source, in_features, out_features, module_rank in [ - ("blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), - ("blocks.0.attn.out_proj", inner, hidden, rank), - ("blocks.0.mlp.fc1", hidden, 2 * config.ffn_dim, rank), - ("blocks.0.mlp.fc2", config.ffn_dim, hidden, rank), - ("blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), - ("token_refiner.blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), - ("final_layer.adaln_proj.linear", config.time_embed_dim, 2 * hidden, adaln_rank), - ]: - state_dict[f"{prefix}{source}.lora_A.weight"] = torch.randn(module_rank, in_features) - state_dict[f"{prefix}{source}.lora_B.weight"] = torch.randn(out_features, module_rank) - return state_dict - - def test_lora_state_dict_conversion(self): - r"""The original module names map onto the diffusers ones, fused projections split, `mlp.fc1` halves swap.""" - state_dict = self.get_dummy_lora_state_dict() - rank = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight"].shape[0] - fused_up = state_dict["diffusion_model.blocks.0.mlp.fc1.lora_B.weight"] - qkv_up = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_B.weight"] - - converted = self.pipeline_class.lora_state_dict(state_dict) - - assert "transformer.transformer_blocks.0.attn.to_q.lora_A.weight" in converted - assert "transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight" in converted - assert "transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight" in converted - assert "transformer.transformer_blocks.0.ff.net.2.lora_B.weight" in converted - assert "transformer.transformer_blocks.0.adaln_proj.linear.lora_A.weight" in converted - assert "transformer.token_refiner.refiner_blocks.0.attn.to_v.lora_B.weight" in converted - assert "transformer.norm_out.linear.lora_B.weight" in converted - assert not any("qkv_proj" in key or "fc1" in key or "final_layer" in key for key in converted) - - # The fused QKV splits into three row blocks that share `lora_A`. - inner = qkv_up.shape[0] // 3 - for index, projection in enumerate(["to_q", "to_k", "to_v"]): - prefix = f"transformer.transformer_blocks.0.attn.{projection}" - assert torch.equal(converted[f"{prefix}.lora_B.weight"], qkv_up[index * inner : (index + 1) * inner]) - assert torch.equal( - converted[f"{prefix}.lora_A.weight"], - converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], - ) - - # `mlp.fc1` is `[gate; value]` and `SwiGLU.proj` is `[value; gate]`, so `lora_B`'s halves swap and `lora_A` - # is untouched. A key-name-only assertion would pass with the swap missing, which is a silent quality bug. - ffn_dim = fused_up.shape[0] // 2 - swapped = converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"] - assert torch.equal(swapped, torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]])) - assert torch.equal( - converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight"], - state_dict["diffusion_model.blocks.0.mlp.fc1.lora_A.weight"], - ) - assert all(value.shape[1] == rank or value.shape[0] == rank for value in converted.values()) - - def test_lora_state_dict_conversion_without_a_prefix(self): - r"""The one public H3 LoRA has no prefix at all, so the module names are what identifies the format.""" - converted = self.pipeline_class.lora_state_dict(self.get_dummy_lora_state_dict(prefix="")) - - assert "transformer.transformer_blocks.0.attn.to_k.lora_B.weight" in converted - assert all(key.startswith("transformer.") for key in converted) - - def test_lora_state_dict_conversion_raises_on_an_unknown_module(self): - state_dict = self.get_dummy_lora_state_dict() - state_dict["diffusion_model.blocks.0.not_a_module.lora_A.weight"] = torch.randn(4, 8) - state_dict["diffusion_model.blocks.0.not_a_module.lora_B.weight"] = torch.randn(8, 4) - - with pytest.raises(ValueError, match="not_a_module"): - self.pipeline_class.lora_state_dict(state_dict) - - def test_lora_state_dict_synthesizes_unit_scale_metadata(self): - r""" - A non-diffusers H3 LoRA has no alpha information and applies as `W + lora_B @ lora_A`. `get_peft_kwargs` reads - `lora_alpha` off the first rank it sees and never re-derives it, so a mixed-rank file — which the public turbo - LoRA is — would have its majority-rank modules scaled by `alpha / r`. The converted metadata pins - `alpha == rank` for every module instead. - """ - state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) - - _, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) - - assert metadata["transformer.r"] == 8 - assert metadata["transformer.lora_alpha"] == 8 - assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] - assert set(metadata["transformer.rank_pattern"].values()) == {2} - assert "^norm_out.linear" in metadata["transformer.rank_pattern"] - - def get_dummy_diffusers_lora_state_dict(self, prefix="transformer", rank=8, adaln_rank=2): - r""" - The same adapter already converted to diffusers keys and republished — which is how the public turbo LoRA also - circulates. Mixed-rank, still alpha-less, so it needs the same treatment as the original layout even though no - conversion runs. - """ - transformer = self.get_pipeline().transformer - config = transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - - state_dict = {} - for module, in_features, out_features, module_rank in [ - ("transformer_blocks.0.attn.to_q", hidden, inner, rank), - ("transformer_blocks.0.attn.to_out.0", inner, hidden, rank), - ("transformer_blocks.0.ff.net.0.proj", hidden, 2 * config.ffn_dim, rank), - ("transformer_blocks.0.ff.net.2", config.ffn_dim, hidden, rank), - ("transformer_blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), - ("norm_out.linear", config.time_embed_dim, 2 * hidden, adaln_rank), - ]: - state_dict[f"{prefix}.{module}.lora_A.weight"] = torch.randn(module_rank, in_features) - state_dict[f"{prefix}.{module}.lora_B.weight"] = torch.randn(out_features, module_rank) - return state_dict - - @require_peft_backend - @pytest.mark.parametrize("prefix", ["transformer", "transformer_ref"], ids=["transformer", "transformer_ref"]) - def test_load_lora_weights_diffusers_format_mixed_rank(self, prefix): - r""" - A mixed-rank, alpha-less adapter already in diffusers keys bypasses the converter, so the alpha handling cannot - live there: without it `get_peft_kwargs` takes `lora_alpha` from whichever rank it sees first and one of the - two rank groups is applied at `alpha / r`. - """ - pipe = self.get_pipeline() - - pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(prefix=prefix), adapter_name="dummy") - - component = getattr(pipe, prefix) - injected = [module for module in component.modules() if "dummy" in getattr(module, "scaling", {})] - assert len(injected) == 6 - assert {module.scaling["dummy"] for module in injected} == {1.0} - for module in injected: - assert module.lora_alpha["dummy"] == module.r["dummy"] - assert component.transformer_blocks[0].attn.to_q.r["dummy"] == 8 - assert component.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 - assert component.norm_out.linear.r["dummy"] == 2 - - def test_lora_state_dict_respects_existing_metadata(self): - r"""A file that carries diffusers' own `lora_adapter_metadata` must not have it overwritten.""" - pipe = self.get_pipeline() - pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy") - layers = get_peft_model_state_dict(pipe.transformer, adapter_name="dummy") - saved_metadata = {"r": 8, "lora_alpha": 8, "rank_pattern": {}, "alpha_pattern": {}, "target_modules": ["x"]} - - with tempfile.TemporaryDirectory() as tmpdir: - self.pipeline_class.save_lora_weights( - tmpdir, transformer_lora_layers=layers, transformer_lora_adapter_metadata=saved_metadata - ) - _, metadata = self.pipeline_class.lora_state_dict(tmpdir, return_lora_metadata=True) - - assert metadata["transformer.target_modules"] == ["x"] - - @require_peft_backend - @pytest.mark.parametrize("prefix", ["diffusion_model.", ""], ids=["ai_toolkit", "unprefixed"]) - def test_load_lora_weights(self, prefix): - r""" - A mixed-rank, alpha-less file — the public turbo LoRA's shape — has to reach every module at its own rank and - at an effective scale of exactly 1.0. - """ - pipe = self.get_pipeline() - - pipe.load_lora_weights( - self.get_dummy_lora_state_dict(prefix=prefix, rank=8, adaln_rank=2), adapter_name="dummy" - ) - - assert "dummy" in pipe.transformer.peft_config - # Both partitions are loaded here and the file does not say which one it targets, so only `transformer` gets it. - assert "dummy" not in getattr(pipe.transformer_ref, "peft_config", {}) - - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - # 3 split qkv + to_out.0 + the two ff Linears + adaln, the refiner's 3 split qkv, and norm_out - assert len(injected) == 11 - assert {module.scaling["dummy"] for module in injected} == {1.0} - for module in injected: - assert module.lora_A["dummy"].weight.shape[0] == module.r["dummy"] - assert module.lora_alpha["dummy"] == module.r["dummy"] - assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 - assert pipe.transformer.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 - assert pipe.transformer.norm_out.linear.r["dummy"] == 2 - - @require_peft_backend - def test_load_lora_weights_into_transformer_ref(self): - pipe = self.get_pipeline() - - pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) - - assert "dummy" in pipe.transformer_ref.peft_config - assert "dummy" not in getattr(pipe.transformer, "peft_config", {}) - - @require_peft_backend - def test_save_load_lora_weights_round_trip(self): - r""" - `save_lora_weights` is the only mechanism that records which partition a LoRA belongs to, so the round trip - has to preserve it. - """ - pipe = self.get_pipeline() - pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) - layers = get_peft_model_state_dict(pipe.transformer_ref, adapter_name="dummy") - - with tempfile.TemporaryDirectory() as tmpdir: - self.pipeline_class.save_lora_weights(tmpdir, transformer_ref_lora_layers=layers) - reloaded = self.pipeline_class.lora_state_dict(tmpdir) - - assert reloaded - assert all(key.startswith("transformer_ref.") for key in reloaded) - - fresh = self.get_pipeline() - fresh.load_lora_weights(reloaded, adapter_name="dummy") - assert "dummy" in fresh.transformer_ref.peft_config - assert "dummy" not in getattr(fresh.transformer, "peft_config", {}) - class TestMiniMaxH3Ref2VAModularPipelineFast(ModularPipelineTesterMixin): """The `ref2va` requests of [`MiniMaxH3Blocks`]: a prompt and an ordered list of references.""" @@ -966,31 +741,6 @@ def test_check_inputs_references(self, references, message): with pytest.raises(ValueError, match=message): pipe(**inputs) - @require_peft_backend - def test_load_lora_weights_routes_to_the_only_partition(self): - r""" - `workflow="ref2va"` loads `transformer_ref` and no `transformer`, and nothing in a published H3 LoRA says which - partition it targets, so the one partition that is present is the unambiguous destination. - """ - pipe = self.pipeline_blocks_class().get_workflow("ref2va").init_pipeline(self.pretrained_model_name_or_path) - pipe.load_components(dtype=torch.float32) - assert getattr(pipe, "transformer", None) is None - - state_dict = TestMiniMaxH3ModularPipelineFast().get_dummy_lora_state_dict() - pipe.load_lora_weights(state_dict, adapter_name="dummy") - - assert "dummy" in pipe.transformer_ref.peft_config - - @require_peft_backend - def test_load_lora_weights_raises_without_the_requested_partition(self): - pipe = self.pipeline_blocks_class().get_workflow("t2va").init_pipeline(self.pretrained_model_name_or_path) - pipe.load_components(dtype=torch.float32) - assert getattr(pipe, "transformer_ref", None) is None - - state_dict = TestMiniMaxH3ModularPipelineFast().get_dummy_lora_state_dict() - with pytest.raises(ValueError, match="load_into_transformer_ref"): - pipe.load_lora_weights(state_dict, load_into_transformer_ref=True) - class TestMiniMaxH3Reference: """ From c2bcac3f1f6fd72311832759e3da47706817427b Mon Sep 17 00:00:00 2001 From: apolinario Date: Tue, 11 Aug 2026 08:02:33 +0000 Subject: [PATCH 04/11] Support the remaining published MiniMax-H3 LoRA layouts Three more published H3 LoRAs did not load. Two were key-layout gaps and the third was the reason neither was noticed: a layout that reaches no module at all used to return without an exception. - musubi-tuner writes one flattened `lora_unet_` name per module. It is un-flattened against the H3 module vocabulary, since `qkv_proj`, `token_refiner`, `final_layer` and the output heads carry underscores that are not path separators, and then goes through the existing kohya path. - one producer publishes its own converter's output: diffusers module names with peft's `.default.` infix left in and no component prefix. The infix is dropped and the prefix added. - a state dict that filters to nothing in both partitions now warns instead of loading as a silent no-op, in the wording `load_lora_adapter` uses for the same situation. A fourth loaded, but at the wrong strength. One producer ships no `.alpha` scalars and records the alpha it trained with in the file's own `__metadata__` instead, under `alpha`. Its 8-step turbo LoRA pairs that entry's 8 with rank 128, an effective scale of 0.0625, so synthesizing `alpha == rank` applied the adapter 16x too strong. That entry is now read as the uniform network alpha. Per-module scalars still win when a file carries both, since the converter has already folded them into the weights, and a non-numeric value is warned about and ignored. `__metadata__` only exists on a file, so `_fetch_state_dict` hands it back on request. --- src/diffusers/loaders/lora_base.py | 15 +- .../loaders/lora_conversion_utils.py | 39 +- src/diffusers/loaders/lora_pipeline.py | 143 ++++++- src/diffusers/utils/state_dict_utils.py | 12 +- tests/lora/test_lora_layers_minimax_h3.py | 350 +++++++++++++++++- 5 files changed, 532 insertions(+), 27 deletions(-) diff --git a/src/diffusers/loaders/lora_base.py b/src/diffusers/loaders/lora_base.py index d4c88d35924f..ab4f2c40a35e 100644 --- a/src/diffusers/loaders/lora_base.py +++ b/src/diffusers/loaders/lora_base.py @@ -47,7 +47,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(): @@ -209,7 +209,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 @@ -240,6 +248,8 @@ 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: @@ -247,6 +257,7 @@ def _fetch_state_dict( # try loading non-safetensors weights model_file = None metadata = None + file_metadata = None pass if model_file is None: @@ -271,6 +282,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 diff --git a/src/diffusers/loaders/lora_conversion_utils.py b/src/diffusers/loaders/lora_conversion_utils.py index 20396686a63b..5b658f2637ed 100644 --- a/src/diffusers/loaders/lora_conversion_utils.py +++ b/src/diffusers/loaders/lora_conversion_utils.py @@ -3127,11 +3127,12 @@ def _convert_non_diffusers_ace_step_lora_to_diffusers(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. - Both known producers train 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 — so the prefix is optional and - the module names are what identifies the format. Handles: + 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` @@ -3143,6 +3144,38 @@ def _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict): """ 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" diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index b092a5c18f1e..db5e81a0efc6 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7058,12 +7058,14 @@ class MiniMaxH3LoraLoaderMixin(LoraBaseMixin): passing `load_into_transformer_ref=True`. Two things to know about third-party H3 LoRAs. LoRAs trained against a *pruned* checkpoint do not load: pruned - releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide - input instead of `time_embed_dim`, and the update cannot be mapped onto the released checkpoint — loading fails - with a size mismatch naming the module. And published H3 LoRAs carry no alpha information while applying as - `W + lora_B @ lora_A`, so mixed-rank files are loaded with `alpha == rank` per module (effective scale exactly - 1.0); their updates are also small enough relative to the base weights that [`~MiniMaxH3LoraLoaderMixin.fuse_lora`] - into bfloat16 discards most of the update — prefer the default unfused path. + releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide input + instead of `time_embed_dim`, and the update cannot be mapped onto the released checkpoint — loading fails with a + size mismatch naming the module. And most published H3 LoRAs carry no alpha information while applying as `W + + lora_B @ lora_A`, so mixed-rank files are loaded with `alpha == rank` per module (effective scale exactly 1.0) — + unless the file records the alpha it was trained with in its own safetensors `__metadata__`, under `alpha`, which + is then honored for every module as `alpha / rank`. Their updates are also small enough relative to the base + weights that [`~MiniMaxH3LoraLoaderMixin.fuse_lora`] into bfloat16 discards most of the update — prefer the default + unfused path. """ _lora_loadable_modules = ["transformer", "transformer_ref"] @@ -7098,7 +7100,7 @@ def lora_state_dict( user_agent = {"file_type": "attn_procs_weights", "framework": "pytorch"} - state_dict, metadata = _fetch_state_dict( + state_dict, metadata, file_metadata = _fetch_state_dict( pretrained_model_name_or_path_or_dict=pretrained_model_name_or_path_or_dict, weight_name=weight_name, use_safetensors=use_safetensors, @@ -7111,19 +7113,40 @@ def lora_state_dict( subfolder=subfolder, user_agent=user_agent, allow_pickle=allow_pickle, + return_file_metadata=True, ) + # Read before the conversion below, which folds per-module alphas into the weights and drops the scalars. + has_alpha_tensors = any(k.endswith(".alpha") for k in state_dict) + is_dora_scale_present = any("dora_scale" in k for k in state_dict) if is_dora_scale_present: warn_msg = "It seems like you are using a DoRA checkpoint that is not compatible in Diffusers at the moment. So, we are going to filter out the keys associated to 'dora_scale` from the state dict. If you think this is a mistake please open an issue https://github.com/huggingface/diffusers/issues/new." logger.warning(warn_msg) state_dict = {k: v for k, v in state_dict.items() if "dora_scale" not in k} - # ai-toolkit writes the original checkpoint's module names under a `diffusion_model.` prefix, while the - # reference `generate.py` / ComfyUI checkpoints carry no prefix at all, so the module names are what - # identifies a non-diffusers file. + # One producer publishes its own converter's output: diffusers module names, but with peft's adapter-name + # infix left in the keys and no component prefix. Neither needs the module-name conversion below, so the + # infix is dropped and the prefix added here. Gating on the infix the way `QwenImageLoraLoaderMixin` and + # `ZImageLoraLoaderMixin` do, together with requiring that no key carries a component prefix, keeps this from + # shadowing a file diffusers itself wrote — `write_lora_layers` never emits `.default.`. + # + # The module names really are diffusers' own, not a look-alike basis: that producer ships the same adapter in + # both encodings, and converting the original-format copy reproduces this one's `lora_B @ lora_A` exactly, to + # 0.0 relative error on all 312 modules. + is_unprefixed_diffusers_format = any(".default.weight" in k for k in state_dict) and not any( + k.startswith((f"{cls.transformer_name}.", f"{cls.transformer_ref_name}.")) for k in state_dict + ) + if is_unprefixed_diffusers_format: + # Strips the infix off `lora_B.default.bias` as well, which a `lora_bias=True` adapter carries. + state_dict = {f"{cls.transformer_name}.{k.replace('.default.', '.')}": v for k, v in state_dict.items()} + + # ai-toolkit writes the original checkpoint's module names under a `diffusion_model.` prefix, the reference + # `generate.py` / ComfyUI checkpoints carry no prefix at all, and musubi-tuner flattens them under + # `lora_unet_`, so the module names are what identifies a non-diffusers file. is_non_diffusers_format = any( - k.startswith(("diffusion_model.", "blocks.", "token_refiner.", "final_layer.")) for k in state_dict + k.startswith(("diffusion_model.", "blocks.", "token_refiner.", "final_layer.", "lora_unet_")) + for k in state_dict ) if is_non_diffusers_format: state_dict = _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict) @@ -7137,6 +7160,37 @@ def lora_state_dict( # target modules), just not this alpha correction. Keying the synthesis off the absence of alpha information # rather than off the conversion is deliberate: the same file also circulates pre-converted to diffusers # keys, and that copy needs the same treatment. + # + # A file that *did* ship `.alpha` scalars is not affected: the converter folds `alpha / rank` into the weights + # per module and removes the scalars, so `alpha == rank` here is what leaves that fold applied exactly once. + # Folding rather than forwarding is what lets two modules of the same rank carry different alphas, which a + # rank-keyed `alpha_pattern` cannot express. + # + # One producer instead records the single alpha it trained with in the file's own `__metadata__`, under + # `alpha`, and ships no scalars: its 8-step turbo LoRA pairs `alpha` "8" with rank 128, i.e. an effective + # scale of 0.0625 rather than 1.0. That entry is the only alpha information such a file has, so it is honored + # as a uniform network alpha — `lora_alpha` is taken from it and `alpha_pattern` left empty, which has peft + # scale every module by `alpha / rank`, the off-majority ranks in `rank_pattern` included. Per-module scalars + # win when a file carries both, since the fold above has already applied them. + network_alpha = None + if file_metadata is not None and "alpha" in file_metadata and not has_alpha_tensors: + try: + network_alpha = float(file_metadata["alpha"]) + except ValueError: + logger.warning( + f"This LoRA file records `alpha` as {file_metadata['alpha']!r} in its `__metadata__`. MiniMax-H3" + " reads that entry as the network alpha every module was trained with, and it has to be a number," + " so it is being ignored — the adapter is loaded with `alpha == rank` instead." + ) + else: + logger.info( + f"Using the network alpha {network_alpha} this LoRA file records in its `__metadata__`; every " + "module is scaled by `alpha / rank`." + ) + + # `has_alpha_tensors` above was read before the conversion, which folds a file's own `.alpha` scalars into the + # weights and drops them; this probe is the post-conversion one, and it is what catches an already-diffusers- + # keyed file that carries `.alpha` and never went through the converter at all. if metadata is None and not any(k.endswith(".alpha") for k in state_dict): metadata = {} for prefix in (cls.transformer_name, cls.transformer_ref_name): @@ -7150,9 +7204,13 @@ def lora_state_dict( lora_config_kwargs = get_peft_kwargs( rank, network_alpha_dict=None, peft_state_dict=component_state_dict, is_unet=False ) - # The same fix-up `PeftAdapterMixin.load_lora_adapter` applies to SAI control LoRAs. - lora_config_kwargs["lora_alpha"] = lora_config_kwargs["r"] - lora_config_kwargs["alpha_pattern"] = lora_config_kwargs["rank_pattern"] + if network_alpha is not None: + lora_config_kwargs["lora_alpha"] = network_alpha + lora_config_kwargs["alpha_pattern"] = {} + else: + # The same fix-up `PeftAdapterMixin.load_lora_adapter` applies to SAI control LoRAs. + lora_config_kwargs["lora_alpha"] = lora_config_kwargs["r"] + lora_config_kwargs["alpha_pattern"] = lora_config_kwargs["rank_pattern"] metadata.update(_pack_dict_with_prefix(lora_config_kwargs, prefix)) metadata = metadata or None @@ -7181,6 +7239,10 @@ def load_lora_weights( raise ValueError("PEFT backend is required for this method.") low_cpu_mem_usage = kwargs.pop("low_cpu_mem_usage", _LOW_CPU_MEM_USAGE_DEFAULT_LORA) + if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): + raise ValueError( + "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." + ) # if a dict is passed, copy it instead of modifying it inplace if isinstance(pretrained_model_name_or_path_or_dict, dict): @@ -7210,6 +7272,16 @@ def load_lora_weights( ) return + if not transformer_state_dict and not transformer_ref_state_dict: + logger.warning( + f"No LoRA keys associated to {type(self).__name__} found with the prefix " + f"`{self.transformer_name}.` or `{self.transformer_ref_name}.`, so loading this state dict would " + f"target no module at all and nothing was loaded. This is an unrecognized MiniMax-H3 LoRA layout; " + f"its first keys are {sorted(state_dict)[:4]}. Open an issue if you think it's unexpected: " + "https://github.com/huggingface/diffusers/issues/new" + ) + return + if transformer_state_dict: # `transformer.`-prefixed layers go to `transformer_ref` when the caller asks for it, and also when # `transformer_ref` is the only partition present — which is what `workflow="ref2va"` loads. @@ -7229,10 +7301,10 @@ def load_lora_weights( ) if into_ref: # `transformer.`-prefixed layers into the other partition, so the prefix and the target differ. - transformer_ref.load_lora_adapter( + self.load_lora_into_transformer_ref( transformer_state_dict, + transformer_ref=transformer_ref, prefix=self.transformer_name, - network_alphas=None, adapter_name=adapter_name, metadata=metadata, _pipeline=self, @@ -7256,10 +7328,10 @@ def load_lora_weights( f"This LoRA has `{self.transformer_ref_name}.`-prefixed layers but the pipeline does not hold a " f'`{self.transformer_ref_name}` component. Load it with `workflow="ref2va"`.' ) - transformer_ref.load_lora_adapter( + self.load_lora_into_transformer_ref( transformer_ref_state_dict, + transformer_ref=transformer_ref, prefix=self.transformer_ref_name, - network_alphas=None, adapter_name=adapter_name, metadata=metadata, _pipeline=self, @@ -7299,6 +7371,41 @@ def load_lora_into_transformer( hotswap=hotswap, ) + @classmethod + def load_lora_into_transformer_ref( + cls, + state_dict, + transformer_ref, + prefix, + adapter_name=None, + _pipeline=None, + low_cpu_mem_usage=False, + hotswap: bool = False, + metadata=None, + ): + """ + Load LoRA layers into the `transformer_ref` partition. `prefix` is the component name the keys carry, which is + `transformer_ref` for a file that names the partition and `transformer` for one routed here by + `load_into_transformer_ref=True`. See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more + details. + """ + if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): + raise ValueError( + "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." + ) + + logger.info(f"Loading {prefix}.") + transformer_ref.load_lora_adapter( + state_dict, + prefix=prefix, + network_alphas=None, + adapter_name=adapter_name, + metadata=metadata, + _pipeline=_pipeline, + low_cpu_mem_usage=low_cpu_mem_usage, + hotswap=hotswap, + ) + @classmethod def save_lora_weights( cls, diff --git a/src/diffusers/utils/state_dict_utils.py b/src/diffusers/utils/state_dict_utils.py index cdfdb7b1df04..00832171dcde 100644 --- a/src/diffusers/utils/state_dict_utils.py +++ b/src/diffusers/utils/state_dict_utils.py @@ -529,15 +529,21 @@ def state_dict_all_zero(state_dict, filter_str=None): return all(torch.all(param == 0).item() for param in state_dict.values()) -def _load_sft_state_dict_metadata(model_file: str): +def _load_sft_file_metadata(model_file: str): + """A safetensors file's own `__metadata__`, minus safetensors' `format` entry.""" import safetensors.torch - from ..loaders.lora_base import LORA_ADAPTER_METADATA_KEY - with safetensors.torch.safe_open(model_file, framework="pt", device="cpu") as f: metadata = f.metadata() or {} metadata.pop("format", None) + return metadata + + +def _load_sft_state_dict_metadata(model_file: str): + from ..loaders.lora_base import LORA_ADAPTER_METADATA_KEY + + metadata = _load_sft_file_metadata(model_file) if metadata: raw = metadata.get(LORA_ADAPTER_METADATA_KEY) return json.loads(raw) if raw else None diff --git a/tests/lora/test_lora_layers_minimax_h3.py b/tests/lora/test_lora_layers_minimax_h3.py index d5fae696ff2d..1a274b619ca7 100644 --- a/tests/lora/test_lora_layers_minimax_h3.py +++ b/tests/lora/test_lora_layers_minimax_h3.py @@ -12,15 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import tempfile import pytest +import safetensors.torch import torch from diffusers.modular_pipelines import MiniMaxH3Blocks, MiniMaxH3ModularPipeline -from diffusers.utils import is_peft_available +from diffusers.utils import is_peft_available, logging -from ..testing_utils import require_peft_backend +from ..testing_utils import CaptureLogger, require_peft_backend if is_peft_available(): @@ -127,6 +129,247 @@ def test_lora_state_dict_conversion_raises_on_an_unknown_module(self): with pytest.raises(ValueError, match="not_a_module"): self.pipeline_class.lora_state_dict(state_dict) + def get_dummy_flattened_lora_state_dict(self, rank=4, alpha=4.0): + r""" + musubi-tuner's layout: one flat `lora_unet_` module name per key with every `.` collapsed to `_`, kohya + `lora_down`/`lora_up` tensors and an explicit scalar `.alpha`. The module names carry underscores of their own, + so every name the un-flattening has to disambiguate is present here, not just the four the published LoRA in + this layout happens to train. + """ + config = self.get_pipeline().transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + video_patch_dim = config.in_channels * config.patch_size[0] * config.patch_size[1] * config.patch_size[2] + + state_dict = {} + for module, in_features, out_features in [ + ("blocks_0_attn_qkv_proj", hidden, 3 * inner), + ("blocks_0_attn_out_proj", inner, hidden), + ("blocks_0_mlp_fc1", hidden, 2 * config.ffn_dim), + ("blocks_0_mlp_fc2", config.ffn_dim, hidden), + ("blocks_0_adaln_proj_linear", config.time_embed_dim, 6 * 3 * hidden), + ("token_refiner_blocks_0_attn_qkv_proj", hidden, 3 * inner), + ("token_refiner_blocks_0_mlp_fc2", config.ffn_dim, hidden), + ("video_patch_proj", video_patch_dim, hidden), + ("audio_patch_proj", config.audio_in_channels, hidden), + ("condition_proj", config.text_dim, hidden), + ("time_embedder_proj_in", config.freq_dim, config.time_embed_hidden_dim), + ("time_embedder_proj_out", config.time_embed_hidden_dim, config.time_embed_dim), + ("final_layer_adaln_proj_linear", config.time_embed_dim, 2 * hidden), + ("final_layer_video_out", hidden, video_patch_dim), + ("final_layer_audio_out", hidden, config.audio_in_channels), + ]: + state_dict[f"lora_unet_{module}.lora_down.weight"] = torch.randn(rank, in_features) + state_dict[f"lora_unet_{module}.lora_up.weight"] = torch.randn(out_features, rank) + state_dict[f"lora_unet_{module}.alpha"] = torch.tensor(alpha) + return state_dict + + def test_lora_state_dict_conversion_flattened_layout(self): + r""" + musubi-tuner's flat names are un-flattened against the module vocabulary, never by turning `_` into `.`: + `qkv_proj`, `out_proj`, `token_refiner`, `time_embedder`, `final_layer` and the two output heads all carry + underscores that are not path separators. + """ + state_dict = self.get_dummy_flattened_lora_state_dict() + + converted = self.pipeline_class.lora_state_dict(state_dict) + + assert set(converted) == { + f"transformer.{module}.lora_{ab}.weight" + for module in [ + "transformer_blocks.0.attn.to_q", + "transformer_blocks.0.attn.to_k", + "transformer_blocks.0.attn.to_v", + "transformer_blocks.0.attn.to_out.0", + "transformer_blocks.0.ff.net.0.proj", + "transformer_blocks.0.ff.net.2", + "transformer_blocks.0.adaln_proj.linear", + "token_refiner.refiner_blocks.0.attn.to_q", + "token_refiner.refiner_blocks.0.attn.to_k", + "token_refiner.refiner_blocks.0.attn.to_v", + "token_refiner.refiner_blocks.0.ff.net.2", + "proj_in", + "audio_proj_in", + "context_embedder", + "time_embedder.linear_1", + "time_embedder.linear_2", + "norm_out.linear", + "proj_out", + "audio_proj_out", + ] + for ab in ["A", "B"] + } + + # `alpha == rank` here, as it is in the published file, so the fold is the identity and the tensors arrive + # untouched apart from the qkv split and the `mlp.fc1` half swap. + assert torch.equal( + converted["transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight"], + state_dict["lora_unet_blocks_0_attn_out_proj.lora_up.weight"], + ) + fused_up = state_dict["lora_unet_blocks_0_mlp_fc1.lora_up.weight"] + ffn_dim = fused_up.shape[0] // 2 + assert torch.equal( + converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"], + torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]]), + ) + + def test_load_lora_weights_flattened_layout(self): + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_flattened_lora_state_dict(), adapter_name="dummy") + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 19 + assert {module.scaling["dummy"] for module in injected} == {1.0} + + def test_lora_state_dict_conversion_flattened_layout_raises_on_an_unknown_module(self): + state_dict = self.get_dummy_flattened_lora_state_dict() + state_dict["lora_unet_blocks_0_not_a_module.lora_down.weight"] = torch.randn(4, 8) + + with pytest.raises(ValueError, match="not_a_module"): + self.pipeline_class.lora_state_dict(state_dict) + + def test_lora_state_dict_folds_explicit_alphas_once(self): + r""" + Files that ship explicit `.alpha` scalars must apply at `alpha / rank` per module. That ratio is folded into the + weights during conversion, not carried to peft as a network alpha, so the loaded adapter shows `alpha == rank` + and a peft scale of 1.0 while the update itself carries the ratio. Both halves are asserted: dropping the fold + under-scales the update, and passing the alpha on as well would square the ratio. Two modules of the same rank + carry different alphas here, which no rank-keyed `alpha_pattern` could express. + """ + config = self.get_pipeline().transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + + state_dict, expected = {}, {} + for source, target, in_features, out_features, rank, alpha in [ + ("blocks.0.attn.out_proj", "transformer_blocks.0.attn.to_out.0", inner, hidden, 8, 2.0), + ("blocks.0.mlp.fc2", "transformer_blocks.0.ff.net.2", config.ffn_dim, hidden, 8, 4.0), + ( + "blocks.0.adaln_proj.linear", + "transformer_blocks.0.adaln_proj.linear", + config.time_embed_dim, + 6 * 3 * hidden, + 4, + 2.0, + ), + ("final_layer.adaln_proj.linear", "norm_out.linear", config.time_embed_dim, 2 * hidden, 4, 1.0), + ]: + down, up = torch.randn(rank, in_features), torch.randn(out_features, rank) + state_dict[f"diffusion_model.{source}.lora_A.weight"] = down + state_dict[f"diffusion_model.{source}.lora_B.weight"] = up + state_dict[f"diffusion_model.{source}.alpha"] = torch.tensor(alpha) + expected[target] = (alpha / rank) * (up @ down) + + converted, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) + + assert not any("alpha" in key for key in converted) + for target, delta in expected.items(): + folded = ( + converted[f"transformer.{target}.lora_B.weight"] @ converted[f"transformer.{target}.lora_A.weight"] + ) + assert torch.allclose(folded, delta, atol=1e-5), target + # `alpha == rank` everywhere, so peft multiplies the already-folded update by exactly 1.0. + assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] + + pipe = self.get_pipeline() + pipe.load_lora_weights(state_dict, adapter_name="dummy") + + for target, delta in expected.items(): + module = pipe.transformer.get_submodule(target) + assert module.scaling["dummy"] == 1.0 + assert module.lora_alpha["dummy"] == module.r["dummy"] + applied = module.scaling["dummy"] * (module.lora_B["dummy"].weight @ module.lora_A["dummy"].weight) + assert torch.allclose(applied, delta, atol=1e-5), target + + def get_dummy_unprefixed_diffusers_lora_state_dict(self, rank=4): + r""" + One producer publishes its own converter's output: diffusers module names with split q/k/v, peft's + `.default.` adapter-name infix left in every key, and no component prefix. + """ + config = self.get_pipeline().transformer.config + hidden = config.hidden_size + inner = config.num_attention_heads * config.attention_head_dim + + state_dict = {} + for module, in_features, out_features in [ + ("transformer_blocks.0.attn.to_q", hidden, inner), + ("transformer_blocks.0.attn.to_k", hidden, inner), + ("transformer_blocks.0.attn.to_v", hidden, inner), + ("transformer_blocks.0.attn.to_out.0", inner, hidden), + ("transformer_blocks.0.ff.net.0.proj", hidden, 2 * config.ffn_dim), + ("transformer_blocks.0.ff.net.2", config.ffn_dim, hidden), + ("token_refiner.refiner_blocks.0.attn.to_q", hidden, inner), + ("token_refiner.refiner_blocks.0.ff.net.2", config.ffn_dim, hidden), + ]: + state_dict[f"{module}.lora_A.default.weight"] = torch.randn(rank, in_features) + state_dict[f"{module}.lora_B.default.weight"] = torch.randn(out_features, rank) + return state_dict + + def test_lora_state_dict_unprefixed_diffusers_format(self): + r"""The `.default.` infix is dropped and the `transformer.` prefix added; no module name is translated.""" + state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict() + + converted = self.pipeline_class.lora_state_dict(state_dict) + + assert all(key.startswith("transformer.") and ".default." not in key for key in converted) + assert torch.equal( + converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], + state_dict["transformer_blocks.0.attn.to_q.lora_A.default.weight"], + ) + assert "transformer.token_refiner.refiner_blocks.0.ff.net.2.lora_B.weight" in converted + + def test_lora_state_dict_unprefixed_diffusers_format_with_a_bias(self): + r""" + The infix is detected per-key, not by requiring it on every key: an adapter trained with `lora_bias=True` + carries a `lora_B.default.bias` alongside the weights, and demanding the infix on *all* keys used to reject the + whole file, which then fell through to the module-name converter and died there on leftover keys. + """ + state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict() + bias_key = "transformer_blocks.0.attn.to_q.lora_B.default.bias" + state_dict[bias_key] = torch.randn(state_dict["transformer_blocks.0.attn.to_q.lora_B.default.weight"].shape[0]) + + converted = self.pipeline_class.lora_state_dict(state_dict) + + assert all(key.startswith("transformer.") and ".default." not in key for key in converted) + # The bias keeps its own suffix; only the adapter-name infix is dropped. + assert torch.equal(converted["transformer.transformer_blocks.0.attn.to_q.lora_B.bias"], state_dict[bias_key]) + assert "transformer.transformer_blocks.0.attn.to_q.lora_B.weight" in converted + + def test_load_lora_weights_unprefixed_diffusers_format(self): + pipe = self.get_pipeline() + + pipe.load_lora_weights(self.get_dummy_unprefixed_diffusers_lora_state_dict(), adapter_name="dummy") + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 8 + assert {module.scaling["dummy"] for module in injected} == {1.0} + + def test_load_lora_weights_warns_when_nothing_is_targeted(self): + r""" + An unrecognized layout that keeps the substring `lora` in every key passes the format check and then filters to + nothing in both partitions. Neither partition branch would run, so without this warning the load is a silent + no-op. The message follows `PeftAdapterMixin.load_lora_adapter`'s wording, which is what every single-denoiser + model emits in the same situation. + """ + pipe = self.get_pipeline() + state_dict = { + "some_other_model.layers.0.lora_A.weight": torch.randn(4, 24), + "some_other_model.layers.0.lora_B.weight": torch.randn(24, 4), + } + + logger = logging.get_logger("diffusers.loaders.lora_pipeline") + logger.setLevel(logging.WARNING) + with CaptureLogger(logger) as cap_logger: + pipe.load_lora_weights(state_dict, adapter_name="dummy") + + assert cap_logger.out.startswith("No LoRA keys associated to MiniMaxH3ModularPipeline") + assert "some_other_model.layers.0.lora_A.weight" in cap_logger.out + # Nothing was loaded into either partition. + for component in [pipe.transformer, pipe.transformer_ref]: + assert "dummy" not in getattr(component, "peft_config", {}) + assert not [module for module in component.modules() if "dummy" in getattr(module, "scaling", {})] + def test_lora_state_dict_synthesizes_unit_scale_metadata(self): r""" A non-diffusers H3 LoRA has no alpha information and applies as `W + lora_B @ lora_A`. `get_peft_kwargs` reads @@ -144,6 +387,109 @@ def test_lora_state_dict_synthesizes_unit_scale_metadata(self): assert set(metadata["transformer.rank_pattern"].values()) == {2} assert "^norm_out.linear" in metadata["transformer.rank_pattern"] + def save_with_file_metadata(self, state_dict, tmpdir, alpha="8"): + r""" + One producer records the alpha it trained with in the safetensors `__metadata__` instead of in per-module + scalars, so the value exists only on disk — a state dict handed over in memory cannot carry it. + """ + weight_name = "pytorch_lora_weights.safetensors" + safetensors.torch.save_file( + state_dict, os.path.join(tmpdir, weight_name), metadata={"floating_dtype": "bfloat16", "alpha": alpha} + ) + return weight_name + + def test_lora_state_dict_honors_the_metadata_alpha(self): + r""" + The 8-step turbo LoRA's header in miniature: diffusers module names with peft's `.default.` infix, one uniform + rank, no `.alpha` scalars anywhere, and `alpha` "8" in the file's own `__metadata__`. Rank 128 against alpha 8 + is a trained scale of 0.0625; synthesizing `alpha == rank` instead applies the adapter 16x too strongly. + """ + state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict(rank=128) + + with tempfile.TemporaryDirectory() as tmpdir: + weight_name = self.save_with_file_metadata(state_dict, tmpdir) + _, metadata = self.pipeline_class.lora_state_dict( + tmpdir, weight_name=weight_name, return_lora_metadata=True + ) + + assert metadata["transformer.r"] == 128 + assert metadata["transformer.lora_alpha"] == 8.0 + # The alpha is uniform, so every module reads it off `lora_alpha` and only the ranks need a pattern. + assert metadata["transformer.alpha_pattern"] == {} + + pipe = self.get_pipeline() + pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 8 + assert {module.scaling["dummy"] for module in injected} == {0.0625} + + def test_lora_state_dict_prefers_alpha_tensors_over_the_metadata_alpha(self): + r""" + A file carrying both must not apply an alpha twice. The converter folds each module's `.alpha` into the weights, + so the `__metadata__` entry is ignored and the adapter loads at `alpha == rank`. The mixed rank is what makes + this discriminating: honoring `alpha` "8" here would scale the rank-2 modules by 4.0. + """ + state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) + for key in [k for k in state_dict if k.endswith(".lora_A.weight")]: + state_dict[f"{key.removesuffix('.lora_A.weight')}.alpha"] = torch.tensor(2.0) + expected = {} + for source, target, module_rank in [ + ("blocks.0.attn.out_proj", "transformer_blocks.0.attn.to_out.0", 8), + ("final_layer.adaln_proj.linear", "norm_out.linear", 2), + ]: + up = state_dict[f"diffusion_model.{source}.lora_B.weight"] + down = state_dict[f"diffusion_model.{source}.lora_A.weight"] + expected[target] = (2.0 / module_rank) * (up @ down) + + with tempfile.TemporaryDirectory() as tmpdir: + weight_name = self.save_with_file_metadata(state_dict, tmpdir) + _, metadata = self.pipeline_class.lora_state_dict( + tmpdir, weight_name=weight_name, return_lora_metadata=True + ) + + assert metadata["transformer.lora_alpha"] == metadata["transformer.r"] == 8 + assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] + assert set(metadata["transformer.rank_pattern"].values()) == {2} + + pipe = self.get_pipeline() + pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") + + for target, delta in expected.items(): + module = pipe.transformer.get_submodule(target) + assert module.scaling["dummy"] == 1.0 + applied = module.scaling["dummy"] * (module.lora_B["dummy"].weight @ module.lora_A["dummy"].weight) + assert torch.allclose(applied, delta, atol=1e-5), target + + def test_lora_state_dict_warns_on_a_non_numeric_metadata_alpha(self): + r""" + `alpha` is a generic word for a header entry, so a file can carry one that means something else entirely. Such a + value is warned about and ignored rather than refused — the file still loads, at the `alpha == rank` the + alpha-less synthesis pins. + """ + state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict(rank=8) + + with tempfile.TemporaryDirectory() as tmpdir: + weight_name = self.save_with_file_metadata(state_dict, tmpdir, alpha="high") + + logger = logging.get_logger("diffusers.loaders.lora_pipeline") + logger.setLevel(logging.WARNING) + with CaptureLogger(logger) as cap_logger: + _, metadata = self.pipeline_class.lora_state_dict( + tmpdir, weight_name=weight_name, return_lora_metadata=True + ) + + assert "'high'" in cap_logger.out + assert metadata["transformer.lora_alpha"] == metadata["transformer.r"] == 8 + assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] + + pipe = self.get_pipeline() + pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") + + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + assert len(injected) == 8 + assert {module.scaling["dummy"] for module in injected} == {1.0} + def get_dummy_diffusers_lora_state_dict(self, prefix="transformer", rank=8, adaln_rank=2): r""" The same adapter already converted to diffusers keys and republished — which is how the public turbo LoRA also From 9976bd25b67002cbb1719023388dc34b4d6fa06b Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 20:23:18 +0000 Subject: [PATCH 05/11] Simplify the MiniMax-H3 LoRA comments --- src/diffusers/loaders/lora_pipeline.py | 80 ++++++++------------------ 1 file changed, 24 insertions(+), 56 deletions(-) diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index db5e81a0efc6..8edfdd468767 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7049,23 +7049,15 @@ class MiniMaxH3LoraLoaderMixin(LoraBaseMixin): r""" Load LoRA layers into [`MiniMaxH3Transformer3DModel`]. Specific to [`MiniMaxH3ModularPipeline`]. - MiniMax-H3 holds two independently trained DiT partitions in one repository — `transformer/` for the `t2va` and - `fl2va` workflows, `transformer_ref/` for `ref2va` — and a workflow loads only its own. The two are separate - checkpoints with nothing tied between them, and their module names are identical, so a LoRA trained against one - loads without error into the other and silently produces garbage. Nothing in a published H3 LoRA records which - partition it was trained against, so the routing is explicit: a converted state dict targets `transformer.`, and - `transformer_ref` is reached either by a `transformer_ref.`-prefixed file (what `save_lora_weights` writes) or by - passing `load_into_transformer_ref=True`. - - Two things to know about third-party H3 LoRAs. LoRAs trained against a *pruned* checkpoint do not load: pruned - releases replace the timestep MLP with a small interpolation table, so their AdaLN projections take an 8-wide input - instead of `time_embed_dim`, and the update cannot be mapped onto the released checkpoint — loading fails with a - size mismatch naming the module. And most published H3 LoRAs carry no alpha information while applying as `W + - lora_B @ lora_A`, so mixed-rank files are loaded with `alpha == rank` per module (effective scale exactly 1.0) — - unless the file records the alpha it was trained with in its own safetensors `__metadata__`, under `alpha`, which - is then honored for every module as `alpha / rank`. Their updates are also small enough relative to the base - weights that [`~MiniMaxH3LoraLoaderMixin.fuse_lora`] into bfloat16 discards most of the update — prefer the default - unfused path. + MiniMax-H3 ships two independent DiT partitions with identical module names (a LoRA for one loads into the other + and degrades output), so routing is explicit: converted state dicts target `transformer.`; reach `transformer_ref` + with a `transformer_ref.`-prefixed file or `load_into_transformer_ref=True`. + + LoRAs trained against a pruned checkpoint (the `*_pruned_*` files in + [Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3); + [joyfox/MiniMax-H3-Turbo](https://huggingface.co/joyfox/MiniMax-H3-Turbo) is one) fail with a size mismatch. + Alpha-less files load at `alpha == rank` (scale 1.0); a `__metadata__` `alpha` entry, when present, is honored + instead. """ _lora_loadable_modules = ["transformer", "transformer_ref"] @@ -7100,6 +7092,7 @@ def lora_state_dict( user_agent = {"file_type": "attn_procs_weights", "framework": "pytorch"} + # `return_file_metadata=True` because some H3 LoRAs record their training alpha in the file's `__metadata__`. state_dict, metadata, file_metadata = _fetch_state_dict( pretrained_model_name_or_path_or_dict=pretrained_model_name_or_path_or_dict, weight_name=weight_name, @@ -7125,20 +7118,12 @@ def lora_state_dict( logger.warning(warn_msg) state_dict = {k: v for k, v in state_dict.items() if "dora_scale" not in k} - # One producer publishes its own converter's output: diffusers module names, but with peft's adapter-name - # infix left in the keys and no component prefix. Neither needs the module-name conversion below, so the - # infix is dropped and the prefix added here. Gating on the infix the way `QwenImageLoraLoaderMixin` and - # `ZImageLoraLoaderMixin` do, together with requiring that no key carries a component prefix, keeps this from - # shadowing a file diffusers itself wrote — `write_lora_layers` never emits `.default.`. - # - # The module names really are diffusers' own, not a look-alike basis: that producer ships the same adapter in - # both encodings, and converting the original-format copy reproduces this one's `lora_B @ lora_A` exactly, to - # 0.0 relative error on all 312 modules. + # A peft dump: diffusers module names carrying peft's `.default.` infix and no component prefix. The missing + # prefix is what keeps this from shadowing a file diffusers itself wrote. is_unprefixed_diffusers_format = any(".default.weight" in k for k in state_dict) and not any( k.startswith((f"{cls.transformer_name}.", f"{cls.transformer_ref_name}.")) for k in state_dict ) if is_unprefixed_diffusers_format: - # Strips the infix off `lora_B.default.bias` as well, which a `lora_bias=True` adapter carries. state_dict = {f"{cls.transformer_name}.{k.replace('.default.', '.')}": v for k, v in state_dict.items()} # ai-toolkit writes the original checkpoint's module names under a `diffusion_model.` prefix, the reference @@ -7151,27 +7136,11 @@ def lora_state_dict( if is_non_diffusers_format: state_dict = _convert_non_diffusers_minimax_h3_lora_to_diffusers(state_dict) - # Every published MiniMax-H3 LoRA is alpha-less, MIXED-RANK (rank 64 on attention and FFN modules, rank 16 - # on the AdaLN projections) and applies as `W + lora_B @ lora_A`, i.e. at an effective scale of 1.0 *per - # module*. `get_peft_kwargs` reads `lora_alpha` off whichever rank it happens to see first and never - # re-derives it, so one of the two rank groups would be silently scaled by `alpha / r`. The `LoraConfig` is - # therefore built here with `alpha == rank` everywhere and passed on as metadata, which `load_lora_adapter` - # uses in place of its own `get_peft_kwargs` inference — that inference recovers everything else (ranks, - # target modules), just not this alpha correction. Keying the synthesis off the absence of alpha information - # rather than off the conversion is deliberate: the same file also circulates pre-converted to diffusers - # keys, and that copy needs the same treatment. - # - # A file that *did* ship `.alpha` scalars is not affected: the converter folds `alpha / rank` into the weights - # per module and removes the scalars, so `alpha == rank` here is what leaves that fold applied exactly once. - # Folding rather than forwarding is what lets two modules of the same rank carry different alphas, which a - # rank-keyed `alpha_pattern` cannot express. + # Published H3 LoRAs are alpha-less and mixed-rank (64 on attention and FFN, 16 on the AdaLN projections), and + # `get_peft_kwargs` would scale one of the two rank groups by `alpha / r`, so `alpha == rank` is pinned below. # - # One producer instead records the single alpha it trained with in the file's own `__metadata__`, under - # `alpha`, and ships no scalars: its 8-step turbo LoRA pairs `alpha` "8" with rank 128, i.e. an effective - # scale of 0.0625 rather than 1.0. That entry is the only alpha information such a file has, so it is honored - # as a uniform network alpha — `lora_alpha` is taken from it and `alpha_pattern` left empty, which has peft - # scale every module by `alpha / rank`, the off-majority ranks in `rank_pattern` included. Per-module scalars - # win when a file carries both, since the fold above has already applied them. + # An alpha-less file may instead record its training alpha in the `__metadata__`, honored as a uniform network + # alpha (`alpha / rank` per module). Per-module scalars win, the fold above having already applied them. network_alpha = None if file_metadata is not None and "alpha" in file_metadata and not has_alpha_tensors: try: @@ -7188,9 +7157,8 @@ def lora_state_dict( "module is scaled by `alpha / rank`." ) - # `has_alpha_tensors` above was read before the conversion, which folds a file's own `.alpha` scalars into the - # weights and drops them; this probe is the post-conversion one, and it is what catches an already-diffusers- - # keyed file that carries `.alpha` and never went through the converter at all. + # The converter folds per-module `.alpha` scalars into the factors, as the kohya converters do, so the probe + # has to run again after it: `has_alpha_tensors` would skip the synthesis converted files still need. if metadata is None and not any(k.endswith(".alpha") for k in state_dict): metadata = {} for prefix in (cls.transformer_name, cls.transformer_ref_name): @@ -7208,7 +7176,6 @@ def lora_state_dict( lora_config_kwargs["lora_alpha"] = network_alpha lora_config_kwargs["alpha_pattern"] = {} else: - # The same fix-up `PeftAdapterMixin.load_lora_adapter` applies to SAI control LoRAs. lora_config_kwargs["lora_alpha"] = lora_config_kwargs["r"] lora_config_kwargs["alpha_pattern"] = lora_config_kwargs["rank_pattern"] metadata.update(_pack_dict_with_prefix(lora_config_kwargs, prefix)) @@ -7282,6 +7249,12 @@ def load_lora_weights( ) return + if transformer_ref_state_dict and transformer_ref is None: + raise ValueError( + f"This LoRA has `{self.transformer_ref_name}.`-prefixed layers but the pipeline does not hold a " + f'`{self.transformer_ref_name}` component. Load it with `workflow="ref2va"`.' + ) + if transformer_state_dict: # `transformer.`-prefixed layers go to `transformer_ref` when the caller asks for it, and also when # `transformer_ref` is the only partition present — which is what `workflow="ref2va"` loads. @@ -7323,11 +7296,6 @@ def load_lora_weights( ) if transformer_ref_state_dict: - if transformer_ref is None: - raise ValueError( - f"This LoRA has `{self.transformer_ref_name}.`-prefixed layers but the pipeline does not hold a " - f'`{self.transformer_ref_name}` component. Load it with `workflow="ref2va"`.' - ) self.load_lora_into_transformer_ref( transformer_ref_state_dict, transformer_ref=transformer_ref, From bbe95f657161b6ad58af4dc79d28b35a952eeea8 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 20:32:22 +0000 Subject: [PATCH 06/11] Trim the MiniMax-H3 LoRA tests to loading and effectiveness --- tests/lora/test_lora_layers_minimax_h3.py | 247 ++-------------------- 1 file changed, 16 insertions(+), 231 deletions(-) diff --git a/tests/lora/test_lora_layers_minimax_h3.py b/tests/lora/test_lora_layers_minimax_h3.py index 1a274b619ca7..2a16284dd993 100644 --- a/tests/lora/test_lora_layers_minimax_h3.py +++ b/tests/lora/test_lora_layers_minimax_h3.py @@ -32,10 +32,9 @@ @require_peft_backend class TestMiniMaxH3LoraLayers: """ - The MiniMax-H3 LoRA surface that is specific to this model and its two checkpoint partitions: the conversion of - the two circulating non-diffusers formats (fused projections, original module names, no alpha keys), the - `alpha == rank` metadata synthesis for alpha-less files, and the routing between the `transformer` and - `transformer_ref` partitions. Generic LoRA behavior is not tested here. + The MiniMax-H3 LoRA surface that is specific to this model and its two checkpoint partitions: loading the layouts + that circulate, the alpha handling that gets each of them to its trained scale, and the routing between the + `transformer` and `transformer_ref` partitions. Generic LoRA behavior is not tested here. """ pipeline_class = MiniMaxH3ModularPipeline @@ -75,60 +74,6 @@ def get_dummy_lora_state_dict(self, prefix="diffusion_model.", rank=4, adaln_ran state_dict[f"{prefix}{source}.lora_B.weight"] = torch.randn(out_features, module_rank) return state_dict - def test_lora_state_dict_conversion(self): - r"""The original module names map onto the diffusers ones, fused projections split, `mlp.fc1` halves swap.""" - state_dict = self.get_dummy_lora_state_dict() - rank = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight"].shape[0] - fused_up = state_dict["diffusion_model.blocks.0.mlp.fc1.lora_B.weight"] - qkv_up = state_dict["diffusion_model.blocks.0.attn.qkv_proj.lora_B.weight"] - - converted = self.pipeline_class.lora_state_dict(state_dict) - - assert "transformer.transformer_blocks.0.attn.to_q.lora_A.weight" in converted - assert "transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight" in converted - assert "transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight" in converted - assert "transformer.transformer_blocks.0.ff.net.2.lora_B.weight" in converted - assert "transformer.transformer_blocks.0.adaln_proj.linear.lora_A.weight" in converted - assert "transformer.token_refiner.refiner_blocks.0.attn.to_v.lora_B.weight" in converted - assert "transformer.norm_out.linear.lora_B.weight" in converted - assert not any("qkv_proj" in key or "fc1" in key or "final_layer" in key for key in converted) - - # The fused QKV splits into three row blocks that share `lora_A`. - inner = qkv_up.shape[0] // 3 - for index, projection in enumerate(["to_q", "to_k", "to_v"]): - prefix = f"transformer.transformer_blocks.0.attn.{projection}" - assert torch.equal(converted[f"{prefix}.lora_B.weight"], qkv_up[index * inner : (index + 1) * inner]) - assert torch.equal( - converted[f"{prefix}.lora_A.weight"], - converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], - ) - - # `mlp.fc1` is `[gate; value]` and `SwiGLU.proj` is `[value; gate]`, so `lora_B`'s halves swap and `lora_A` - # is untouched. A key-name-only assertion would pass with the swap missing, which is a silent quality bug. - ffn_dim = fused_up.shape[0] // 2 - swapped = converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"] - assert torch.equal(swapped, torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]])) - assert torch.equal( - converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight"], - state_dict["diffusion_model.blocks.0.mlp.fc1.lora_A.weight"], - ) - assert all(value.shape[1] == rank or value.shape[0] == rank for value in converted.values()) - - def test_lora_state_dict_conversion_without_a_prefix(self): - r"""The one public H3 LoRA has no prefix at all, so the module names are what identifies the format.""" - converted = self.pipeline_class.lora_state_dict(self.get_dummy_lora_state_dict(prefix="")) - - assert "transformer.transformer_blocks.0.attn.to_k.lora_B.weight" in converted - assert all(key.startswith("transformer.") for key in converted) - - def test_lora_state_dict_conversion_raises_on_an_unknown_module(self): - state_dict = self.get_dummy_lora_state_dict() - state_dict["diffusion_model.blocks.0.not_a_module.lora_A.weight"] = torch.randn(4, 8) - state_dict["diffusion_model.blocks.0.not_a_module.lora_B.weight"] = torch.randn(8, 4) - - with pytest.raises(ValueError, match="not_a_module"): - self.pipeline_class.lora_state_dict(state_dict) - def get_dummy_flattened_lora_state_dict(self, rank=4, alpha=4.0): r""" musubi-tuner's layout: one flat `lora_unet_` module name per key with every `.` collapsed to `_`, kohya @@ -164,55 +109,6 @@ def get_dummy_flattened_lora_state_dict(self, rank=4, alpha=4.0): state_dict[f"lora_unet_{module}.alpha"] = torch.tensor(alpha) return state_dict - def test_lora_state_dict_conversion_flattened_layout(self): - r""" - musubi-tuner's flat names are un-flattened against the module vocabulary, never by turning `_` into `.`: - `qkv_proj`, `out_proj`, `token_refiner`, `time_embedder`, `final_layer` and the two output heads all carry - underscores that are not path separators. - """ - state_dict = self.get_dummy_flattened_lora_state_dict() - - converted = self.pipeline_class.lora_state_dict(state_dict) - - assert set(converted) == { - f"transformer.{module}.lora_{ab}.weight" - for module in [ - "transformer_blocks.0.attn.to_q", - "transformer_blocks.0.attn.to_k", - "transformer_blocks.0.attn.to_v", - "transformer_blocks.0.attn.to_out.0", - "transformer_blocks.0.ff.net.0.proj", - "transformer_blocks.0.ff.net.2", - "transformer_blocks.0.adaln_proj.linear", - "token_refiner.refiner_blocks.0.attn.to_q", - "token_refiner.refiner_blocks.0.attn.to_k", - "token_refiner.refiner_blocks.0.attn.to_v", - "token_refiner.refiner_blocks.0.ff.net.2", - "proj_in", - "audio_proj_in", - "context_embedder", - "time_embedder.linear_1", - "time_embedder.linear_2", - "norm_out.linear", - "proj_out", - "audio_proj_out", - ] - for ab in ["A", "B"] - } - - # `alpha == rank` here, as it is in the published file, so the fold is the identity and the tensors arrive - # untouched apart from the qkv split and the `mlp.fc1` half swap. - assert torch.equal( - converted["transformer.transformer_blocks.0.attn.to_out.0.lora_B.weight"], - state_dict["lora_unet_blocks_0_attn_out_proj.lora_up.weight"], - ) - fused_up = state_dict["lora_unet_blocks_0_mlp_fc1.lora_up.weight"] - ffn_dim = fused_up.shape[0] // 2 - assert torch.equal( - converted["transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight"], - torch.cat([fused_up[ffn_dim:], fused_up[:ffn_dim]]), - ) - def test_load_lora_weights_flattened_layout(self): pipe = self.get_pipeline() @@ -222,14 +118,7 @@ def test_load_lora_weights_flattened_layout(self): assert len(injected) == 19 assert {module.scaling["dummy"] for module in injected} == {1.0} - def test_lora_state_dict_conversion_flattened_layout_raises_on_an_unknown_module(self): - state_dict = self.get_dummy_flattened_lora_state_dict() - state_dict["lora_unet_blocks_0_not_a_module.lora_down.weight"] = torch.randn(4, 8) - - with pytest.raises(ValueError, match="not_a_module"): - self.pipeline_class.lora_state_dict(state_dict) - - def test_lora_state_dict_folds_explicit_alphas_once(self): + def test_load_lora_weights_folds_explicit_alphas_once(self): r""" Files that ship explicit `.alpha` scalars must apply at `alpha / rank` per module. That ratio is folded into the weights during conversion, not carried to peft as a network alpha, so the loaded adapter shows `alpha == rank` @@ -237,7 +126,8 @@ def test_lora_state_dict_folds_explicit_alphas_once(self): under-scales the update, and passing the alpha on as well would square the ratio. Two modules of the same rank carry different alphas here, which no rank-keyed `alpha_pattern` could express. """ - config = self.get_pipeline().transformer.config + pipe = self.get_pipeline() + config = pipe.transformer.config hidden = config.hidden_size inner = config.num_attention_heads * config.attention_head_dim @@ -261,18 +151,6 @@ def test_lora_state_dict_folds_explicit_alphas_once(self): state_dict[f"diffusion_model.{source}.alpha"] = torch.tensor(alpha) expected[target] = (alpha / rank) * (up @ down) - converted, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) - - assert not any("alpha" in key for key in converted) - for target, delta in expected.items(): - folded = ( - converted[f"transformer.{target}.lora_B.weight"] @ converted[f"transformer.{target}.lora_A.weight"] - ) - assert torch.allclose(folded, delta, atol=1e-5), target - # `alpha == rank` everywhere, so peft multiplies the already-folded update by exactly 1.0. - assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] - - pipe = self.get_pipeline() pipe.load_lora_weights(state_dict, adapter_name="dummy") for target, delta in expected.items(): @@ -306,36 +184,6 @@ def get_dummy_unprefixed_diffusers_lora_state_dict(self, rank=4): state_dict[f"{module}.lora_B.default.weight"] = torch.randn(out_features, rank) return state_dict - def test_lora_state_dict_unprefixed_diffusers_format(self): - r"""The `.default.` infix is dropped and the `transformer.` prefix added; no module name is translated.""" - state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict() - - converted = self.pipeline_class.lora_state_dict(state_dict) - - assert all(key.startswith("transformer.") and ".default." not in key for key in converted) - assert torch.equal( - converted["transformer.transformer_blocks.0.attn.to_q.lora_A.weight"], - state_dict["transformer_blocks.0.attn.to_q.lora_A.default.weight"], - ) - assert "transformer.token_refiner.refiner_blocks.0.ff.net.2.lora_B.weight" in converted - - def test_lora_state_dict_unprefixed_diffusers_format_with_a_bias(self): - r""" - The infix is detected per-key, not by requiring it on every key: an adapter trained with `lora_bias=True` - carries a `lora_B.default.bias` alongside the weights, and demanding the infix on *all* keys used to reject the - whole file, which then fell through to the module-name converter and died there on leftover keys. - """ - state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict() - bias_key = "transformer_blocks.0.attn.to_q.lora_B.default.bias" - state_dict[bias_key] = torch.randn(state_dict["transformer_blocks.0.attn.to_q.lora_B.default.weight"].shape[0]) - - converted = self.pipeline_class.lora_state_dict(state_dict) - - assert all(key.startswith("transformer.") and ".default." not in key for key in converted) - # The bias keeps its own suffix; only the adapter-name infix is dropped. - assert torch.equal(converted["transformer.transformer_blocks.0.attn.to_q.lora_B.bias"], state_dict[bias_key]) - assert "transformer.transformer_blocks.0.attn.to_q.lora_B.weight" in converted - def test_load_lora_weights_unprefixed_diffusers_format(self): pipe = self.get_pipeline() @@ -370,23 +218,6 @@ def test_load_lora_weights_warns_when_nothing_is_targeted(self): assert "dummy" not in getattr(component, "peft_config", {}) assert not [module for module in component.modules() if "dummy" in getattr(module, "scaling", {})] - def test_lora_state_dict_synthesizes_unit_scale_metadata(self): - r""" - A non-diffusers H3 LoRA has no alpha information and applies as `W + lora_B @ lora_A`. `get_peft_kwargs` reads - `lora_alpha` off the first rank it sees and never re-derives it, so a mixed-rank file — which the public turbo - LoRA is — would have its majority-rank modules scaled by `alpha / r`. The converted metadata pins - `alpha == rank` for every module instead. - """ - state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) - - _, metadata = self.pipeline_class.lora_state_dict(state_dict, return_lora_metadata=True) - - assert metadata["transformer.r"] == 8 - assert metadata["transformer.lora_alpha"] == 8 - assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] - assert set(metadata["transformer.rank_pattern"].values()) == {2} - assert "^norm_out.linear" in metadata["transformer.rank_pattern"] - def save_with_file_metadata(self, state_dict, tmpdir, alpha="8"): r""" One producer records the alpha it trained with in the safetensors `__metadata__` instead of in per-module @@ -398,7 +229,7 @@ def save_with_file_metadata(self, state_dict, tmpdir, alpha="8"): ) return weight_name - def test_lora_state_dict_honors_the_metadata_alpha(self): + def test_load_lora_weights_honors_the_metadata_alpha(self): r""" The 8-step turbo LoRA's header in miniature: diffusers module names with peft's `.default.` infix, one uniform rank, no `.alpha` scalars anywhere, and `alpha` "8" in the file's own `__metadata__`. Rank 128 against alpha 8 @@ -408,14 +239,6 @@ def test_lora_state_dict_honors_the_metadata_alpha(self): with tempfile.TemporaryDirectory() as tmpdir: weight_name = self.save_with_file_metadata(state_dict, tmpdir) - _, metadata = self.pipeline_class.lora_state_dict( - tmpdir, weight_name=weight_name, return_lora_metadata=True - ) - - assert metadata["transformer.r"] == 128 - assert metadata["transformer.lora_alpha"] == 8.0 - # The alpha is uniform, so every module reads it off `lora_alpha` and only the ranks need a pattern. - assert metadata["transformer.alpha_pattern"] == {} pipe = self.get_pipeline() pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") @@ -424,7 +247,7 @@ def test_lora_state_dict_honors_the_metadata_alpha(self): assert len(injected) == 8 assert {module.scaling["dummy"] for module in injected} == {0.0625} - def test_lora_state_dict_prefers_alpha_tensors_over_the_metadata_alpha(self): + def test_load_lora_weights_prefers_alpha_tensors_over_the_metadata_alpha(self): r""" A file carrying both must not apply an alpha twice. The converter folds each module's `.alpha` into the weights, so the `__metadata__` entry is ignored and the adapter loads at `alpha == rank`. The mixed rank is what makes @@ -433,35 +256,19 @@ def test_lora_state_dict_prefers_alpha_tensors_over_the_metadata_alpha(self): state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) for key in [k for k in state_dict if k.endswith(".lora_A.weight")]: state_dict[f"{key.removesuffix('.lora_A.weight')}.alpha"] = torch.tensor(2.0) - expected = {} - for source, target, module_rank in [ - ("blocks.0.attn.out_proj", "transformer_blocks.0.attn.to_out.0", 8), - ("final_layer.adaln_proj.linear", "norm_out.linear", 2), - ]: - up = state_dict[f"diffusion_model.{source}.lora_B.weight"] - down = state_dict[f"diffusion_model.{source}.lora_A.weight"] - expected[target] = (2.0 / module_rank) * (up @ down) with tempfile.TemporaryDirectory() as tmpdir: weight_name = self.save_with_file_metadata(state_dict, tmpdir) - _, metadata = self.pipeline_class.lora_state_dict( - tmpdir, weight_name=weight_name, return_lora_metadata=True - ) - - assert metadata["transformer.lora_alpha"] == metadata["transformer.r"] == 8 - assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] - assert set(metadata["transformer.rank_pattern"].values()) == {2} pipe = self.get_pipeline() pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") - for target, delta in expected.items(): - module = pipe.transformer.get_submodule(target) - assert module.scaling["dummy"] == 1.0 - applied = module.scaling["dummy"] * (module.lora_B["dummy"].weight @ module.lora_A["dummy"].weight) - assert torch.allclose(applied, delta, atol=1e-5), target + injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] + assert {module.scaling["dummy"] for module in injected} == {1.0} + assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 + assert pipe.transformer.norm_out.linear.r["dummy"] == 2 - def test_lora_state_dict_warns_on_a_non_numeric_metadata_alpha(self): + def test_load_lora_weights_warns_on_a_non_numeric_metadata_alpha(self): r""" `alpha` is a generic word for a header entry, so a file can carry one that means something else entirely. Such a value is warned about and ignored rather than refused — the file still loads, at the `alpha == rank` the @@ -474,18 +281,11 @@ def test_lora_state_dict_warns_on_a_non_numeric_metadata_alpha(self): logger = logging.get_logger("diffusers.loaders.lora_pipeline") logger.setLevel(logging.WARNING) - with CaptureLogger(logger) as cap_logger: - _, metadata = self.pipeline_class.lora_state_dict( - tmpdir, weight_name=weight_name, return_lora_metadata=True - ) - - assert "'high'" in cap_logger.out - assert metadata["transformer.lora_alpha"] == metadata["transformer.r"] == 8 - assert metadata["transformer.alpha_pattern"] == metadata["transformer.rank_pattern"] - pipe = self.get_pipeline() - pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") + with CaptureLogger(logger) as cap_logger: + pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") + assert "'high'" in cap_logger.out injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] assert len(injected) == 8 assert {module.scaling["dummy"] for module in injected} == {1.0} @@ -535,21 +335,6 @@ def test_load_lora_weights_diffusers_format_mixed_rank(self, prefix): assert component.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 assert component.norm_out.linear.r["dummy"] == 2 - def test_lora_state_dict_respects_existing_metadata(self): - r"""A file that carries diffusers' own `lora_adapter_metadata` must not have it overwritten.""" - pipe = self.get_pipeline() - pipe.load_lora_weights(self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy") - layers = get_peft_model_state_dict(pipe.transformer, adapter_name="dummy") - saved_metadata = {"r": 8, "lora_alpha": 8, "rank_pattern": {}, "alpha_pattern": {}, "target_modules": ["x"]} - - with tempfile.TemporaryDirectory() as tmpdir: - self.pipeline_class.save_lora_weights( - tmpdir, transformer_lora_layers=layers, transformer_lora_adapter_metadata=saved_metadata - ) - _, metadata = self.pipeline_class.lora_state_dict(tmpdir, return_lora_metadata=True) - - assert metadata["transformer.target_modules"] == ["x"] - @pytest.mark.parametrize("prefix", ["diffusion_model.", ""], ids=["ai_toolkit", "unprefixed"]) def test_load_lora_weights(self, prefix): r""" From 05695c053b5de06bc711557cedcac3067bf68bb1 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 20:46:38 +0000 Subject: [PATCH 07/11] Reference the alpha convention in the MiniMax-H3 LoRA docstring --- src/diffusers/loaders/lora_pipeline.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index 8edfdd468767..0e47ba1ec98f 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7056,8 +7056,10 @@ class MiniMaxH3LoraLoaderMixin(LoraBaseMixin): LoRAs trained against a pruned checkpoint (the `*_pruned_*` files in [Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3); [joyfox/MiniMax-H3-Turbo](https://huggingface.co/joyfox/MiniMax-H3-Turbo) is one) fail with a size mismatch. - Alpha-less files load at `alpha == rank` (scale 1.0); a `__metadata__` `alpha` entry, when present, is honored - instead. + Alpha-less files load at `alpha == rank` (scale 1.0, the convention stated by e.g. + [larryvrh/MiniMax-H3-Turbo-Lora](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora)); a `__metadata__` `alpha` + entry (e.g. [lightx2v/Minimax-h3-Turbo](https://huggingface.co/lightx2v/Minimax-h3-Turbo)'s 8-step file), when + present, is honored instead. """ _lora_loadable_modules = ["transformer", "transformer_ref"] From 599a27e37b04e838a95147b285f1cba4b897e639 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 20:53:35 +0000 Subject: [PATCH 08/11] Drop a redundant format comment --- src/diffusers/loaders/lora_pipeline.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index 0e47ba1ec98f..d69d8bccce1b 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7128,9 +7128,6 @@ def lora_state_dict( if is_unprefixed_diffusers_format: state_dict = {f"{cls.transformer_name}.{k.replace('.default.', '.')}": v for k, v in state_dict.items()} - # ai-toolkit writes the original checkpoint's module names under a `diffusion_model.` prefix, the reference - # `generate.py` / ComfyUI checkpoints carry no prefix at all, and musubi-tuner flattens them under - # `lora_unet_`, so the module names are what identifies a non-diffusers file. is_non_diffusers_format = any( k.startswith(("diffusion_model.", "blocks.", "token_refiner.", "final_layer.", "lora_unet_")) for k in state_dict From a41321aa2af743ccb103a0f5c91f0ecba5ba478a Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 21:07:45 +0000 Subject: [PATCH 09/11] Test only diffusers-written state dicts --- tests/lora/test_lora_layers_minimax_h3.py | 236 ++-------------------- 1 file changed, 12 insertions(+), 224 deletions(-) diff --git a/tests/lora/test_lora_layers_minimax_h3.py b/tests/lora/test_lora_layers_minimax_h3.py index 2a16284dd993..997cfe91c1dd 100644 --- a/tests/lora/test_lora_layers_minimax_h3.py +++ b/tests/lora/test_lora_layers_minimax_h3.py @@ -47,152 +47,6 @@ def get_pipeline(self): pipeline.set_progress_bar_config(disable=None) return pipeline - def get_dummy_lora_state_dict(self, prefix="diffusion_model.", rank=4, adaln_rank=None): - r""" - A LoRA in the layout both real-world producers emit: the *original* checkpoint's module names, fused - `attn.qkv_proj` and `mlp.fc1`, and no `.alpha`. ai-toolkit prefixes them with `diffusion_model.`; the one - public H3 LoRA carries no prefix at all, which `prefix=""` reproduces. `adaln_rank` makes the file mixed-rank, - as that LoRA is. - """ - transformer = self.get_pipeline().transformer - config = transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - adaln_rank = adaln_rank or rank - - state_dict = {} - for source, in_features, out_features, module_rank in [ - ("blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), - ("blocks.0.attn.out_proj", inner, hidden, rank), - ("blocks.0.mlp.fc1", hidden, 2 * config.ffn_dim, rank), - ("blocks.0.mlp.fc2", config.ffn_dim, hidden, rank), - ("blocks.0.adaln_proj.linear", config.time_embed_dim, 6 * 3 * hidden, adaln_rank), - ("token_refiner.blocks.0.attn.qkv_proj", hidden, 3 * inner, rank), - ("final_layer.adaln_proj.linear", config.time_embed_dim, 2 * hidden, adaln_rank), - ]: - state_dict[f"{prefix}{source}.lora_A.weight"] = torch.randn(module_rank, in_features) - state_dict[f"{prefix}{source}.lora_B.weight"] = torch.randn(out_features, module_rank) - return state_dict - - def get_dummy_flattened_lora_state_dict(self, rank=4, alpha=4.0): - r""" - musubi-tuner's layout: one flat `lora_unet_` module name per key with every `.` collapsed to `_`, kohya - `lora_down`/`lora_up` tensors and an explicit scalar `.alpha`. The module names carry underscores of their own, - so every name the un-flattening has to disambiguate is present here, not just the four the published LoRA in - this layout happens to train. - """ - config = self.get_pipeline().transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - video_patch_dim = config.in_channels * config.patch_size[0] * config.patch_size[1] * config.patch_size[2] - - state_dict = {} - for module, in_features, out_features in [ - ("blocks_0_attn_qkv_proj", hidden, 3 * inner), - ("blocks_0_attn_out_proj", inner, hidden), - ("blocks_0_mlp_fc1", hidden, 2 * config.ffn_dim), - ("blocks_0_mlp_fc2", config.ffn_dim, hidden), - ("blocks_0_adaln_proj_linear", config.time_embed_dim, 6 * 3 * hidden), - ("token_refiner_blocks_0_attn_qkv_proj", hidden, 3 * inner), - ("token_refiner_blocks_0_mlp_fc2", config.ffn_dim, hidden), - ("video_patch_proj", video_patch_dim, hidden), - ("audio_patch_proj", config.audio_in_channels, hidden), - ("condition_proj", config.text_dim, hidden), - ("time_embedder_proj_in", config.freq_dim, config.time_embed_hidden_dim), - ("time_embedder_proj_out", config.time_embed_hidden_dim, config.time_embed_dim), - ("final_layer_adaln_proj_linear", config.time_embed_dim, 2 * hidden), - ("final_layer_video_out", hidden, video_patch_dim), - ("final_layer_audio_out", hidden, config.audio_in_channels), - ]: - state_dict[f"lora_unet_{module}.lora_down.weight"] = torch.randn(rank, in_features) - state_dict[f"lora_unet_{module}.lora_up.weight"] = torch.randn(out_features, rank) - state_dict[f"lora_unet_{module}.alpha"] = torch.tensor(alpha) - return state_dict - - def test_load_lora_weights_flattened_layout(self): - pipe = self.get_pipeline() - - pipe.load_lora_weights(self.get_dummy_flattened_lora_state_dict(), adapter_name="dummy") - - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - assert len(injected) == 19 - assert {module.scaling["dummy"] for module in injected} == {1.0} - - def test_load_lora_weights_folds_explicit_alphas_once(self): - r""" - Files that ship explicit `.alpha` scalars must apply at `alpha / rank` per module. That ratio is folded into the - weights during conversion, not carried to peft as a network alpha, so the loaded adapter shows `alpha == rank` - and a peft scale of 1.0 while the update itself carries the ratio. Both halves are asserted: dropping the fold - under-scales the update, and passing the alpha on as well would square the ratio. Two modules of the same rank - carry different alphas here, which no rank-keyed `alpha_pattern` could express. - """ - pipe = self.get_pipeline() - config = pipe.transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - - state_dict, expected = {}, {} - for source, target, in_features, out_features, rank, alpha in [ - ("blocks.0.attn.out_proj", "transformer_blocks.0.attn.to_out.0", inner, hidden, 8, 2.0), - ("blocks.0.mlp.fc2", "transformer_blocks.0.ff.net.2", config.ffn_dim, hidden, 8, 4.0), - ( - "blocks.0.adaln_proj.linear", - "transformer_blocks.0.adaln_proj.linear", - config.time_embed_dim, - 6 * 3 * hidden, - 4, - 2.0, - ), - ("final_layer.adaln_proj.linear", "norm_out.linear", config.time_embed_dim, 2 * hidden, 4, 1.0), - ]: - down, up = torch.randn(rank, in_features), torch.randn(out_features, rank) - state_dict[f"diffusion_model.{source}.lora_A.weight"] = down - state_dict[f"diffusion_model.{source}.lora_B.weight"] = up - state_dict[f"diffusion_model.{source}.alpha"] = torch.tensor(alpha) - expected[target] = (alpha / rank) * (up @ down) - - pipe.load_lora_weights(state_dict, adapter_name="dummy") - - for target, delta in expected.items(): - module = pipe.transformer.get_submodule(target) - assert module.scaling["dummy"] == 1.0 - assert module.lora_alpha["dummy"] == module.r["dummy"] - applied = module.scaling["dummy"] * (module.lora_B["dummy"].weight @ module.lora_A["dummy"].weight) - assert torch.allclose(applied, delta, atol=1e-5), target - - def get_dummy_unprefixed_diffusers_lora_state_dict(self, rank=4): - r""" - One producer publishes its own converter's output: diffusers module names with split q/k/v, peft's - `.default.` adapter-name infix left in every key, and no component prefix. - """ - config = self.get_pipeline().transformer.config - hidden = config.hidden_size - inner = config.num_attention_heads * config.attention_head_dim - - state_dict = {} - for module, in_features, out_features in [ - ("transformer_blocks.0.attn.to_q", hidden, inner), - ("transformer_blocks.0.attn.to_k", hidden, inner), - ("transformer_blocks.0.attn.to_v", hidden, inner), - ("transformer_blocks.0.attn.to_out.0", inner, hidden), - ("transformer_blocks.0.ff.net.0.proj", hidden, 2 * config.ffn_dim), - ("transformer_blocks.0.ff.net.2", config.ffn_dim, hidden), - ("token_refiner.refiner_blocks.0.attn.to_q", hidden, inner), - ("token_refiner.refiner_blocks.0.ff.net.2", config.ffn_dim, hidden), - ]: - state_dict[f"{module}.lora_A.default.weight"] = torch.randn(rank, in_features) - state_dict[f"{module}.lora_B.default.weight"] = torch.randn(out_features, rank) - return state_dict - - def test_load_lora_weights_unprefixed_diffusers_format(self): - pipe = self.get_pipeline() - - pipe.load_lora_weights(self.get_dummy_unprefixed_diffusers_lora_state_dict(), adapter_name="dummy") - - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - assert len(injected) == 8 - assert {module.scaling["dummy"] for module in injected} == {1.0} - def test_load_lora_weights_warns_when_nothing_is_targeted(self): r""" An unrecognized layout that keeps the substring `lora` in every key passes the format check and then filters to @@ -231,11 +85,11 @@ def save_with_file_metadata(self, state_dict, tmpdir, alpha="8"): def test_load_lora_weights_honors_the_metadata_alpha(self): r""" - The 8-step turbo LoRA's header in miniature: diffusers module names with peft's `.default.` infix, one uniform - rank, no `.alpha` scalars anywhere, and `alpha` "8" in the file's own `__metadata__`. Rank 128 against alpha 8 - is a trained scale of 0.0625; synthesizing `alpha == rank` instead applies the adapter 16x too strongly. + A file with one uniform rank, no `.alpha` scalars and `alpha` "8" in its own `__metadata__`: rank 128 + against alpha 8 is a trained scale of 0.0625; synthesizing `alpha == rank` would apply the adapter 16x too + strongly. """ - state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict(rank=128) + state_dict = self.get_dummy_diffusers_lora_state_dict(prefix="transformer", rank=128, adaln_rank=128) with tempfile.TemporaryDirectory() as tmpdir: weight_name = self.save_with_file_metadata(state_dict, tmpdir) @@ -244,52 +98,9 @@ def test_load_lora_weights_honors_the_metadata_alpha(self): pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - assert len(injected) == 8 + assert len(injected) == 6 assert {module.scaling["dummy"] for module in injected} == {0.0625} - def test_load_lora_weights_prefers_alpha_tensors_over_the_metadata_alpha(self): - r""" - A file carrying both must not apply an alpha twice. The converter folds each module's `.alpha` into the weights, - so the `__metadata__` entry is ignored and the adapter loads at `alpha == rank`. The mixed rank is what makes - this discriminating: honoring `alpha` "8" here would scale the rank-2 modules by 4.0. - """ - state_dict = self.get_dummy_lora_state_dict(rank=8, adaln_rank=2) - for key in [k for k in state_dict if k.endswith(".lora_A.weight")]: - state_dict[f"{key.removesuffix('.lora_A.weight')}.alpha"] = torch.tensor(2.0) - - with tempfile.TemporaryDirectory() as tmpdir: - weight_name = self.save_with_file_metadata(state_dict, tmpdir) - - pipe = self.get_pipeline() - pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") - - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - assert {module.scaling["dummy"] for module in injected} == {1.0} - assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 - assert pipe.transformer.norm_out.linear.r["dummy"] == 2 - - def test_load_lora_weights_warns_on_a_non_numeric_metadata_alpha(self): - r""" - `alpha` is a generic word for a header entry, so a file can carry one that means something else entirely. Such a - value is warned about and ignored rather than refused — the file still loads, at the `alpha == rank` the - alpha-less synthesis pins. - """ - state_dict = self.get_dummy_unprefixed_diffusers_lora_state_dict(rank=8) - - with tempfile.TemporaryDirectory() as tmpdir: - weight_name = self.save_with_file_metadata(state_dict, tmpdir, alpha="high") - - logger = logging.get_logger("diffusers.loaders.lora_pipeline") - logger.setLevel(logging.WARNING) - pipe = self.get_pipeline() - with CaptureLogger(logger) as cap_logger: - pipe.load_lora_weights(tmpdir, weight_name=weight_name, adapter_name="dummy") - - assert "'high'" in cap_logger.out - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - assert len(injected) == 8 - assert {module.scaling["dummy"] for module in injected} == {1.0} - def get_dummy_diffusers_lora_state_dict(self, prefix="transformer", rank=8, adaln_rank=2): r""" The same adapter already converted to diffusers keys and republished — which is how the public turbo LoRA also @@ -335,38 +146,13 @@ def test_load_lora_weights_diffusers_format_mixed_rank(self, prefix): assert component.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 assert component.norm_out.linear.r["dummy"] == 2 - @pytest.mark.parametrize("prefix", ["diffusion_model.", ""], ids=["ai_toolkit", "unprefixed"]) - def test_load_lora_weights(self, prefix): - r""" - A mixed-rank, alpha-less file — the public turbo LoRA's shape — has to reach every module at its own rank and - at an effective scale of exactly 1.0. - """ + def test_load_lora_weights_into_transformer_ref(self): pipe = self.get_pipeline() pipe.load_lora_weights( - self.get_dummy_lora_state_dict(prefix=prefix, rank=8, adaln_rank=2), adapter_name="dummy" + self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True ) - assert "dummy" in pipe.transformer.peft_config - # Both partitions are loaded here and the file does not say which one it targets, so only `transformer` gets it. - assert "dummy" not in getattr(pipe.transformer_ref, "peft_config", {}) - - injected = [module for module in pipe.transformer.modules() if "dummy" in getattr(module, "scaling", {})] - # 3 split qkv + to_out.0 + the two ff Linears + adaln, the refiner's 3 split qkv, and norm_out - assert len(injected) == 11 - assert {module.scaling["dummy"] for module in injected} == {1.0} - for module in injected: - assert module.lora_A["dummy"].weight.shape[0] == module.r["dummy"] - assert module.lora_alpha["dummy"] == module.r["dummy"] - assert pipe.transformer.transformer_blocks[0].attn.to_q.r["dummy"] == 8 - assert pipe.transformer.transformer_blocks[0].adaln_proj.linear.r["dummy"] == 2 - assert pipe.transformer.norm_out.linear.r["dummy"] == 2 - - def test_load_lora_weights_into_transformer_ref(self): - pipe = self.get_pipeline() - - pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) - assert "dummy" in pipe.transformer_ref.peft_config assert "dummy" not in getattr(pipe.transformer, "peft_config", {}) @@ -376,7 +162,9 @@ def test_save_load_lora_weights_round_trip(self): has to preserve it. """ pipe = self.get_pipeline() - pipe.load_lora_weights(self.get_dummy_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True) + pipe.load_lora_weights( + self.get_dummy_diffusers_lora_state_dict(), adapter_name="dummy", load_into_transformer_ref=True + ) layers = get_peft_model_state_dict(pipe.transformer_ref, adapter_name="dummy") with tempfile.TemporaryDirectory() as tmpdir: @@ -400,7 +188,7 @@ def test_load_lora_weights_routes_to_the_only_partition(self): pipe.load_components(dtype=torch.float32) assert getattr(pipe, "transformer", None) is None - state_dict = self.get_dummy_lora_state_dict() + state_dict = self.get_dummy_diffusers_lora_state_dict() pipe.load_lora_weights(state_dict, adapter_name="dummy") assert "dummy" in pipe.transformer_ref.peft_config @@ -410,6 +198,6 @@ def test_load_lora_weights_raises_without_the_requested_partition(self): pipe.load_components(dtype=torch.float32) assert getattr(pipe, "transformer_ref", None) is None - state_dict = self.get_dummy_lora_state_dict() + state_dict = self.get_dummy_diffusers_lora_state_dict() with pytest.raises(ValueError, match="load_into_transformer_ref"): pipe.load_lora_weights(state_dict, load_into_transformer_ref=True) From 81adeae224fd99bd067f4303937e4b968da6b572 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 21:43:23 +0000 Subject: [PATCH 10/11] Rely on get_peft_kwargs for alpha-less files --- src/diffusers/loaders/lora_pipeline.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index d69d8bccce1b..ebd332eaff64 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -7156,9 +7156,9 @@ def lora_state_dict( "module is scaled by `alpha / rank`." ) - # The converter folds per-module `.alpha` scalars into the factors, as the kohya converters do, so the probe - # has to run again after it: `has_alpha_tensors` would skip the synthesis converted files still need. - if metadata is None and not any(k.endswith(".alpha") for k in state_dict): + # Without a `__metadata__` alpha, no metadata is built: `get_peft_kwargs` already derives `alpha == rank` for + # alpha-less files, which is the fold's contract too. + if metadata is None and network_alpha is not None: metadata = {} for prefix in (cls.transformer_name, cls.transformer_ref_name): component_state_dict = { @@ -7171,12 +7171,8 @@ def lora_state_dict( lora_config_kwargs = get_peft_kwargs( rank, network_alpha_dict=None, peft_state_dict=component_state_dict, is_unet=False ) - if network_alpha is not None: - lora_config_kwargs["lora_alpha"] = network_alpha - lora_config_kwargs["alpha_pattern"] = {} - else: - lora_config_kwargs["lora_alpha"] = lora_config_kwargs["r"] - lora_config_kwargs["alpha_pattern"] = lora_config_kwargs["rank_pattern"] + lora_config_kwargs["lora_alpha"] = network_alpha + lora_config_kwargs["alpha_pattern"] = {} metadata.update(_pack_dict_with_prefix(lora_config_kwargs, prefix)) metadata = metadata or None From 552c00e961b764ffcd6a1709ee3f0b943e2211f4 Mon Sep 17 00:00:00 2001 From: apolinario Date: Thu, 13 Aug 2026 22:04:07 +0000 Subject: [PATCH 11/11] Drop the peft version guards after the main cleanup --- src/diffusers/loaders/lora_pipeline.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/diffusers/loaders/lora_pipeline.py b/src/diffusers/loaders/lora_pipeline.py index 0cd2b5b7aecb..aee68486c68d 100644 --- a/src/diffusers/loaders/lora_pipeline.py +++ b/src/diffusers/loaders/lora_pipeline.py @@ -6892,15 +6892,7 @@ def load_lora_weights( workflow denoises with — instead of `transformer`. Only needed when both partitions are loaded: a pipeline that holds `transformer_ref` alone routes there on its own. """ - if not USE_PEFT_BACKEND: - raise ValueError("PEFT backend is required for this method.") - low_cpu_mem_usage = kwargs.pop("low_cpu_mem_usage", _LOW_CPU_MEM_USAGE_DEFAULT_LORA) - if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): - raise ValueError( - "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." - ) - # if a dict is passed, copy it instead of modifying it inplace if isinstance(pretrained_model_name_or_path_or_dict, dict): pretrained_model_name_or_path_or_dict = pretrained_model_name_or_path_or_dict.copy() @@ -7012,11 +7004,6 @@ def load_lora_into_transformer( """ See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more details. """ - if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): - raise ValueError( - "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." - ) - # Load the layers corresponding to transformer. logger.info(f"Loading {cls.transformer_name}.") transformer.load_lora_adapter( @@ -7047,11 +7034,6 @@ def load_lora_into_transformer_ref( `load_into_transformer_ref=True`. See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more details. """ - if low_cpu_mem_usage and is_peft_version("<", "0.13.0"): - raise ValueError( - "`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`." - ) - logger.info(f"Loading {prefix}.") transformer_ref.load_lora_adapter( state_dict,