[diffusion] fix: fix quantized qkv scales and missing-param policy for minimax-h3 (#35740)

This commit is contained in:
王鹤男
2026-08-21 22:10:50 +08:00
committed by GitHub
parent 4f343abc13
commit 5ecd6d794d
2 changed files with 109 additions and 1 deletions
@@ -176,6 +176,39 @@ def _reorder_grouped_qkv_to_qkv(
)
def _install_qkv_row_reorder(
param: torch.Tensor,
reorder: Callable[[torch.Tensor], torch.Tensor],
qkv_rows: int,
) -> None:
"""Reorder a per-output-row qkv parameter the same way its rows are reordered.
Applied to quantization metadata rather than the weight itself. Anything whose
leading dim is not the checkpoint's qkv row count is passed through: per-tensor
scales are scalars, and a swizzled block-scale layout is not row-indexed.
"""
def _maybe_reorder(loaded_weight: torch.Tensor) -> torch.Tensor:
if loaded_weight.dim() >= 2 and loaded_weight.shape[0] == qkv_rows:
return reorder(loaded_weight)
return loaded_weight
base_loader = (
param._weight_loader
if hasattr(param, "_weight_loader")
else param.weight_loader
)
def _weight_loader(p: torch.Tensor, loaded_weight: torch.Tensor) -> None:
base_loader(p, _maybe_reorder(loaded_weight))
if hasattr(param, "_weight_loader"):
param._weight_loader = _weight_loader
else:
param.weight_loader = _weight_loader
param.rank_local_weight_transform = _maybe_reorder
def _copy_grouped_qkv_tp_shard(
param: torch.Tensor,
loaded_weight: torch.Tensor,
@@ -662,6 +695,17 @@ class MiniMaxH3Attention(nn.Module):
# rank-local FSDP must reorder grouped QKV before selecting each shard
weight.rank_local_weight_transform = _reorder_checkpoint_weight
# A quantized checkpoint stores metadata indexed by output row next to the
# rows themselves (NVFP4 block scales, fp8 per-channel scales). Those rows
# are permuted above, so the per-row metadata has to be permuted the same
# way. Row count is the gate: a swizzled scale layout is not row-indexed,
# and per-tensor scales are scalars, so both are passed through untouched.
qkv_rows = 3 * arch.num_attention_heads * arch.attention_head_dim
for name, param in self.qkv_proj.named_parameters(recurse=False):
if name == "weight":
continue
_install_qkv_row_reorder(param, _reorder_checkpoint_weight, qkv_rows)
def _forward_mps_streamed_attention(
self,
x: torch.Tensor,
@@ -1816,7 +1860,11 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
def _mark_missing_params_required(self) -> None:
for _, param in self.named_parameters():
param.missing_param_init = "error"
# A quant method's create_weights() declares its own policy for scales
# it can synthesize (weight-only NVFP4 has no input_scale and marks it
# "ones"); claiming only undeclared params keeps that intact.
if getattr(param, "missing_param_init", None) is None:
param.missing_param_init = "error"
def post_load_weights(self) -> None:
fp32_param_names = list(_MINIMAX_H3_FP32_PARAM_NAMES_IN_MODEL_ORDER)
@@ -0,0 +1,60 @@
import unittest
import torch
from sglang.multimodal_gen.runtime.models.dits.minimax_h3 import (
_install_qkv_row_reorder,
)
QKV_ROWS = 12
def _install(reorder=lambda w: w.flip(0)):
param = torch.zeros(QKV_ROWS)
seen = []
param.weight_loader = lambda p, loaded_weight: seen.append(loaded_weight)
_install_qkv_row_reorder(param, reorder, QKV_ROWS)
return param, seen
class TestMiniMaxH3QkvScaleReorder(unittest.TestCase):
"""MiniMax-H3 permutes the fused qkv weight's output rows on load.
Quantization metadata indexed by output row (NVFP4 block scales, fp8
per-channel scales) has to move with those rows, or every scale lands on the
wrong row: the model loads and runs, and renders noise. Metadata that is not
row-indexed must be left alone, so the row count is the gate.
"""
def test_row_indexed_metadata_is_reordered(self):
param, seen = _install()
scales = torch.arange(QKV_ROWS * 2, dtype=torch.float32).reshape(QKV_ROWS, 2)
param.weight_loader(param, scales)
self.assertTrue(torch.equal(seen[0], scales.flip(0)))
def test_metadata_with_another_row_count_is_passed_through(self):
param, seen = _install()
swizzled = torch.arange(QKV_ROWS, dtype=torch.float32).reshape(QKV_ROWS // 3, 3)
param.weight_loader(param, swizzled)
self.assertTrue(torch.equal(seen[0], swizzled))
def test_per_tensor_scale_is_passed_through(self):
param, seen = _install()
scale = torch.tensor(0.5)
param.weight_loader(param, scale)
self.assertTrue(torch.equal(seen[0], scale))
def test_rank_local_transform_follows_the_same_gate(self):
# rank-local FSDP reorders before selecting its shard, so it needs the
# transform rather than the wrapped loader.
param, _ = _install()
scales = torch.arange(QKV_ROWS * 2, dtype=torch.float32).reshape(QKV_ROWS, 2)
self.assertTrue(
torch.equal(param.rank_local_weight_transform(scales), scales.flip(0))
)
scale = torch.tensor(0.5)
self.assertTrue(torch.equal(param.rank_local_weight_transform(scale), scale))
if __name__ == "__main__":
unittest.main()