From 82c6fc2db9cbada6533022567a5b50ca548a0397 Mon Sep 17 00:00:00 2001 From: Mick Date: Thu, 20 Aug 2026 19:34:14 +0800 Subject: [PATCH] [diffusion] quant: support pruned safetensors checkpoints for minimax-h3 (#35418) --- .../cookbook/diffusion/MiniMax/MiniMax-H3.mdx | 53 +++++ docs/docs/sglang-diffusion/quantization.mdx | 26 ++- .../runtime/layers/quantization/comfy_fp8.py | 145 ++++++++++++++ .../component_loaders/transformer_loader.py | 50 ++++- .../runtime/loader/minimax_h3_weights.py | 139 +++++++++++++ .../runtime/loader/transformer_load_utils.py | 55 +++++- .../runtime/models/dits/minimax_h3.py | 4 +- .../test/unit/test_minimax_h3_dit_contract.py | 59 +++--- .../test/unit/test_transformer_quant.py | 182 +++++++++++++++++- 9 files changed, 672 insertions(+), 41 deletions(-) create mode 100644 python/sglang/multimodal_gen/runtime/layers/quantization/comfy_fp8.py create mode 100644 python/sglang/multimodal_gen/runtime/loader/minimax_h3_weights.py diff --git a/docs/cookbook/diffusion/MiniMax/MiniMax-H3.mdx b/docs/cookbook/diffusion/MiniMax/MiniMax-H3.mdx index 80fe19375..159fe7536 100644 --- a/docs/cookbook/diffusion/MiniMax/MiniMax-H3.mdx +++ b/docs/cookbook/diffusion/MiniMax/MiniMax-H3.mdx @@ -248,6 +248,59 @@ server environment. For MiniMax-H3, `--performance-mode speed` deliberately keeps the DiT eager. The current `torch.compile` path changes the model's numerical output, so it is not enabled implicitly by any recommended lossless preset. An explicit `--enable-torch-compile true` remains available for controlled experiments, but it should not be used to generate consistency ground truth. +### AdaLN-pruned safetensors transformers + +[Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/diffusion_models) +publishes smaller DiT-only checkpoints that replace the original AdaLN branches +with an interpolated curve table. Select one file explicitly; the base model +still supplies the text encoder and VAEs. + +```bash Command +sglang serve \ + --model-path MiniMaxAI/MiniMax-H3 \ + --model-variant fl2va \ + --transformer-weights-path \ + Comfy-Org/MiniMax-H3/diffusion_models/minimax_h3_fl2va_pruned_bf16.safetensors \ + --num-gpus 4 \ + --tp-size 2 \ + --ulysses-degree 2 \ + --performance-mode speed \ + --port 30010 +``` + +The pruned checkpoint is approximate and is therefore rejected by +`quality="high"`, which remains limited to the audited official BF16 DiT. + +The Comfy `pruned_fp8_scaled` FL2VA and Ref2VA files are also supported. Their +per-layer markers are detected automatically; do not add `--quantization`: + +```bash Command +sglang serve \ + --model-path MiniMaxAI/MiniMax-H3 \ + --model-variant fl2va \ + --transformer-weights-path \ + Comfy-Org/MiniMax-H3/diffusion_models/minimax_h3_fl2va_pruned_fp8_scaled.safetensors \ + --num-gpus 4 \ + --tp-size 2 \ + --ulysses-degree 2 \ + --performance-mode speed \ + --port 30010 +``` + +SGLang uses its native static-activation FP8 linear path for attention and +`fc1`. The checkpoint marks `fc2` for full-precision matrix multiplication, so +SGLang retains its FP8 storage but materializes and scales one compute-dtype +`fc2` matrix for each call. This preserves the checkpoint's mixed execution +contract and low resident weight memory, but that part is slower than a fully +quantized FP8 GEMM. TP, Ulysses/Ring sequence parallelism, and +component/layerwise offload are supported; FSDP inference is rejected. + +The `pruned_int8_convrot` files are detected but remain unsupported. They +require online regular-Hadamard ConvRot, dynamic INT8 activation quantization, +and a matching W8A8 GEMM. SGLang fails before loading them instead of silently +treating their stored INT8 values as ordinary weights. A native ConvRot kernel +path should be added and benchmarked separately before these files are accepted. + ### Advanced: precomputed AdaLN cache The [model card](https://huggingface.co/MiniMaxAI/MiniMax-H3) notes that about diff --git a/docs/docs/sglang-diffusion/quantization.mdx b/docs/docs/sglang-diffusion/quantization.mdx index fbded2b50..e037905c2 100644 --- a/docs/docs/sglang-diffusion/quantization.mdx +++ b/docs/docs/sglang-diffusion/quantization.mdx @@ -15,7 +15,9 @@ Use these paths: - `--model-path`: the base or original model - `--transformer-path`: a quantized transformers-style transformer component directory that already contains its own `config.json` -- `--transformer-weights-path`: quantized transformer weights provided as a single safetensors file, a sharded safetensors directory, a local path, or a Hugging Face repo ID +- `--transformer-weights-path`: replacement transformer weights in safetensors + format (file, directory, or Hub repository/file) or a supported GGUF file + (local or Hub) - `--quantization`: apply online quantization to unquantized models at load time (activations are quantized dynamically) - `--quantization-ignored-layers` layer name patterns to keep unquantized (e.g. `attention.to_`) - `--component-paths.text_encoder`: replace a native text encoder with a checkpoint whose `quantization_config` is auto-detected @@ -45,6 +47,12 @@ directory directly as `--model-path`, but that is a compatibility path. If a repo contains multiple candidate checkpoints, pass `--transformer-weights-path` explicitly. +MiniMax-H3 auto-detects the per-layer metadata in Comfy's +`pruned_fp8_scaled` safetensors. Pass one selected FL2VA or Ref2VA file by local +path, `owner/repo/path/file.safetensors`, or direct Hugging Face file URL; do +not combine it with `--quantization`. MiniMax-H3 GGUF usage is documented in +the [MiniMax-H3 cookbook](/cookbook/diffusion/MiniMax/MiniMax-H3#pre-quantized-gguf-transformer). + ## Quant Families Here, `quant_family` means a checkpoint and loading family with shared CLI @@ -111,6 +119,22 @@ backend. None Mixed override repos keep the base model separate; full Qwen Image exports can be loaded directly as --model-path; raw exports such as black-forest-labs/FLUX.2-dev-NVFP4 still use the weights-path flow + + gguf + One selected GGUF DiT file + --transformer-weights-path + MiniMax-H3 original or pruned FL2VA / Ref2VA DiTs + None + CUDA only; auto-detected; supports standard and K-quant GGML types; FSDP and the separate Qwen3-VL text-encoder GGUF files are not supported + + + comfy-fp8 + One selected Comfy safetensors file with per-layer comfy_quant metadata + --transformer-weights-path + MiniMax-H3 pruned FL2VA / Ref2VA DiTs + None + CUDA; auto-detected; TP, sequence parallelism, and component/layerwise offload are supported, while FSDP is not. Checkpoint-marked fc2 layers retain FP8 storage and use compute-dtype matmul. + qvg-kv Unquantized model with runtime causal KV-cache compression diff --git a/python/sglang/multimodal_gen/runtime/layers/quantization/comfy_fp8.py b/python/sglang/multimodal_gen/runtime/layers/quantization/comfy_fp8.py new file mode 100644 index 000000000..b903733e3 --- /dev/null +++ b/python/sglang/multimodal_gen/runtime/layers/quantization/comfy_fp8.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Comfy per-layer FP8 checkpoint support.""" + +from __future__ import annotations + +from typing import Any + +import torch +import torch.nn.functional as F +from torch import nn + +from sglang.multimodal_gen.runtime.layers.linear import ( + LinearBase, + LinearMethodBase, + UnquantizedLinearMethod, +) +from sglang.multimodal_gen.runtime.layers.quantization.configs.base_config import ( + QuantizationConfig, + QuantizeMethodBase, +) +from sglang.multimodal_gen.runtime.layers.quantization.fp8 import ( + Fp8Config, + Fp8LinearMethod, +) +from sglang.multimodal_gen.runtime.models.parameter import ( + ModelWeightParameter, + PerTensorScaleParameter, +) + + +class ComfyFullPrecisionFp8LinearMethod(LinearMethodBase): + """Keep FP8 storage but honor Comfy's full-precision matmul marker.""" + + def create_weights( + self, + layer: nn.Module, + input_size_per_partition: int, + output_partition_sizes: list[int], + input_size: int, + output_size: int, + params_dtype: torch.dtype, + **extra_weight_attrs: Any, + ) -> None: + if len(output_partition_sizes) != 1: + raise ValueError( + "Comfy full_precision_matrix_mult does not support fused linears" + ) + weight_loader = extra_weight_attrs.get("weight_loader") + layer.logical_widths = output_partition_sizes + layer.input_size_per_partition = input_size_per_partition + layer.output_size_per_partition = output_partition_sizes[0] + layer.orig_dtype = params_dtype + weight = ModelWeightParameter( + data=torch.empty( + output_partition_sizes[0], + input_size_per_partition, + dtype=torch.float8_e4m3fn, + ), + input_dim=1, + output_dim=0, + weight_loader=weight_loader, + ) + layer.register_parameter("weight", weight) + weight_scale = PerTensorScaleParameter( + data=torch.empty(1, dtype=torch.float32), + weight_loader=weight_loader, + ) + layer.register_parameter("weight_scale", weight_scale) + + def process_weights_after_loading(self, layer: nn.Module) -> None: + layer.weight = nn.Parameter(layer.weight.data, requires_grad=False) + layer.weight_scale = nn.Parameter(layer.weight_scale.data, requires_grad=False) + + def apply( + self, + layer: nn.Module, + x: torch.Tensor, + bias: torch.Tensor | None = None, + ) -> torch.Tensor: + # Comfy's marker disables quantized GEMM for this layer. Materialize one + # compute-dtype matrix per call so the checkpoint's FP8 residency benefit + # is retained instead of permanently expanding every fc2 weight to BF16. + weight = layer.weight.to(dtype=x.dtype) + weight.mul_(layer.weight_scale[0].to(dtype=x.dtype)) + return F.linear(x, weight, bias) + + +class ComfyFp8Config(QuantizationConfig): + """Dispatch each Linear according to its serialized ``comfy_quant`` marker.""" + + def __init__(self, layer_markers: dict[str, dict[str, Any]]) -> None: + super().__init__() + self.layer_markers = layer_markers + self._fp8_config = Fp8Config( + is_checkpoint_fp8_serialized=True, + activation_scheme="static", + ) + + unsupported = { + prefix: marker.get("format") + for prefix, marker in layer_markers.items() + if marker.get("format") != "float8_e4m3fn" + } + if unsupported: + raise ValueError(f"Unsupported Comfy FP8 layer formats: {unsupported}") + + @classmethod + def get_name(cls) -> str: + return "comfy_fp8" + + @classmethod + def get_supported_act_dtypes(cls) -> list[torch.dtype]: + return Fp8Config.get_supported_act_dtypes() + + @classmethod + def get_min_capability(cls) -> int: + return Fp8Config.get_min_capability() + + @staticmethod + def get_config_filenames() -> list[str]: + return [] + + @classmethod + def from_config(cls, config: dict[str, Any]) -> ComfyFp8Config: + raise ValueError( + "ComfyFp8Config must be constructed from safetensors layer markers" + ) + + def get_quant_method( + self, layer: nn.Module, prefix: str + ) -> QuantizeMethodBase | None: + if not isinstance(layer, LinearBase): + return None + marker = self.layer_markers.get(prefix) + if marker is None: + return UnquantizedLinearMethod() + if marker.get("full_precision_matrix_mult", False): + return ComfyFullPrecisionFp8LinearMethod() + return Fp8LinearMethod(self._fp8_config) + + +__all__ = [ + "ComfyFp8Config", + "ComfyFullPrecisionFp8LinearMethod", +] diff --git a/python/sglang/multimodal_gen/runtime/loader/component_loaders/transformer_loader.py b/python/sglang/multimodal_gen/runtime/loader/component_loaders/transformer_loader.py index ccdffab6a..72a2a9653 100644 --- a/python/sglang/multimodal_gen/runtime/loader/component_loaders/transformer_loader.py +++ b/python/sglang/multimodal_gen/runtime/loader/component_loaders/transformer_loader.py @@ -17,6 +17,12 @@ from sglang.multimodal_gen.runtime.loader.component_loaders.component_loader imp ) from sglang.multimodal_gen.runtime.loader.fsdp_load import maybe_load_fsdp_model from sglang.multimodal_gen.runtime.loader.gguf_weights import gguf_weights_iterator +from sglang.multimodal_gen.runtime.loader.minimax_h3_weights import ( + comfy_quant_key_filter, + inspect_minimax_h3_safetensors, + resolve_minimax_h3_checkpoint_quantization, + validate_minimax_h3_checkpoint_variant, +) from sglang.multimodal_gen.runtime.loader.transformer_load_utils import ( TransformerQuantLoadSpec, resolve_transformer_gguf_to_load, @@ -214,6 +220,35 @@ class TransformerLoader(ComponentLoader): cls_name = config.pop("_class_name") model_cls, _ = ModelRegistry.resolve_model_cls(cls_name) + checkpoint_quant_config = None + if cls_name == "MiniMaxH3DiTModel": + selected_variant = str(component_server_args.model_variant or "fl2va") + if gguf_file is not None: + validate_minimax_h3_checkpoint_variant([gguf_file], selected_variant) + elif component_server_args.transformer_weights_path is not None: + validate_minimax_h3_checkpoint_variant( + safetensors_list, selected_variant + ) + adaln_curve_shape, layer_markers = inspect_minimax_h3_safetensors( + safetensors_list + ) + checkpoint_quant_config = resolve_minimax_h3_checkpoint_quantization( + layer_markers + ) + if adaln_curve_shape is not None: + ( + dit_config.arch_config.adaln_curve_grid, + dit_config.arch_config.time_embed_dim, + ) = adaln_curve_shape + if ( + component_server_args.minimax_h3_adaln_cache_path is not None + or component_server_args.minimax_h3_adaln_online + ): + raise ValueError( + "MiniMax-H3 pruned curve checkpoints cannot use a " + "separate AdaLN cache or online AdaLN rebuild" + ) + quant_spec = resolve_transformer_quant_load_spec( hf_config=config, server_args=component_server_args, @@ -223,6 +258,7 @@ class TransformerLoader(ComponentLoader): cls_name=cls_name, component_name=component_name, gguf_file=gguf_file, + checkpoint_quant_config=checkpoint_quant_config, ) if quant_spec.gguf_file is not None and cls_name == "MiniMaxH3DiTModel": assert quant_spec.quant_config is not None @@ -244,6 +280,11 @@ class TransformerLoader(ComponentLoader): or cpu_offload_flag ) use_fsdp = server_args.should_use_fsdp_for_component(component_name) + if quant_spec.is_comfy_fp8 and use_fsdp: + raise ValueError( + "MiniMax-H3 Comfy FP8 does not support FSDP inference; use TP " + "and/or sequence parallelism instead" + ) if quant_spec.gguf_file is not None: logger.info( @@ -266,7 +307,9 @@ class TransformerLoader(ComponentLoader): "hf_config": config, "quant_config": quant_spec.runtime_quant_config, } - checkpoint_key_filter: Callable[[str], bool] | None = None + checkpoint_key_filter: Callable[[str], bool] | None = ( + comfy_quant_key_filter if quant_spec.is_comfy_fp8 else None + ) adaln_cache_path = component_server_args.minimax_h3_adaln_cache_path if adaln_cache_path is not None: if cls_name != "MiniMaxH3DiTModel": @@ -304,8 +347,9 @@ class TransformerLoader(ComponentLoader): init_params["quant_config"] is None and component_server_args.transformer_weights_path is not None ): - logger.warning( - "transformer_weights_path provided, but quantization config not resolved, which is unexpected and likely to cause errors" + logger.info( + "Using an unquantized transformer weight override from %s", + component_server_args.transformer_weights_path, ) else: logger.debug("quantization config: %s", init_params["quant_config"]) diff --git a/python/sglang/multimodal_gen/runtime/loader/minimax_h3_weights.py b/python/sglang/multimodal_gen/runtime/loader/minimax_h3_weights.py new file mode 100644 index 000000000..e0bbcc987 --- /dev/null +++ b/python/sglang/multimodal_gen/runtime/loader/minimax_h3_weights.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Checkpoint inspection for MiniMax-H3 transformer overrides.""" + +import json +from typing import Any + +from safetensors import safe_open + +from sglang.multimodal_gen.runtime.layers.quantization.comfy_fp8 import ComfyFp8Config +from sglang.multimodal_gen.runtime.layers.quantization.configs.base_config import ( + QuantizationConfig, +) + + +def comfy_quant_key_filter(name: str) -> bool: + return not name.endswith(".comfy_quant") + + +def inspect_minimax_h3_safetensors( + safetensors_list: list[str], +) -> tuple[tuple[int, int] | None, dict[str, dict[str, Any]]]: + """Read H3 architecture metadata and Comfy per-layer format markers.""" + adaln_curve_shape = None + layer_markers: dict[str, dict[str, Any]] = {} + checkpoint_keys: set[str] = set() + fp8_weight_prefixes: set[str] = set() + + for path in safetensors_list: + with safe_open(path, framework="pt", device="cpu") as checkpoint: + keys = checkpoint.keys() + checkpoint_keys.update(keys) + if "adaln_t_table" in keys: + shape = tuple(checkpoint.get_slice("adaln_t_table").get_shape()) + if len(shape) != 2 or shape[0] < 2: + raise ValueError( + "MiniMax-H3 adaln_t_table must have shape [N, D] with " + f"N >= 2, got {shape} in {path}" + ) + if adaln_curve_shape is not None and adaln_curve_shape != shape: + raise ValueError( + "MiniMax-H3 checkpoint shards disagree on adaln_t_table " + f"shape: {adaln_curve_shape} vs {shape}" + ) + adaln_curve_shape = shape + + for key in keys: + if ( + key.endswith(".weight") + and checkpoint.get_slice(key).get_dtype() == "F8_E4M3" + ): + fp8_weight_prefixes.add(key.removesuffix(".weight")) + if not key.endswith(".comfy_quant"): + continue + try: + marker = json.loads(checkpoint.get_tensor(key).numpy().tobytes()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError( + f"Invalid Comfy quantization marker {key!r} in {path}" + ) from exc + if not isinstance(marker, dict): + raise ValueError( + f"Comfy quantization marker {key!r} must contain a JSON object" + ) + prefix = key.removesuffix(".comfy_quant") + previous = layer_markers.get(prefix) + if previous is not None and previous != marker: + raise ValueError( + f"Conflicting Comfy quantization markers for {prefix!r}" + ) + layer_markers[prefix] = marker + + if layer_markers: + missing_markers = fp8_weight_prefixes - layer_markers.keys() + if missing_markers: + raise ValueError( + "MiniMax-H3 FP8 weights are missing comfy_quant metadata: " + f"{sorted(missing_markers)[:5]}" + ) + + for prefix, marker in layer_markers.items(): + if marker.get("format") != "float8_e4m3fn": + continue + required = {f"{prefix}.weight", f"{prefix}.weight_scale"} + if not marker.get("full_precision_matrix_mult", False): + required.add(f"{prefix}.input_scale") + missing = required - checkpoint_keys + if missing: + raise ValueError( + f"MiniMax-H3 Comfy FP8 layer {prefix!r} is missing checkpoint " + f"tensors: {sorted(missing)}" + ) + + return adaln_curve_shape, layer_markers + + +def resolve_minimax_h3_checkpoint_quantization( + layer_markers: dict[str, dict[str, Any]], +) -> QuantizationConfig | None: + if not layer_markers: + return None + + formats = sorted({str(marker.get("format")) for marker in layer_markers.values()}) + if "int8_tensorwise" in formats: + raise NotImplementedError( + "MiniMax-H3 pruned_int8_convrot is not supported yet. Its " + "int8_tensorwise weights require an online regular-Hadamard ConvRot " + "and dynamic INT8 activation quantization kernel; loading them as " + "ordinary INT8/BF16 weights would produce incorrect output." + ) + if formats == ["float8_e4m3fn"]: + return ComfyFp8Config(layer_markers) + raise NotImplementedError( + "Unsupported MiniMax-H3 Comfy quantization format(s): " + ", ".join(formats) + ) + + +def validate_minimax_h3_checkpoint_variant( + checkpoint_paths: list[str], selected_variant: str +) -> None: + names = " ".join(path.lower() for path in checkpoint_paths) + checkpoint_variant = next( + (variant for variant in ("fl2va", "ref2va") if variant in names), None + ) + if ( + checkpoint_variant is not None + and checkpoint_variant != selected_variant.lower() + ): + raise ValueError( + f"MiniMax-H3 checkpoint variant {checkpoint_variant!r} does not match " + f"--model-variant {selected_variant!r}" + ) + + +__all__ = [ + "comfy_quant_key_filter", + "inspect_minimax_h3_safetensors", + "resolve_minimax_h3_checkpoint_quantization", + "validate_minimax_h3_checkpoint_variant", +] diff --git a/python/sglang/multimodal_gen/runtime/loader/transformer_load_utils.py b/python/sglang/multimodal_gen/runtime/loader/transformer_load_utils.py index a9d2d8483..50bb9b080 100644 --- a/python/sglang/multimodal_gen/runtime/loader/transformer_load_utils.py +++ b/python/sglang/multimodal_gen/runtime/loader/transformer_load_utils.py @@ -37,6 +37,7 @@ from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency from sglang.multimodal_gen.runtime.platforms import current_platform from sglang.multimodal_gen.runtime.server_args import ServerArgs from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import ( + hf_hub_download, maybe_download_model, snapshot_download, ) @@ -61,6 +62,11 @@ _PRECISION_VARIANT_SUFFIX_RE = re.compile( r"^(?P.+?)(?P\.(?:fp16|bf16|fp32))(?P-\d+-of-\d+)?(?P\.safetensors)$" ) _MIXED_SAFETENSORS_RE = re.compile(r".*-mixed(?:-\d+-of-\d+)?\.safetensors$") +_HF_SAFETENSORS_URL_RE = re.compile( + r"https?://huggingface\.co/(?P[^/]+/[^/]+)/" + r"(?:blob|resolve)/(?P[^/]+)/(?P.+\.safetensors)$", + re.IGNORECASE, +) def _get_quant_config_name(config: Optional[QuantizationConfig]) -> Optional[str]: @@ -154,6 +160,10 @@ class TransformerQuantLoadSpec: def is_modelopt_fp4(self) -> bool: return _get_quant_config_name(self.quant_config) == "modelopt_fp4" + @property + def is_comfy_fp8(self) -> bool: + return _get_quant_config_name(self.quant_config) == "comfy_fp8" + class _TransformerQuantAdapter: def prepare(self) -> None: @@ -567,7 +577,31 @@ def resolve_transformer_safetensors_to_load( if quantized_path: original_quantized_path = quantized_path - quantized_path = maybe_download_model(original_quantized_path) + direct_url = _HF_SAFETENSORS_URL_RE.fullmatch(original_quantized_path) + if direct_url is not None: + quantized_path = hf_hub_download( + repo_id=direct_url.group("repo"), + filename=direct_url.group("filename"), + revision=direct_url.group("revision"), + ) + else: + parts = original_quantized_path.strip("/").split("/") + is_hub_file = ( + not os.path.exists(original_quantized_path) + and not os.path.isabs(original_quantized_path) + and not original_quantized_path.startswith((".", "~")) + and len(parts) > 2 + and original_quantized_path.endswith(".safetensors") + ) + quantized_path = ( + hf_hub_download( + repo_id="/".join(parts[:2]), + filename="/".join(parts[2:]), + revision=server_args.revision, + ) + if is_hub_file + else maybe_download_model(original_quantized_path) + ) logger.info("using quantized transformer weights from: %s", quantized_path) if os.path.isfile(quantized_path) and quantized_path.endswith(".safetensors"): safetensors_list = [quantized_path] @@ -694,8 +728,11 @@ def resolve_transformer_quant_load_spec( cls_name: str, component_name: str | None = None, gguf_file: str | None = None, + checkpoint_quant_config: QuantizationConfig | None = None, ) -> TransformerQuantLoadSpec: if gguf_file is not None: + if checkpoint_quant_config is not None: + raise ValueError("GGUF and safetensors quantization metadata conflict") return _resolve_gguf_quant_load_spec( gguf_file=gguf_file, server_args=server_args, @@ -703,7 +740,19 @@ def resolve_transformer_quant_load_spec( component_name=component_name, ) - if getattr(model_cls, "handles_checkpoint_quantization", False): + if checkpoint_quant_config is not None: + if server_args.quantization is not None: + raise ValueError( + "Checkpoint quantization is encoded in per-layer metadata; do not " + "also set --quantization" + ) + if server_args.nunchaku_config is not None: + raise ValueError( + "Per-layer checkpoint quantization and Nunchaku are mutually " + "exclusive" + ) + quant_config = checkpoint_quant_config + elif getattr(model_cls, "handles_checkpoint_quantization", False): quant_config = None else: quant_config = _resolve_quant_config( @@ -793,7 +842,7 @@ def _needs_device_weight_postprocess( ) -> bool: """Return whether post-load weight processing needs CUDA/NPU tensors.""" quant_name = _get_quant_config_name(quant_config) - if quant_name == "modelopt_fp8": + if quant_name in ("modelopt_fp8", "comfy_fp8"): return True serialized_flag_by_quant_name = { diff --git a/python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py b/python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py index 563198c8d..2907a4ea4 100644 --- a/python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py +++ b/python/sglang/multimodal_gen/runtime/models/dits/minimax_h3.py @@ -193,8 +193,8 @@ def _copy_grouped_qkv_tp_shard( or getattr(param, "output_dim", None) != 0 or getattr(param, "is_sharded_weight", False) or getattr(param, "packed_dim", None) is not None - or param.dtype != _BF16_DTYPE - or loaded_weight.dtype != _BF16_DTYPE + or param.dtype != loaded_weight.dtype + or param.dtype not in (_BF16_DTYPE, torch.float8_e4m3fn) or not param.is_contiguous() or not loaded_weight.is_contiguous() ): diff --git a/python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py b/python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py index f41bc5d0b..c0be860ae 100644 --- a/python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py +++ b/python/sglang/multimodal_gen/test/unit/test_minimax_h3_dit_contract.py @@ -97,36 +97,35 @@ def test_native_weight_names_and_grouped_qkv_reorder(): ).reshape(12, 1) torch.testing.assert_close(actual, expected) - grouped = torch.arange(48, dtype=torch.int16).reshape(24, 2).view(torch.bfloat16) - reordered = _reorder_grouped_qkv_to_qkv( - grouped, - num_query_groups=4, - heads_per_group=1, - head_dim=2, - ) - for tp_size in (1, 2, 4): - local_rows = 8 // tp_size - for tp_rank in range(tp_size): - start = tp_rank * local_rows - param = torch.nn.Parameter( - torch.empty(3 * local_rows, 2, dtype=torch.bfloat16), - requires_grad=False, - ) - param.output_dim = 0 - assert _copy_grouped_qkv_tp_shard( - param, - grouped, - num_query_groups=4, - head_dim=2, - tp_rank=tp_rank, - tp_size=tp_size, - ) - expected_shard = reordered.view(3, 8, 2)[ - :, start : start + local_rows - ].reshape(-1, 2) - assert torch.equal( - param.view(torch.int16), expected_shard.view(torch.int16) - ) + for dtype in (torch.bfloat16, torch.float8_e4m3fn): + grouped = torch.arange(48, dtype=torch.float32).reshape(24, 2).to(dtype) + reordered = _reorder_grouped_qkv_to_qkv( + grouped, + num_query_groups=4, + heads_per_group=1, + head_dim=2, + ) + for tp_size in (1, 2, 4): + local_rows = 8 // tp_size + for tp_rank in range(tp_size): + start = tp_rank * local_rows + param = torch.nn.Parameter( + torch.empty(3 * local_rows, 2, dtype=dtype), + requires_grad=False, + ) + param.output_dim = 0 + assert _copy_grouped_qkv_tp_shard( + param, + grouped, + num_query_groups=4, + head_dim=2, + tp_rank=tp_rank, + tp_size=tp_size, + ) + expected_shard = reordered.view(3, 8, 2)[ + :, start : start + local_rows + ].reshape(-1, 2) + assert torch.equal(param, expected_shard) def test_pruned_adaln_curve_interpolates_without_timestep_mlp(): diff --git a/python/sglang/multimodal_gen/test/unit/test_transformer_quant.py b/python/sglang/multimodal_gen/test/unit/test_transformer_quant.py index b676b07a5..58b27439a 100644 --- a/python/sglang/multimodal_gen/test/unit/test_transformer_quant.py +++ b/python/sglang/multimodal_gen/test/unit/test_transformer_quant.py @@ -45,11 +45,21 @@ sys.modules.setdefault( ) sys.modules.setdefault("partial_json_parser.core.options", partial_json_parser_options) -from sglang.multimodal_gen.runtime.layers.linear import UnquantizedLinearMethod +from sglang.multimodal_gen.runtime.layers.linear import ( + LinearBase, + UnquantizedLinearMethod, +) +from sglang.multimodal_gen.runtime.layers.quantization.comfy_fp8 import ( + ComfyFp8Config, + ComfyFullPrecisionFp8LinearMethod, +) from sglang.multimodal_gen.runtime.layers.quantization.configs.nunchaku_config import ( NunchakuConfig, ) -from sglang.multimodal_gen.runtime.layers.quantization.fp8 import Fp8Config +from sglang.multimodal_gen.runtime.layers.quantization.fp8 import ( + Fp8Config, + Fp8LinearMethod, +) from sglang.multimodal_gen.runtime.layers.quantization.modelopt_quant import ( ModelOptFp4Config, ModelOptFp8Config, @@ -61,6 +71,10 @@ from sglang.multimodal_gen.runtime.loader.component_loaders.transformer_loader i _resolve_checkpoint_load_device, _warn_if_expected_param_dtype_missing, ) +from sglang.multimodal_gen.runtime.loader.minimax_h3_weights import ( + inspect_minimax_h3_safetensors, + resolve_minimax_h3_checkpoint_quantization, +) from sglang.multimodal_gen.runtime.loader.transformer_load_utils import ( TransformerQuantLoadSpec, _filter_duplicate_precision_variant_safetensors, @@ -119,6 +133,7 @@ class TestTransformerQuantHelpers(unittest.TestCase): nunchaku_config=None, quantization=None, quantization_ignored_layers=None, + revision="test-revision", tp_size=1, dit_cpu_offload=False, direct_gpu_weight_loading=False, @@ -170,6 +185,164 @@ class TestTransformerQuantHelpers(unittest.TestCase): self.assertEqual(resolved, [f.name]) + @patch( + "sglang.multimodal_gen.runtime.loader.transformer_load_utils.hf_hub_download", + return_value="/cache/model.safetensors", + ) + def test_resolve_transformer_safetensors_to_load_uses_hf_file_reference( + self, mock_download + ): + filename = "diffusion_models/minimax_h3_fl2va_pruned_bf16.safetensors" + references = ( + ( + f"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/{filename}", + "main", + ), + (f"Comfy-Org/MiniMax-H3/{filename}", "test-revision"), + ) + + for reference, revision in references: + with self.subTest(reference=reference): + server_args = self._make_server_args(transformer_weights_path=reference) + with patch( + "os.path.isfile", + side_effect=lambda path: path == "/cache/model.safetensors", + ): + self.assertEqual( + resolve_transformer_safetensors_to_load( + server_args, "/unused/component/path" + ), + ["/cache/model.safetensors"], + ) + mock_download.assert_called_once_with( + repo_id="Comfy-Org/MiniMax-H3", + filename=filename, + revision=revision, + ) + mock_download.reset_mock() + + def test_inspect_minimax_h3_safetensors_detects_curve_and_comfy_format(self): + marker = json.dumps({"format": "int8_tensorwise", "convrot": True}).encode() + with tempfile.NamedTemporaryFile(suffix=".safetensors") as f: + save_file( + { + "adaln_t_table": torch.zeros((1025, 8)), + "blocks.0.mlp.fc1.comfy_quant": torch.tensor( + list(marker), dtype=torch.uint8 + ), + }, + f.name, + ) + + curve_shape, comfy_quant = inspect_minimax_h3_safetensors([f.name]) + + self.assertEqual(curve_shape, (1025, 8)) + self.assertEqual(comfy_quant["blocks.0.mlp.fc1"]["format"], "int8_tensorwise") + + def test_inspect_minimax_h3_fp8_validates_required_scales(self): + marker = torch.tensor(list(b'{"format":"float8_e4m3fn"}'), dtype=torch.uint8) + with tempfile.NamedTemporaryFile(suffix=".safetensors") as f: + save_file( + { + "blocks.0.mlp.fc1.weight": torch.ones( + (2, 2), dtype=torch.float8_e4m3fn + ), + "blocks.0.mlp.fc1.weight_scale": torch.tensor(0.5), + "blocks.0.mlp.fc1.input_scale": torch.tensor(0.25), + "blocks.0.mlp.fc1.comfy_quant": marker, + }, + f.name, + ) + + _, layer_markers = inspect_minimax_h3_safetensors([f.name]) + + self.assertEqual(layer_markers["blocks.0.mlp.fc1"], {"format": "float8_e4m3fn"}) + + def test_minimax_h3_comfy_int8_fails_before_weight_loading(self): + with self.assertRaisesRegex(NotImplementedError, "regular-Hadamard"): + resolve_minimax_h3_checkpoint_quantization( + { + "blocks.0.mlp.fc1": { + "format": "int8_tensorwise", + "convrot": True, + } + } + ) + + def test_minimax_h3_comfy_fp8_resolves_per_layer_dispatch(self): + config = resolve_minimax_h3_checkpoint_quantization( + { + "blocks.0.attn.qkv_proj": {"format": "float8_e4m3fn"}, + "blocks.0.mlp.fc2": { + "format": "float8_e4m3fn", + "full_precision_matrix_mult": True, + }, + } + ) + + self.assertIsInstance(config, ComfyFp8Config) + layer = LinearBase(input_size=1, output_size=1) + self.assertIsInstance( + config.get_quant_method(layer, "blocks.0.mlp.fc2"), + ComfyFullPrecisionFp8LinearMethod, + ) + self.assertIsInstance( + config.get_quant_method(layer, "blocks.0.attn.qkv_proj"), + Fp8LinearMethod, + ) + self.assertIsInstance( + config.get_quant_method(layer, "unmarked"), + UnquantizedLinearMethod, + ) + + def test_comfy_full_precision_fp8_dequantizes_before_linear(self): + layer = torch.nn.Module() + layer.weight = torch.nn.Parameter( + torch.tensor([[2.0, -4.0]], dtype=torch.float8_e4m3fn), + requires_grad=False, + ) + layer.weight_scale = torch.nn.Parameter( + torch.tensor([0.5]), requires_grad=False + ) + + output = ComfyFullPrecisionFp8LinearMethod().apply( + layer, torch.tensor([[3.0, 1.0]]) + ) + + torch.testing.assert_close(output, torch.tensor([[1.0]])) + + def test_checkpoint_quantization_metadata_drives_load_spec(self): + config = ComfyFp8Config({}) + server_args = self._make_server_args() + + spec = resolve_transformer_quant_load_spec( + hf_config={}, + server_args=server_args, + safetensors_list=["model.safetensors"], + component_model_path="/unused/component/path", + model_cls=_FakeFluxTransformer, + cls_name=_FakeFluxTransformer.__name__, + checkpoint_quant_config=config, + ) + + self.assertIs(spec.quant_config, config) + self.assertTrue(spec.is_comfy_fp8) + self.assertTrue(spec.needs_device_weight_postprocess) + + def test_checkpoint_quantization_rejects_explicit_quantization(self): + server_args = self._make_server_args(quantization="fp8") + + with self.assertRaisesRegex(ValueError, "per-layer metadata"): + resolve_transformer_quant_load_spec( + hf_config={}, + server_args=server_args, + safetensors_list=["model.safetensors"], + component_model_path="/unused/component/path", + model_cls=_FakeFluxTransformer, + cls_name=_FakeFluxTransformer.__name__, + checkpoint_quant_config=ComfyFp8Config({}), + ) + @patch( "sglang.multimodal_gen.runtime.loader.transformer_load_utils.maybe_download_model", side_effect=lambda path, **kw: path, @@ -355,6 +528,11 @@ class TestTransformerQuantHelpers(unittest.TestCase): ) ) + def test_comfy_fp8_needs_device_weight_postprocess(self): + self.assertTrue( + _needs_device_weight_postprocess(_make_quant_config("comfy_fp8")) + ) + def test_online_fp8_receives_cli_ignored_layer_patterns(self): ignored_layers = ["blocks.0.attn.out_proj", "condition_proj"] server_args = self._make_server_args(