Skip to content

Commit c99546d

Browse files
authored
Merge pull request #3202 from bghira/fix/minimax-h3-mixed-convrot-groups
Allow mixed ConvRot groups in MiniMax H3 checkpoints
2 parents ebe3cb2 + a734a6c commit c99546d

3 files changed

Lines changed: 86 additions & 16 deletions

File tree

simpletuner/helpers/models/minimaxh3/transformer.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@
6969
_H3_MASKED_CONTEXT_PARALLEL_BACKENDS = frozenset({AttentionBackendName.NATIVE, AttentionBackendName._NATIVE_CUDNN})
7070

7171

72+
def _linear_compute_dtype(linear: nn.Module) -> torch.dtype:
73+
compute_dtype = getattr(linear, "compute_dtype", None)
74+
if isinstance(compute_dtype, torch.dtype):
75+
return compute_dtype
76+
weight = linear.weight
77+
dequantizer = getattr(weight, "sdnq_dequantizer", None)
78+
result_dtype = getattr(dequantizer, "result_dtype", None)
79+
return result_dtype if isinstance(result_dtype, torch.dtype) else weight.dtype
80+
81+
7282
class _MiniMaxH3AllGather(torch.autograd.Function):
7383
"""Gather sequence shards without PyTorch's unsupported NCCL coalesced path."""
7484

@@ -440,7 +450,13 @@ def _infer_minimax_h3_config_from_checkpoint(checkpoint) -> dict[str, Any]:
440450
audio_weight = _get_checkpoint_tensor(checkpoint, "audio_proj_in.weight")
441451
context_weight = _get_checkpoint_tensor(checkpoint, "context_embedder.weight")
442452
q_norm_weight = _get_checkpoint_tensor(checkpoint, "transformer_blocks.0.attn.norm_q.weight")
443-
q_weight = _get_checkpoint_tensor(checkpoint, "transformer_blocks.0.attn.to_q.weight")
453+
if "transformer_blocks.0.attn.to_q.weight" in raw_keys:
454+
q_output_dim = _get_checkpoint_tensor(checkpoint, "transformer_blocks.0.attn.to_q.weight").shape[0]
455+
else:
456+
qkv_weight = _get_checkpoint_tensor(checkpoint, "blocks.0.attn.qkv_proj.weight")
457+
if qkv_weight.shape[0] % 3 != 0:
458+
raise RuntimeError("MiniMax-H3 fused QKV tensor blocks.0.attn.qkv_proj.weight cannot be split into q/k/v")
459+
q_output_dim = qkv_weight.shape[0] // 3
444460
ffn_weight = _get_checkpoint_tensor(checkpoint, "transformer_blocks.0.ff.net.0.proj.weight")
445461
has_adaln_curve = "adaln_t_table" in raw_keys
446462
adaln_curve_table = _get_checkpoint_tensor(checkpoint, "adaln_t_table") if has_adaln_curve else None
@@ -456,7 +472,7 @@ def _infer_minimax_h3_config_from_checkpoint(checkpoint) -> dict[str, Any]:
456472
"audio_in_channels": audio_weight.shape[1],
457473
"text_dim": context_weight.shape[1],
458474
"attention_head_dim": q_norm_weight.shape[0],
459-
"num_attention_heads": q_weight.shape[0] // q_norm_weight.shape[0],
475+
"num_attention_heads": q_output_dim // q_norm_weight.shape[0],
460476
"freq_dim": time_in.shape[1] if time_in is not None else 256,
461477
"time_embed_hidden_dim": time_in.shape[0] if time_in is not None else 5376,
462478
"time_embed_dim": adaln_curve_table.shape[1] if has_adaln_curve else time_out.shape[0],
@@ -631,7 +647,7 @@ def forward(self, temb: torch.Tensor) -> tuple[torch.Tensor, ...]:
631647
# The activation runs at `temb`'s own precision and only the projection input is aligned to the projection
632648
# weight. Every block reads the same `temb`, so early rounding biases every block's modulation coherently.
633649
temb = nn.functional.silu(temb) if self.apply_silu else temb
634-
temb = self.linear(temb.to(self.linear.weight.dtype))
650+
temb = self.linear(temb.to(_linear_compute_dtype(self.linear)))
635651
temb = temb.view(-1, 6 * self.hidden_size)
636652
return temb.chunk(6, dim=-1)
637653

@@ -661,7 +677,7 @@ def forward(
661677
) -> torch.Tensor:
662678
# As in `MiniMaxH3AdaLayerNormModulation`: activate at `temb`'s precision, cast to the projection's dtype after.
663679
temb = nn.functional.silu(temb) if self.apply_silu else temb
664-
shift, scale = self.linear(temb.to(self.linear.weight.dtype)).chunk(2, dim=-1)
680+
shift, scale = self.linear(temb.to(_linear_compute_dtype(self.linear))).chunk(2, dim=-1)
665681
activation_dtype = hidden_states.dtype
666682
hidden_states = self.norm(hidden_states)
667683
shift = _select_modulation(shift, timestep_indices).to(dtype=activation_dtype)
@@ -1522,7 +1538,7 @@ def _time_embedding(
15221538
temb = blend_flowmap_embeddings(temb, delta_temb, self.flowmap_delta_emb_gate)
15231539
return temb
15241540

1525-
dtype = self.time_embedder.linear_1.weight.dtype
1541+
dtype = _linear_compute_dtype(self.time_embedder.linear_1)
15261542
temb = flowmap_timestep_embedding(
15271543
time_proj=self.time_proj,
15281544
timestep_embedder=self.time_embedder,
@@ -1857,21 +1873,20 @@ def from_single_file(
18571873
result_dtype=torch_dtype or torch.bfloat16,
18581874
hadamard_group_size=hadamard_group_size,
18591875
)
1860-
if len(hadamard_group_sizes) != 1:
1861-
raise RuntimeError(
1862-
f"MiniMax-H3 ConvRot checkpoint uses multiple Hadamard group sizes: {sorted(hadamard_group_sizes)}"
1863-
)
1864-
group_size = hadamard_group_sizes.pop()
18651876
model.quantization_method = "minimax_h3_comfy_convrot_sdnq"
18661877
model.quantization_config = {
18671878
"quant_method": "sdnq_training",
18681879
"weights_dtype": "int8",
18691880
"quantized_matmul_dtype": "int8",
18701881
"use_hadamard": True,
1871-
"hadamard_group_size": group_size,
18721882
"group_size": -1,
18731883
"source_format": "comfy_minimax_h3_convrot",
18741884
}
1885+
sorted_group_sizes = sorted(hadamard_group_sizes)
1886+
if len(sorted_group_sizes) == 1:
1887+
model.quantization_config["hadamard_group_size"] = sorted_group_sizes[0]
1888+
else:
1889+
model.quantization_config["hadamard_group_sizes"] = sorted_group_sizes
18751890
elif fp8_state_dict:
18761891
model.quantization_method = "minimax_h3_comfy_fp8"
18771892
model.quantization_config = {
@@ -2022,9 +2037,9 @@ def forward(
20222037
# mixed-precision (the two patch projections are float32 while `context_embedder` and the block stack are
20232038
# bfloat16 — see `_keep_in_fp32_modules`), so every input is aligned with its projection's parameter dtype,
20242039
# mirroring the reference's explicit casts. The text stream sets the dtype of the packed sequence.
2025-
video_embeds = self.proj_in(hidden_states.to(self.proj_in.weight.dtype))
2026-
audio_embeds = self.audio_proj_in(audio_hidden_states.to(self.audio_proj_in.weight.dtype))
2027-
text_embeds = self.context_embedder(encoder_hidden_states.to(self.context_embedder.weight.dtype))
2040+
video_embeds = self.proj_in(hidden_states.to(_linear_compute_dtype(self.proj_in)))
2041+
audio_embeds = self.audio_proj_in(audio_hidden_states.to(_linear_compute_dtype(self.audio_proj_in)))
2042+
text_embeds = self.context_embedder(encoder_hidden_states.to(_linear_compute_dtype(self.context_embedder)))
20282043
self.token_refiner.gradient_checkpointing = self.gradient_checkpointing
20292044
text_attention_mask = None
20302045
if packed_valid_mask is not None:
@@ -2375,7 +2390,7 @@ def run_checkpointed_block(
23752390
# 5. Both heads run over every row, then the rows of each modality are selected. The heads are listed in
23762391
# `_keep_in_fp32_modules`, so they stay float32 while the block stack runs in the requested `torch_dtype`;
23772392
# align the activation with their parameter dtype.
2378-
hidden_states = self.norm_out(hidden_states, temb, timestep_indices).to(self.proj_out.weight.dtype)
2393+
hidden_states = self.norm_out(hidden_states, temb, timestep_indices).to(_linear_compute_dtype(self.proj_out))
23792394
video_output = _gather_h3_context_parallel_output(self.proj_out(hidden_states), cp_config, dim=1).index_select(
23802395
1, video_indices.to(hidden_states.device)
23812396
)

simpletuner/helpers/models/z_image/quantized_loading.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ def _wrap_convrot_linear(
200200
True,
201201
-1,
202202
)
203-
_set_module(model, module_name, get_sdnq_wrapper_class(module, forward))
203+
wrapped_module = get_sdnq_wrapper_class(module, forward)
204+
wrapped_module.compute_dtype = result_dtype
205+
_set_module(model, module_name, wrapped_module)
204206

205207

206208
def _validate_quant_metadata(checkpoint, key: str) -> int:

tests/test_minimaxh3.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
_convert_minimax_h3_native_swiglu_scale_to_diffusers,
6565
_convert_minimax_h3_native_swiglu_to_diffusers,
6666
_gather_h3_context_parallel_output,
67+
_linear_compute_dtype,
6768
_pad_h3_context_parallel_layout,
6869
resolve_h3_reference_mode,
6970
)
@@ -768,6 +769,17 @@ def sample(self, generator=None):
768769

769770

770771
class MiniMaxH3Tests(unittest.TestCase):
772+
def test_linear_compute_dtype_uses_quantized_result_dtype(self):
773+
weight = SimpleNamespace(
774+
dtype=torch.int8,
775+
sdnq_dequantizer=SimpleNamespace(result_dtype=torch.bfloat16),
776+
)
777+
self.assertEqual(_linear_compute_dtype(SimpleNamespace(weight=weight)), torch.bfloat16)
778+
779+
def test_linear_compute_dtype_prefers_module_contract(self):
780+
linear = SimpleNamespace(weight=SimpleNamespace(dtype=torch.int8), compute_dtype=torch.float16)
781+
self.assertEqual(_linear_compute_dtype(linear), torch.float16)
782+
771783
def test_registry_metadata_resolves(self):
772784
model_cls = ModelRegistry.get("minimaxh3")
773785
self.assertEqual(model_cls.NAME, "MiniMax H3")
@@ -3655,6 +3667,47 @@ def test_single_file_loader_accepts_abiray_convrot_metadata(self):
36553667
self.assertEqual(wrap_convrot.call_args.args[1], "transformer_blocks.0.attn.to_out.0")
36563668
self.assertEqual(wrap_convrot.call_args.kwargs["hadamard_group_size"], 256)
36573669

3670+
def test_single_file_loader_accepts_mixed_convrot_group_sizes(self):
3671+
model = tiny_h3_transformer(num_layers=1)
3672+
state_dict = dict(model.state_dict())
3673+
3674+
qkv_weights = [state_dict.pop(f"transformer_blocks.0.attn.to_{branch}.weight") for branch in ("q", "k", "v")]
3675+
qkv_source = "blocks.0.attn.qkv_proj"
3676+
qkv_weight = torch.cat(qkv_weights, dim=0)
3677+
state_dict[f"{qkv_source}.weight"] = torch.zeros(qkv_weight.shape, dtype=torch.int8)
3678+
state_dict[f"{qkv_source}.weight_scale"] = torch.ones(qkv_weight.shape[0], 1, dtype=torch.float32)
3679+
state_dict[f"{qkv_source}.comfy_quant"] = comfy_quant_metadata_tensor(
3680+
{"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 64}
3681+
)
3682+
3683+
out_target = "transformer_blocks.0.attn.to_out.0.weight"
3684+
out_weight = state_dict.pop(out_target)
3685+
out_source = "blocks.0.attn.out_proj"
3686+
state_dict[f"{out_source}.weight"] = torch.zeros(out_weight.shape, dtype=torch.int8)
3687+
state_dict[f"{out_source}.weight_scale"] = torch.ones(out_weight.shape[0], 1, dtype=torch.float32)
3688+
state_dict[f"{out_source}.comfy_quant"] = comfy_quant_metadata_tensor(
3689+
{"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 256}
3690+
)
3691+
3692+
with tempfile.TemporaryDirectory() as tmpdir:
3693+
path = f"{tmpdir}/tiny-h3-mixed-convrot.safetensors"
3694+
save_file(state_dict, path)
3695+
with patch("simpletuner.helpers.models.z_image.quantized_loading._wrap_convrot_linear") as wrap_convrot:
3696+
loaded = MiniMaxH3Transformer3DModel.from_single_file(path, torch_dtype=torch.float32)
3697+
3698+
group_sizes_by_module = {call.args[1]: call.kwargs["hadamard_group_size"] for call in wrap_convrot.call_args_list}
3699+
self.assertEqual(
3700+
group_sizes_by_module,
3701+
{
3702+
"transformer_blocks.0.attn.to_q": 64,
3703+
"transformer_blocks.0.attn.to_k": 64,
3704+
"transformer_blocks.0.attn.to_v": 64,
3705+
"transformer_blocks.0.attn.to_out.0": 256,
3706+
},
3707+
)
3708+
self.assertEqual(loaded.quantization_config["hadamard_group_sizes"], [64, 256])
3709+
self.assertNotIn("hadamard_group_size", loaded.quantization_config)
3710+
36583711
def test_single_file_loader_accepts_comfy_fp8_scale_metadata(self):
36593712
model = tiny_h3_transformer(num_layers=1)
36603713
state_dict = dict(model.state_dict())

0 commit comments

Comments
 (0)