[diffusion] feat: support loading pruned minimax h3 components natively (#36070)

This commit is contained in:
Mick
2026-08-24 16:39:37 +08:00
committed by GitHub
parent adc09a1f63
commit 51b27f747a
8 changed files with 269 additions and 16 deletions
@@ -22,11 +22,14 @@ class MiniMaxH3DiTArchConfig(DiTArchConfig):
r"^context_embedder\.(.*)$": r"condition_proj.\1",
r"^time_embedder\.linear_1\.(.*)$": r"time_embedder.proj_in.\1",
r"^time_embedder\.linear_2\.(.*)$": r"time_embedder.proj_out.\1",
r"^time_embedder\.table$": r"adaln_t_table",
r"^norm_out\.norm\.(.*)$": r"final_layer.norm.\1",
r"^norm_out\.folded_bias$": r"final_layer.adaln_proj.linear.bias",
r"^norm_out\.linear\.(.*)$": r"final_layer.adaln_proj.linear.\1",
r"^proj_out\.(.*)$": r"final_layer.video_out.\1",
r"^audio_proj_out\.(.*)$": r"final_layer.audio_out.\1",
r"^transformer_blocks\.(\d+)\.adaln_proj\.linear\.(.*)$": r"blocks.\1.adaln_proj.linear.\2",
r"^transformer_blocks\.(\d+)\.adaln_proj\.folded_bias$": r"blocks.\1.adaln_proj.linear.bias",
r"^transformer_blocks\.(\d+)\.attn\.to_q\.(.*)$": (
r"blocks.\1.attn.qkv_proj.\2",
0,
@@ -95,6 +98,7 @@ class MiniMaxH3DiTArchConfig(DiTArchConfig):
qk_norm_eps: float = 1e-5
final_norm_eps: float = 1e-5
checkpoint_uses_diffusers_layout: bool = False
adaln_affine_input_dim: int | None = None
def __post_init__(self) -> None:
super().__post_init__()
@@ -119,9 +123,14 @@ class MiniMaxH3DiTConfig(DiTConfig):
"time_embed_hidden_dim": "time_embed_hidden_size",
"rope_freq_dim": "rope_inv_freq_len",
}
super().update_model_arch(
{aliases.get(key, key): value for key, value in source_model_dict.items()}
)
model_dict = {
aliases.get(key, key): value for key, value in source_model_dict.items()
}
if source_model_dict.get("_class_name") == "MiniMaxH3PrunedTransformer3DModel":
model_dict["adaln_affine_input_dim"] = source_model_dict["time_embed_dim"]
model_dict["time_embed_dim"] = source_model_dict["adaln_rank"]
model_dict["adaln_curve_grid"] = source_model_dict["time_table_size"]
super().update_model_arch(model_dict)
__all__ = [
@@ -44,6 +44,7 @@ LoRAWeightEntry = tuple[
float,
int | None,
int | None,
torch.nn.Parameter | None,
]
@@ -104,6 +105,8 @@ class BaseLayerWithLoRA(nn.Module):
self.lora_A = None
self.lora_B = None
self.lora_output_offset = None
self.has_lora_output_offset = False
@property
def weight(self):
@@ -138,10 +141,10 @@ class BaseLayerWithLoRA(nn.Module):
) # type: ignore
delta = delta * self.strength
out, output_bias = self.base_layer(x)
return out + delta.to(dtype=out.dtype), output_bias
out = out + delta.to(dtype=out.dtype)
else:
out, output_bias = self.base_layer(x)
return out, output_bias
return self._add_lora_output_offset(out), output_bias
def slice_lora_a_weights(self, A: torch.Tensor) -> torch.Tensor:
return A
@@ -149,6 +152,44 @@ class BaseLayerWithLoRA(nn.Module):
def slice_lora_b_weights(self, B: torch.Tensor) -> torch.Tensor:
return B
def _scaled_lora_output_offset(
self,
offset: torch.Tensor | None,
strength: float,
rank: int | None,
alpha: int | None,
) -> torch.Tensor | None:
if offset is None:
return None
offset = self.slice_lora_b_weights(offset.unsqueeze(-1)).squeeze(-1)
scale = strength
if rank is not None and alpha is not None and rank != alpha:
scale *= alpha / rank
return offset if scale == 1.0 else offset * scale
def _active_lora_output_offset(self) -> torch.Tensor | None:
if self.disable_lora or not self.has_lora_output_offset:
return None
if not self.merged:
return self._scaled_lora_output_offset(
self.lora_output_offset,
self.strength,
self.lora_rank,
self.lora_alpha,
)
combined = None
for _, _, _, strength, rank, alpha, offset in self.lora_weights_list:
scaled = self._scaled_lora_output_offset(offset, strength, rank, alpha)
if scaled is not None:
combined = scaled if combined is None else combined + scaled
return combined
def _add_lora_output_offset(self, output: torch.Tensor) -> torch.Tensor:
offset = self._active_lora_output_offset()
if offset is None:
return output
return output + offset.to(device=output.device, dtype=output.dtype)
@staticmethod
def _as_mutable_tensor(tensor: torch.Tensor) -> torch.Tensor:
# lora can be reconfigured after executor forwards create inference tensors
@@ -165,6 +206,7 @@ class BaseLayerWithLoRA(nn.Module):
strength: float = 1.0,
clear_existing: bool = False,
merge_weights: bool = True,
output_offset: torch.Tensor | None = None,
) -> None:
"""
Set LoRA weights. Supports multiple LoRA adapters.
@@ -176,17 +218,25 @@ class BaseLayerWithLoRA(nn.Module):
strength: LoRA strength
clear_existing: If True, clear existing LoRA weights before adding new one.
If False, append to existing list (for multi-LoRA support).
output_offset: Optional constant output term paired with this adapter
"""
lora_A_param = torch.nn.Parameter(
A
) # share storage with weights in the pipeline
lora_B_param = torch.nn.Parameter(B)
output_offset_param = (
torch.nn.Parameter(output_offset, requires_grad=False)
if output_offset is not None
else None
)
if clear_existing:
self.lora_weights_list.clear()
# Also clear backward compatibility attributes
self.lora_A = None
self.lora_B = None
self.lora_output_offset = None
self.has_lora_output_offset = False
self.lora_path = None
self.strength = 1.0
@@ -199,6 +249,7 @@ class BaseLayerWithLoRA(nn.Module):
strength,
self.lora_rank,
self.lora_alpha,
output_offset_param,
)
)
@@ -206,6 +257,8 @@ class BaseLayerWithLoRA(nn.Module):
# This ensures backward compatibility while supporting multiple LoRA
self.lora_A = lora_A_param
self.lora_B = lora_B_param
self.lora_output_offset = output_offset_param
self.has_lora_output_offset |= output_offset_param is not None
self.lora_path = lora_path
self.strength = strength
@@ -233,10 +286,18 @@ class BaseLayerWithLoRA(nn.Module):
Args:
data: The base weight tensor to merge LoRA into (modified in-place)
lora_list: List of (lora_A, lora_B, lora_path, lora_strength, rank, alpha) tuples
lora_list: Adapter factors, path, scale metadata, and output offset
"""
# Merge all LoRA adapters in order
for lora_A, lora_B, _, lora_strength, lora_rank, lora_alpha in lora_list:
for (
lora_A,
lora_B,
_,
lora_strength,
lora_rank,
lora_alpha,
_,
) in lora_list:
lora_A_sliced = self.slice_lora_a_weights(lora_A.to(data))
lora_B_sliced = self.slice_lora_b_weights(lora_B.to(data))
@@ -286,7 +347,7 @@ class BaseLayerWithLoRA(nn.Module):
) -> bool:
if os.getenv("SGLANG_DIFFUSION_LORA_MERGE_FP32", "1") != "1":
return False
for _, _, lora_path, _, _, _ in lora_list:
for _, _, lora_path, _, _, _, _ in lora_list:
if lora_path and "distilled-lora" in lora_path.lower():
return False
return True
@@ -332,7 +393,15 @@ class BaseLayerWithLoRA(nn.Module):
self.strength = strength
if self.lora_weights_list:
self.lora_weights_list = [
(lora_A, lora_B, lora_path, strength, lora_rank, lora_alpha)
(
lora_A,
lora_B,
lora_path,
strength,
lora_rank,
lora_alpha,
output_offset,
)
for (
lora_A,
lora_B,
@@ -340,6 +409,7 @@ class BaseLayerWithLoRA(nn.Module):
_,
lora_rank,
lora_alpha,
output_offset,
) in self.lora_weights_list
]
@@ -361,11 +431,18 @@ class BaseLayerWithLoRA(nn.Module):
self.strength,
self.lora_rank,
self.lora_alpha,
self.lora_output_offset,
)
]
if not lora_list:
raise ValueError("LoRA weights not set. Please set them first.")
if isinstance(self.base_layer.weight, DTensor) and any(
output_offset is not None for *_, output_offset in lora_list
):
raise ValueError(
"LoRA output offsets require dynamic mode with FSDP-sharded weights."
)
merge_in_fp32 = self._should_merge_in_fp32(lora_list)
@@ -488,6 +565,11 @@ class BaseLayerWithLoRA(nn.Module):
"""
if not self.merged:
return
if self._active_lora_output_offset() is not None:
raise ValueError(
"A LoRA with a constant output offset cannot be committed as a "
"weight-only base."
)
weight = self.base_layer.weight
if isinstance(weight, DTensor):
weight = weight.to_local()
@@ -498,6 +580,8 @@ class BaseLayerWithLoRA(nn.Module):
self.lora_weights_list = []
self.lora_A = None
self.lora_B = None
self.lora_output_offset = None
self.has_lora_output_offset = False
self.lora_path = None
self.strength = 1.0
@@ -534,7 +618,7 @@ class ColumnParallelLinearWithLoRA(BaseLayerWithLoRA):
super().__init__(base_layer, lora_rank, lora_alpha, snapshot_base)
def forward(self, input_: torch.Tensor) -> torch.Tensor:
if self.merged or self.disable_lora:
if self.disable_lora or (self.merged and not self.has_lora_output_offset):
return self.base_layer(input_)
lora_A = self.lora_A
@@ -567,6 +651,7 @@ class ColumnParallelLinearWithLoRA(BaseLayerWithLoRA):
output_parallel = output_parallel + delta_parallel.to(
dtype=output_parallel.dtype
)
output_parallel = self._add_lora_output_offset(output_parallel)
if self.base_layer.gather_output:
output = tensor_model_parallel_all_gather(output_parallel)
else:
@@ -667,7 +752,7 @@ class RowParallelLinearWithLoRA(BaseLayerWithLoRA):
super().__init__(base_layer, lora_rank, lora_alpha, snapshot_base)
def forward(self, input_: torch.Tensor):
if self.merged or self.disable_lora:
if self.disable_lora or (self.merged and not self.has_lora_output_offset):
return self.base_layer(input_)
lora_A = self.lora_A
@@ -723,7 +808,7 @@ class RowParallelLinearWithLoRA(BaseLayerWithLoRA):
else:
output = output_
output_bias = self.base_layer.bias
return output, output_bias
return self._add_lora_output_offset(output), output_bias
def slice_lora_a_weights(self, A: torch.Tensor) -> torch.Tensor:
tp_rank = get_tp_rank()
@@ -779,11 +864,11 @@ class LinearWithLoRA(BaseLayerWithLoRA):
delta = delta * self.strength
# nn.Linear.forward() returns a single tensor, not a tuple
out = self.base_layer(x)
return out + delta.to(dtype=out.dtype)
out = out + delta.to(dtype=out.dtype)
else:
# nn.Linear.forward() returns a single tensor
out = self.base_layer(x)
return out
return self._add_lora_output_offset(out)
def _use_owned_base_snapshot(snapshot_base: bool, device_type: str) -> bool:
@@ -232,7 +232,7 @@ class TransformerLoader(ComponentLoader):
is_minimax_h3 = model_cls.__name__ == "MiniMaxH3DiTModel"
if is_minimax_h3:
dit_config.arch_config.checkpoint_uses_diffusers_layout = (
cls_name == "MiniMaxH3Transformer3DModel"
cls_name != model_cls.__name__
)
checkpoint_quant_config = None
@@ -102,6 +102,12 @@ class BaseDiT(nn.Module, ABC):
"""Run model-specific post-load weight fixups after all parameters are materialized."""
return None
def prepare_lora_adapter(
self, adapter: dict[str, torch.Tensor]
) -> dict[str, torch.Tensor]:
"""Apply model-specific LoRA transforms after names are normalized."""
return adapter
@property
def supported_attention_backends(self) -> set[AttentionBackendEnum]:
return self._supported_attention_backends
@@ -18,6 +18,7 @@ from typing import Any, Callable
import torch
import torch.nn as nn
from safetensors.torch import safe_open
from torch.distributed.tensor import DTensor
from sglang.kernels.ops.activation.activation import (
silu_and_mul_with_activation_rounding_,
@@ -1653,7 +1654,10 @@ class MiniMaxH3FinalLayer(nn.Module):
class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
_aliases = ["MiniMaxH3Transformer3DModel"]
_aliases = [
"MiniMaxH3Transformer3DModel",
"MiniMaxH3PrunedTransformer3DModel",
]
_fsdp_shard_conditions = [is_block]
# refine_prompt_embeds drives a forward pass outside __call__.
_fsdp_forward_methods = ("refine_prompt_embeds",)
@@ -1666,6 +1670,65 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
reverse_param_names_mapping = _ARCH_DEFAULTS.reverse_param_names_mapping
lora_param_names_mapping = _ARCH_DEFAULTS.lora_param_names_mapping
def prepare_lora_adapter(
self, adapter: dict[str, torch.Tensor]
) -> dict[str, torch.Tensor]:
"""Project released-checkpoint AdaLN LoRAs onto pruned coordinates."""
full_width = self.arch.adaln_affine_input_dim
if full_width is None:
return adapter
suffix = ".adaln_proj.linear.lora_A"
a_keys = sorted(key for key in adapter if key.endswith(suffix))
if not a_keys:
return adapter
widths = {int(adapter[key].shape[-1]) for key in a_keys}
if widths == {self.arch.time_embed_dim}:
return adapter
if widths != {full_width}:
raise ValueError(
"MiniMax H3 pruned AdaLN LoRA inputs must be uniformly "
f"{self.arch.time_embed_dim} or {full_width} wide, got "
f"{sorted(widths)}."
)
basis = self.adaln_basis
mean = self.adaln_mean
assert basis is not None and mean is not None
if isinstance(basis, DTensor):
basis = basis.full_tensor()
mean = mean.full_tensor()
if torch.count_nonzero(basis).item() == 0:
raise ValueError(
"MiniMax H3 pruned LoRA projection requires adaln_basis and "
"adaln_mean from the component checkpoint."
)
projected = dict(adapter)
work_device = adapter[a_keys[0]].device
work_basis = basis.to(device=work_device, dtype=torch.float64)
work_mean = mean.to(device=work_device, dtype=torch.float64)
for a_key in a_keys:
b_key = a_key[: -len("lora_A")] + "lora_B"
if b_key not in adapter:
raise ValueError(f"MiniMax H3 AdaLN LoRA is missing {b_key!r}.")
a = adapter[a_key]
b = adapter[b_key]
a64 = a.to(torch.float64)
b64 = b.to(device=work_device, dtype=torch.float64)
projected[a_key] = (a64 @ work_basis.T).to(torch.float32)
projected[a_key[: -len("lora_A")] + "lora_output_offset"] = (
b64 @ (a64 @ work_mean)
).to(torch.float32)
logger.info(
"Projected %d MiniMax H3 AdaLN LoRA modules from width %d to %d",
len(a_keys),
full_width,
self.arch.time_embed_dim,
)
return projected
def prepare_adaln_plans(self, step_timesteps: list[torch.Tensor]) -> None:
"""Fill the AdaLN cache for this request before denoising starts.
@@ -1845,6 +1908,28 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
),
requires_grad=False,
)
if arch.adaln_affine_input_dim is None:
self.register_parameter("adaln_basis", None)
self.register_parameter("adaln_mean", None)
else:
self.register_parameter(
"adaln_basis",
nn.Parameter(
torch.empty(
arch.time_embed_dim,
arch.adaln_affine_input_dim,
dtype=_FP32_DTYPE,
),
requires_grad=False,
),
)
self.register_parameter(
"adaln_mean",
nn.Parameter(
torch.empty(arch.adaln_affine_input_dim, dtype=_FP32_DTYPE),
requires_grad=False,
),
)
self.rope = MiniMaxH3Rope(arch.rope_inv_freq_len)
self.token_refiner = MiniMaxH3TokenRefiner(
arch,
@@ -1929,6 +2014,8 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
if not name.startswith("time_embedder.")
]
fp32_param_names.append("adaln_t_table")
if self.adaln_basis is not None:
fp32_param_names.extend(("adaln_basis", "adaln_mean"))
for name in fp32_param_names:
param = self.get_parameter(name)
if param.dtype != _FP32_DTYPE:
@@ -23,6 +23,7 @@ from sglang.multimodal_gen.runtime.loader.utils import get_param_names_mapping
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
is_layerwise_offloaded_module,
)
from sglang.multimodal_gen.runtime.models.dits.base import BaseDiT
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
ComposedPipelineBase,
)
@@ -688,6 +689,9 @@ class LoRAPipeline(ComposedPipelineBase):
layer.set_lora_weights(
self.lora_adapters[nickname][lora_A_name],
self.lora_adapters[nickname][lora_B_name],
output_offset=self.lora_adapters[nickname].get(
name + ".lora_output_offset"
),
lora_path=path,
strength=lora_strength,
merge_weights=merge_weights and not use_cache,
@@ -918,6 +922,11 @@ class LoRAPipeline(ComposedPipelineBase):
adapter_lora_alpha,
self.device,
)
transformer = self.modules["transformer"]
if isinstance(transformer, BaseDiT):
self.lora_adapters[lora_nickname] = transformer.prepare_lora_adapter(
self.lora_adapters[lora_nickname]
)
self.loaded_adapter_paths[lora_nickname] = lora_path
self.loaded_adapter_alphas[lora_nickname] = adapter_lora_alpha
@@ -6,6 +6,7 @@ invariants are checked on CPU with a plain nn.Linear base (the parallel forward
path needs a distributed runtime and is covered by the diffusion server tests).
"""
import pytest
import torch
from torch import nn
@@ -47,6 +48,24 @@ def test_commit_merged_as_base_promotes_weights_and_resets_state():
assert layer.lora_A is None and layer.lora_B is None
def test_lora_output_offset_tracks_dynamic_and_merged_scale():
layer = _make_layer(torch.zeros(1, 2), rank=1, alpha=2)
inputs = torch.zeros(3, 2)
layer.set_lora_weights(
torch.zeros(1, 2),
torch.zeros(1, 1),
output_offset=torch.tensor([3.0]),
strength=0.5,
clear_existing=True,
merge_weights=False,
)
torch.testing.assert_close(layer(inputs), torch.full((3, 1), 3.0))
layer.merge_lora_weights()
torch.testing.assert_close(layer(inputs), torch.full((3, 1), 3.0))
with pytest.raises(ValueError, match="constant output offset"):
layer.commit_merged_as_base()
def test_dynamic_delta_after_commit_does_not_unmerge_base():
torch.manual_seed(1)
out_f, in_f, rank = 4, 5, 2
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
"""Mixed-precision weight and TP/Ulysses numerical contracts for H3 DiT."""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -44,6 +45,24 @@ def _ensure_single_process_parallel_runtime() -> None:
maybe_init_distributed_environment_and_model_parallel(tp_size=1, sp_size=1)
def test_pruned_adaln_lora_projection_preserves_affine_term():
model = SimpleNamespace(
arch=SimpleNamespace(adaln_affine_input_dim=3, time_embed_dim=2),
adaln_basis=torch.tensor([[1.0, 0.0, 2.0], [0.0, 1.0, -1.0]]),
adaln_mean=torch.tensor([1.0, 2.0, 3.0]),
)
prefix = "blocks.0.adaln_proj.linear."
a = torch.tensor([[2.0, 3.0, 4.0]])
b = torch.tensor([[5.0], [6.0]])
actual = MiniMaxH3DiTModel.prepare_lora_adapter(
model, {prefix + "lora_A": a, prefix + "lora_B": b}
)
torch.testing.assert_close(actual[prefix + "lora_A"], a @ model.adaln_basis.T)
torch.testing.assert_close(
actual[prefix + "lora_output_offset"], b @ (a @ model.adaln_mean)
)
def test_native_weight_names_and_grouped_qkv_reorder():
arch = MiniMaxH3DiTArchConfig()
assert arch.reverse_param_names_mapping == {}
@@ -90,6 +109,12 @@ def test_native_weight_names_and_grouped_qkv_reorder():
1,
3,
)
assert mapping("time_embedder.table") == ("adaln_t_table", None, None)
assert mapping("transformer_blocks.7.adaln_proj.folded_bias") == (
"blocks.7.adaln_proj.linear.bias",
None,
None,
)
diffusers_weights = [
("transformer_blocks.0.attn.to_q.qweight", torch.full((2, 3), 1)),
@@ -110,6 +135,19 @@ def test_native_weight_names_and_grouped_qkv_reorder():
torch.tensor([[4, 5], [6, 7], [0, 1], [2, 3]]),
)
pruned_config = MiniMaxH3DiTConfig()
pruned_config.update_model_arch(
{
"_class_name": "MiniMaxH3PrunedTransformer3DModel",
"adaln_rank": 8,
"time_embed_dim": 2688,
"time_table_size": 1025,
}
)
assert pruned_config.arch_config.time_embed_dim == 8
assert pruned_config.arch_config.adaln_curve_grid == 1025
assert pruned_config.arch_config.adaln_affine_input_dim == 2688
weight = torch.arange(12, dtype=torch.float32).reshape(12, 1)
actual = _reorder_grouped_qkv_to_qkv(
weight,