Prhub

#35703 [diffusion] fix: fix loading a block-FP8 quantized MiniMax-H3 DiT

原始 PR 作者 decajoin 合并时间 2026-09-01 10:33 文件变更 2 提交数 2 评论 4 代码增减 +188 / -8

执行摘要

修复 MiniMax-H3 DiT block-FP8 加载静默产出空白帧

block-FP8 MiniMax-H3 DiT 加载时无任何报错——dtype、shape 全部匹配,Detected fp8 checkpoint 正常打印,服务就绪且推理速度正常——但生成的是空白帧。根因是 H3 checkpoint 按 head 分组存储 qkv 行([h0_q, h0_k, h0_v, h1_q, ...]),DiT 在加载时已将它们重排为 [q_all, k_all, v_all],而前置 PR #35740 的行数门控对 block-FP8 的 weight_scale_inv 失效:门控按 21504(权重行数)对比,scale 的 leading dim 只有 168(= 56 heads × 3),每个 scale 行覆盖 128 个权重行,导致 scale 未经重排直接通过。作者强调这不是任何单一量化器的问题:scale 按 checkpoint 布局产出是合理的,应用重排是运行时职责,任何标准 block-FP8 H3 checkpoint 都会踩中。

值得精读。这是一个经典的量化元数据与权重布局一致性缺陷:根因定位链条完整(布局契约 → 行数门控失效 → 块计数),修复通过类型判断(BlockQuantScaleParameter)与块计数缩小重排粒度,并对无法修复的配置显式 fail-fast。实现与测试共同定义了可复用的加载契约,对后续支持其他 DiT 模型或其他 block 量化格式的 checkpoint 加载器有直接借鉴意义。

讨论亮点

评审流程简短:mickqian 直接 APPROVED(无附带评论),issue 评论区只有 CI 操作(/tag-and-rerun-ci/rerun-failed-ci)、请求审核与致谢,没有展开技术辩论。作者把技术论证完整放在 PR body 中,核心观点值得引用:

block-FP8 checkpoint 携带的 weight_scale_inv 是每个 128×128 tile 一个 scale,#35740 的行数门控按 21504(权重行数)对比,而 scale 的 leading dim 只有 168,导致 scale 未经重排直接通过。

这不是任何单一量化器的问题:scale 按 checkpoint 布局产出是合理的,应用重排是运行时的职责,任何标准 block-FP8 H3 checkpoint 都会踩中。

无修复时帧内对比度塌缩 16 倍、帧间运动塌缩 19 倍;修复后 PSNR-Y 16.5 dB / SSIM-Y 0.68,贴近 BF16 参考,残差来自 FP8 舍入导致的采样轨迹分叉而非损坏。

实现拆解

1. 定位根因并确定修复入口

入口是 _install_qkv_weight_loaderpython/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py)。关键判断:block-FP8 的 scale 也是按输出行索引的,只是索引粒度是块而非行——一个 scale 行覆盖 weight_block_size[0] 个权重行。因此重排操作本身可复用,但重排的 head_dim 参数和行数门控都要按块高缩小。

2. 新增 _qkv_scale_block_rows

新函数从 qkv_proj.quant_method.quant_config.weight_block_size[0] 读取块高:无块配置(BF16、per-tensor FP8、NVFP4、在线量化)返回 1,保持原有每权重行一行 scale 的语义;head_dim % block_rows != 0 时抛 ValueError——块若横跨两个 head 的 q/k/v 行,任何行置换都无法修复,必须显式拒绝而非静默错缩放。

3. 参数化重排并安装到各入口

把原先固定的 _reorder_checkpoint_weight 改为 _make_row_reorder(head_dim) 工厂闭包。在遍历 qkv 投影的量化参数时:BlockQuantScaleParameter 类型参数(block-FP8 的 weight_scale_inv)按 head_dim // block_rows 重排,门控行数缩放为 qkv_rows // block_rows;其余参数(含权重本身、逐通道 scale)保持原重排。param._weight_loader / param.weight_loader(加载器包装)与 param.rank_local_weight_transform(FSDP 切分前)双入口同步更新,保证 CPU 加载与 TP/FSDP 路径行为一致。GGUF 路径原本就存 [q_all, k_all, v_all],被行数门控排除,不受影响。

4. 测试配套

test_minimax_h3_dit_contract.py 新增 136 行、5 个用例与 2 个 helper(_block_fp8_quant_config_meta_h3):

用例 覆盖点
test_offline_block_fp8_checkpoint_layout_and_cpu_load 260 个 FP8 权重与 scale 一一配对、scale 形状为 ceil(n/128)×ceil(k/128)、离线权重无需设备后处理
test_block_fp8_qkv_scale_follows_its_weight_through_the_grouped_reorder 模拟量化器在 grouped 布局下算 scale,验证重排后与权重 tile 的 max 值严格一致
test_qkv_block_scale_param_is_reordered_in_blocks rank_local_weight_transform 作为契约入口验证已安装的变换
test_unquantized_and_per_tensor_qkv_keep_their_loaders 无量化与 per-tensor FP8 路径行为不变、_qkv_scale_block_rows 返回 1
test_block_that_straddles_heads_is_rejected block=256 时抛出 "divides the head dim" 错误

5. 端到端与性能验证

2× RTX 5090、TP=2、layerwise offload、seed 42/10 步/768p 下:无修复时帧内对比度塌缩 16 倍、帧间运动塌缩 19 倍;修复后输出正常,PSNR-Y 16.5 dB / SSIM-Y 0.68 贴近 BF16 参考。本 PR 不改任何计算路径,block-FP8 对比 BF16 在 offload 下从 104.7s 降到 70.4s(1.49 倍)。

文件 模块 状态 重要度
python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py DiT 模型 modified 8.15
python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py 契约测试 modified 6.67

关键符号

_qkv_scale_block_rows _make_row_reorder _install_qkv_weight_loader _reorder

关键源码片段

python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py data-contract

核心修复文件:新增 `_qkv_scale_block_rows` 与 `_make_row_reorder`,让 block-FP8 的 `weight_scale_inv` 按块计数并跟随 qkv 行重排,同时拒绝跨 head 的块配置。

def _qkv_scale_block_rows(qkv_proj: nn.Module, head_dim: int) -> int:
    """返回 qkv 投影的 scale 张量每一行覆盖的权重行数。    逐通道量化与 NVFP4 的 scale 每个权重行对应一行,返回 1;block-FP8 的
    scale 每个权重块(weight_block_size[0] 行)才对应一行,所以 qkv 行置换
    对 scale 需要按块计数。只有完整块才能移动,若块跨越两个 head 的 q/k/v
    行则无法用行置换修复,直接抛错而不是静默错缩放。
    """
    quant_config = getattr(
        getattr(qkv_proj, "quant_method", None), "quant_config", None
    )
    block_size = getattr(quant_config, "weight_block_size", None)
    if not block_size:
        # 无块配置(BF16、per-tensor、NVFP4)时每权重行一行 scale
        return 1
    block_rows = block_size[0]
    if head_dim % block_rows:
        # 块无法整除 head_dim 意味着块会跨 head,行置换无法修复
        raise ValueError(
            "block-quantized qkv needs a block size that divides the head dim: "
            f"head_dim={head_dim}, weight_block_size={block_size}."
        )
    return block_rows
def _install_qkv_weight_loader(self, arch: MiniMaxH3DiTArchConfig) -> None:
    weight = self.qkv_proj.weight
    # H3 checkpoint 按 head 交织存放 Q/K/V 行,而 QKVParallelLinear 期望
    # [q_all, k_all, v_all],加载时必须重排
    weight.checkpoint_mapping_unsafe = True
    base_loader = weight.weight_loader
​
    # 工厂按 head_dim 参数化重排闭包:权重按原始 head_dim 重排,
    # block-FP8 的 scale 按 head_dim // block_rows 重排,结构同构
    def _make_row_reorder(head_dim: int) -> Callable[[torch.Tensor], torch.Tensor]:
        def _reorder(loaded_weight: torch.Tensor) -> torch.Tensor:
            return _reorder_grouped_qkv_to_qkv(
                loaded_weight,
                num_query_groups=arch.num_attention_heads,
                heads_per_group=1,
                head_dim=head_dim,
            )
        return _reorder
​
    _reorder_checkpoint_weight = _make_row_reorder(arch.attention_head_dim)
​
    def _weight_loader(param: torch.Tensor, loaded_weight: torch.Tensor) -> None:
        # TP 直拷路径:稠密 MHA checkpoint 直接写入 TP 本地 Q/K/V 行
        if _copy_grouped_qkv_tp_shard(
            param, loaded_weight,
            num_query_groups=arch.num_attention_heads,
            head_dim=arch.attention_head_dim,
            tp_rank=self.qkv_proj.tp_rank, tp_size=self.tp_size,
        ):
            return
        base_loader(param, _reorder_checkpoint_weight(loaded_weight))
​
    if hasattr(weight, "_weight_loader"):
        weight._weight_loader = _weight_loader
    else:
        weight.weight_loader = _weight_loader
    # rank-local FSDP 也必须先重排 grouped QKV 再切分 shard
    weight.rank_local_weight_transform = _reorder_checkpoint_weight
​
    # 量化 checkpoint 在权重旁存放按输出行索引的元数据(NVFP4 block scale、
    # fp8 per-channel scale)。权重行被置换后,逐行元数据必须同步置换。
    # block-FP8 的 scale 按块而非按行计数:每个块一行,置换与行数门控
    # 都按块高缩放。
    qkv_rows = 3 * arch.num_attention_heads * arch.attention_head_dim
    block_rows = _qkv_scale_block_rows(self.qkv_proj, arch.attention_head_dim)
    for name, param in self.qkv_proj.named_parameters(recurse=False):
        if name == "weight":
            continue
        # 仅 BlockQuantScaleParameter 按块计数;per-tensor 与逐通道 scale
        # 保持每权重行一行,走原有路径
        rows_per_scale_row = (
            block_rows if isinstance(param, BlockQuantScaleParameter) else 1
        )
        _install_qkv_row_reorder(
            param,
            _make_row_reorder(arch.attention_head_dim // rows_per_scale_row),
            qkv_rows // rows_per_scale_row,
        )
python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py test-coverage

新增 5 个测试覆盖离线 block-FP8 布局契约、scale 随权重重排、非块量化路径不变与跨 head 块拒绝,是修复正确性的主要保障。

def test_qkv_block_scale_param_is_reordered_in_blocks():
    model = _meta_h3(_block_fp8_quant_config())
    arch = model.arch
    qkv = model.blocks[0].attn.qkv_proj
    block = 128
    # head_dim 与 block 都是 128,每个 head 的 q/k/v 恰占一个完整 scale 行
    scale_rows = 3 * arch.num_attention_heads * arch.attention_head_dim // block
    assert qkv.weight_scale_inv.shape[0] == scale_rows
​
    # rank_local_weight_transform 是安装后的契约入口:weight_loader 包装后
    # 先执行它再交给 base_loader,FSDP 分片前也会执行它
    loaded = torch.arange(scale_rows * 4, dtype=torch.float32).reshape(scale_rows, 4)
    expected = _reorder_grouped_qkv_to_qkv(
        loaded,
        num_query_groups=arch.num_attention_heads,
        heads_per_group=1,
        head_dim=arch.attention_head_dim // block,
    )
    got = qkv.weight_scale_inv.rank_local_weight_transform(loaded)
    assert torch.equal(got, expected)
    assert not torch.equal(got, loaded)
​
    # 权重本身保持逐行置换,不受块计数影响
    unblocked = torch.zeros(scale_rows * block, 4)
    assert qkv.weight.rank_local_weight_transform(unblocked).shape == unblocked.shape

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

数据契约变更:block-FP8 的 weight_scale_inv 加载行为从"原样传入"变为"按块重排",这是本次修复的核心,属于预期内契约修正,但与 #35740 的逐行元数据行为保持一致。新增硬校验:head_dim % block_rows != 0 会拒绝此前能"静默加载"的配置(如 block=256),这是有意为之的 fail-fast,但若未来出现合法的大块配置需重新审视该限制。多卡路径覆盖有限:新测试在单进程 TP=1 环境运行,TP/FSDP 多卡路径依赖 rank_local_weight_transform 既有契约,未单独多卡验证;不过该入口与 weight_loader 包装共享同一实现,风险可控。契约依赖:新逻辑依赖 quant_config.weight_block_size 约定与 BlockQuantScaleParameter 类型判断,若未来新增返回块布局但参数类型不同的量化方法,可能绕过重排而静默损坏。

影响面收敛在 MiniMax-H3 DiT 的 block-FP8 离线 checkpoint 加载路径:BF16、在线 FP8、per-tensor FP8、NVFP4 与 GGUF 路径经门控和测试确认为不变。对用户而言,直接收益是 block-FP8 权重从"不可用"变为"可用且更快":layerwise offload 下 70.4s 对比 BF16 的 104.7s(1.49 倍),对长视频生成场景有明显成本价值。对团队而言,本 PR 与 #35740 共同确立了"量化元数据必须跟随权重行重排"的加载契约,并提供了拒绝无法修复配置的范式。

数据契约变更 块尺寸硬校验 TP/FSDP 覆盖有限 依赖量化配置约定

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论