chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,883 @@
"""Build an SGLang-loadable ModelOpt FP8 diffusion transformer.
The core conversion path is model-agnostic:
- read the ModelOpt diffusers transformer export
- rebuild per-layer `weight_scale` / `input_scale` tensors from `backbone.pt`
- materialize SGLang-native `float8_e4m3fn` weights
- preserve ModelOpt `ignore` layers in their original dtype
Some models still benefit from a small validated BF16 fallback set. Those
fallback profiles are intentionally isolated so the generic FP8 conversion path
remains reusable across future diffusion backbones.
Example:
python -m sglang.multimodal_gen.tools.build_modelopt_fp8_transformer \
--modelopt-hf-dir /tmp/modelopt_flux2_fp8/hf \
--modelopt-backbone-ckpt /tmp/modelopt_flux2_fp8/ckpt/backbone.pt \
--base-transformer-dir /path/to/FLUX.2-dev/transformer \
--output-dir /tmp/modelopt_flux2_fp8/sglang_transformer
"""
from __future__ import annotations
import argparse
import gc
import json
import os
import re
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence
import torch
from safetensors import safe_open
from safetensors.torch import load_file, save_file
from sglang.multimodal_gen.runtime.utils.quantization_utils import (
normalize_flat_modelopt_quant_config,
)
INDEX_FILENAMES = [
"model.safetensors.index.json",
"diffusion_pytorch_model.safetensors.index.json",
]
FP8_E4M3_MAXBOUND = 448.0
DEFAULT_FLUX2_KEEP_BF16_PATTERNS = [
r"^time_guidance_embed\.(timestep_embedder|guidance_embedder)\.linear_[12]$",
r"^double_stream_modulation_(img|txt)\.linear$",
r"^single_stream_modulation\.linear$",
r"^x_embedder$",
r"^context_embedder$",
r"^norm_out\.linear$",
]
DEFAULT_FLUX1_KEEP_BF16_PATTERNS = [
r"^transformer_blocks\.\d+\.norm1\.linear$",
r"^transformer_blocks\.\d+\.norm1_context\.linear$",
r"^transformer_blocks\.\d+\.ff\.net\.0\.proj$",
r"^transformer_blocks\.\d+\.ff\.net\.2$",
r"^transformer_blocks\.\d+\.ff_context\.net\.0\.proj$",
r"^transformer_blocks\.\d+\.ff_context\.net\.2$",
r"^single_transformer_blocks\.\d+\.norm\.linear$",
]
DEFAULT_LTX2_KEEP_BF16_PATTERNS = [
r"^(audio_)?adaln_single\.emb\.timestep_embedder\.linear_[12]$",
r"^(audio_)?adaln_single\.linear$",
r"^audio_caption_projection\.linear_[12]$",
r"^audio_patchify_proj$",
r"^audio_proj_out$",
r"^av_ca_(a2v_gate|audio_scale_shift|v2a_gate|video_scale_shift)_adaln_single\.emb\.timestep_embedder\.linear_[12]$",
r"^av_ca_(a2v_gate|audio_scale_shift|v2a_gate|video_scale_shift)_adaln_single\.linear$",
r"^caption_projection\.linear_[12]$",
r"^patchify_proj$",
r"^proj_out$",
r"^transformer_blocks\.(0|43|44|45|46|47)\.(attn1|attn2|audio_attn1|audio_attn2|audio_to_video_attn|video_to_audio_attn)\.to_(q|k|v)$",
r"^transformer_blocks\.(0|43|44|45|46|47)\.(attn1|attn2|audio_attn1|audio_attn2|audio_to_video_attn|video_to_audio_attn)\.to_out\.0$",
r"^transformer_blocks\.(0|43|44|45|46|47)\.(ff|audio_ff)\.proj_(in|out)$",
]
DEFAULT_HUNYUANVIDEO_KEEP_BF16_PATTERNS = [
r"^context_embedder\.",
r"^x_embedder\.proj$",
r"^time_text_embed\.(timestep_embedder|guidance_embedder|text_embedder)\.linear_[12]$",
r"^norm_out\.linear$",
r"^proj_out$",
r"^transformer_blocks\.\d+\.norm1\.linear$",
r"^transformer_blocks\.\d+\.norm1_context\.linear$",
r"^single_transformer_blocks\.\d+\.norm\.linear$",
]
HUNYUANVIDEO_RUNTIME_NAME_REPLACEMENTS = [
(
r"^context_embedder\.time_text_embed\.timestep_embedder\.linear_1$",
r"txt_in.t_embedder.mlp.fc_in",
),
(
r"^context_embedder\.time_text_embed\.timestep_embedder\.linear_2$",
r"txt_in.t_embedder.mlp.fc_out",
),
(r"^context_embedder\.proj_in$", r"txt_in.input_embedder"),
(
r"^context_embedder\.time_text_embed\.text_embedder\.linear_1$",
r"txt_in.c_embedder.fc_in",
),
(
r"^context_embedder\.time_text_embed\.text_embedder\.linear_2$",
r"txt_in.c_embedder.fc_out",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm1$",
r"txt_in.refiner_blocks.\1.norm1",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm2$",
r"txt_in.refiner_blocks.\1.norm2",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_[qkv]$",
r"txt_in.refiner_blocks.\1.self_attn_qkv",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_out\.0$",
r"txt_in.refiner_blocks.\1.self_attn_proj",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.ff\.net\.0(?:\.proj)?$",
r"txt_in.refiner_blocks.\1.mlp.fc_in",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.ff\.net\.2(?:\.proj)?$",
r"txt_in.refiner_blocks.\1.mlp.fc_out",
),
(
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm_out\.linear$",
r"txt_in.refiner_blocks.\1.adaLN_modulation.linear",
),
(r"^x_embedder\.proj$", r"img_in.proj"),
(r"^time_text_embed\.timestep_embedder\.linear_1$", r"time_in.mlp.fc_in"),
(r"^time_text_embed\.timestep_embedder\.linear_2$", r"time_in.mlp.fc_out"),
(r"^time_text_embed\.guidance_embedder\.linear_1$", r"guidance_in.mlp.fc_in"),
(r"^time_text_embed\.guidance_embedder\.linear_2$", r"guidance_in.mlp.fc_out"),
(r"^time_text_embed\.text_embedder\.linear_1$", r"vector_in.fc_in"),
(r"^time_text_embed\.text_embedder\.linear_2$", r"vector_in.fc_out"),
(r"^transformer_blocks\.(\d+)\.norm1\.linear$", r"double_blocks.\1.img_mod.linear"),
(
r"^transformer_blocks\.(\d+)\.norm1_context\.linear$",
r"double_blocks.\1.txt_mod.linear",
),
(r"^transformer_blocks\.(\d+)\.attn\.norm_q$", r"double_blocks.\1.img_attn_q_norm"),
(r"^transformer_blocks\.(\d+)\.attn\.norm_k$", r"double_blocks.\1.img_attn_k_norm"),
(r"^transformer_blocks\.(\d+)\.attn\.to_[qkv]$", r"double_blocks.\1.img_attn_qkv"),
(
r"^transformer_blocks\.(\d+)\.attn\.add_[qkv]_proj$",
r"double_blocks.\1.txt_attn_qkv",
),
(
r"^transformer_blocks\.(\d+)\.attn\.to_out\.0$",
r"double_blocks.\1.img_attn_proj",
),
(
r"^transformer_blocks\.(\d+)\.attn\.to_add_out$",
r"double_blocks.\1.txt_attn_proj",
),
(
r"^transformer_blocks\.(\d+)\.attn\.norm_added_q$",
r"double_blocks.\1.txt_attn_q_norm",
),
(
r"^transformer_blocks\.(\d+)\.attn\.norm_added_k$",
r"double_blocks.\1.txt_attn_k_norm",
),
(
r"^transformer_blocks\.(\d+)\.ff\.net\.0(?:\.proj)?$",
r"double_blocks.\1.img_mlp.fc_in",
),
(
r"^transformer_blocks\.(\d+)\.ff\.net\.2(?:\.proj)?$",
r"double_blocks.\1.img_mlp.fc_out",
),
(
r"^transformer_blocks\.(\d+)\.ff_context\.net\.0(?:\.proj)?$",
r"double_blocks.\1.txt_mlp.fc_in",
),
(
r"^transformer_blocks\.(\d+)\.ff_context\.net\.2(?:\.proj)?$",
r"double_blocks.\1.txt_mlp.fc_out",
),
(r"^single_transformer_blocks\.(\d+)\.attn\.norm_q$", r"single_blocks.\1.q_norm"),
(r"^single_transformer_blocks\.(\d+)\.attn\.norm_k$", r"single_blocks.\1.k_norm"),
(
r"^single_transformer_blocks\.(\d+)\.attn\.to_[qkv]$",
r"single_blocks.\1.linear1",
),
(r"^single_transformer_blocks\.(\d+)\.proj_mlp$", r"single_blocks.\1.linear1"),
(r"^single_transformer_blocks\.(\d+)\.proj_out$", r"single_blocks.\1.linear2"),
(
r"^single_transformer_blocks\.(\d+)\.norm\.linear$",
r"single_blocks.\1.modulation.linear",
),
(r"^norm_out\.linear$", r"final_layer.adaLN_modulation.linear"),
(r"^proj_out$", r"final_layer.linear"),
]
DEFAULT_QWEN_IMAGE_KEEP_BF16_PATTERNS = [
r"^img_in$",
r"^txt_in$",
r"^time_text_embed\.timestep_embedder\.linear_[12]$",
r"^norm_out\.linear$",
r"^proj_out$",
r"^transformer_blocks\.\d+\.img_mlp\.net\.2$",
r"^transformer_blocks\.\d+\.(img_mod|txt_mod)$",
]
def _resolve_transformer_dir(path: str) -> str:
candidate = Path(path).expanduser().resolve()
if (candidate / "config.json").is_file():
return str(candidate)
transformer_dir = candidate / "transformer"
if (transformer_dir / "config.json").is_file():
return str(transformer_dir)
raise FileNotFoundError(f"Could not resolve a transformer directory from: {path}")
def _resolve_backbone_ckpt(path: str) -> str:
candidate = Path(path).expanduser().resolve()
if candidate.is_file():
return str(candidate)
backbone_path = candidate / "backbone.pt"
if backbone_path.is_file():
return str(backbone_path)
raise FileNotFoundError(f"Could not resolve backbone.pt from: {path}")
def _find_index_file(model_dir: str) -> str | None:
for filename in INDEX_FILENAMES:
candidate = os.path.join(model_dir, filename)
if os.path.isfile(candidate):
return filename
matches = sorted(
filename
for filename in os.listdir(model_dir)
if filename.endswith(".safetensors.index.json")
)
return matches[0] if matches else None
def _load_weight_map(model_dir: str) -> tuple[dict[str, str], str | None]:
index_filename = _find_index_file(model_dir)
if index_filename is not None:
with open(os.path.join(model_dir, index_filename), encoding="utf-8") as f:
index_data = json.load(f)
return dict(index_data["weight_map"]), index_filename
safetensors_files = sorted(
filename
for filename in os.listdir(model_dir)
if filename.endswith(".safetensors")
)
if len(safetensors_files) != 1:
raise ValueError(
f"Expected an index file or a single safetensors shard in {model_dir}, "
f"found {len(safetensors_files)} shard(s)."
)
shard_name = safetensors_files[0]
with safe_open(
os.path.join(model_dir, shard_name), framework="pt", device="cpu"
) as f:
weight_map = {key: shard_name for key in f.keys()}
index_filename = f"{Path(shard_name).stem}.safetensors.index.json"
return weight_map, index_filename
def _load_config(model_dir: str) -> dict:
config_path = os.path.join(model_dir, "config.json")
with open(config_path, encoding="utf-8") as f:
return json.load(f)
def _load_first_shard_metadata(
model_dir: str, weight_map: Mapping[str, str]
) -> dict[str, str]:
if not weight_map:
return {}
first_shard = next(iter(weight_map.values()))
with safe_open(
os.path.join(model_dir, first_shard), framework="pt", device="cpu"
) as f:
return dict(f.metadata() or {})
def _map_hunyuanvideo_runtime_module_name(module_name: str) -> list[str]:
mapped_names: list[str] = []
for pattern, replacement in HUNYUANVIDEO_RUNTIME_NAME_REPLACEMENTS:
mapped = re.sub(pattern, replacement, module_name)
if mapped != module_name:
mapped_names.append(mapped)
return mapped_names
def _get_runtime_module_name_mapper(
*, model_type: str, class_name: str | None
) -> Callable[[str], list[str]] | None:
if model_type == "hunyuan-video" or class_name == "HunyuanVideoTransformer3DModel":
return _map_hunyuanvideo_runtime_module_name
return None
def _module_name_variants(
weight_name: str,
runtime_name_mapper: Callable[[str], list[str]] | None = None,
) -> list[str]:
module_name = weight_name[:-7] if weight_name.endswith(".weight") else weight_name
variants = [module_name]
for prefix in ("model.diffusion_model.", "velocity_model."):
if module_name.startswith(prefix):
variants.append(module_name[len(prefix) :])
canonicalized: list[str] = []
for variant in variants:
canonicalized.append(
re.sub(r"(\.audio_ff|\.ff)\.net\.0\.proj$", r"\1.proj_in", variant)
)
canonicalized.append(
re.sub(r"(\.audio_ff|\.ff)\.net\.2$", r"\1.proj_out", variant)
)
canonicalized.append(re.sub(r"(\.(img_mod|txt_mod))\.1$", r"\1", variant))
variants.extend(canonicalized)
if runtime_name_mapper is not None:
runtime_variants: list[str] = []
for variant in variants:
runtime_variants.extend(runtime_name_mapper(variant))
variants.extend(runtime_variants)
deduped: list[str] = []
for variant in variants:
if variant not in deduped:
deduped.append(variant)
return deduped
def _preferred_module_name(
weight_name: str,
runtime_name_mapper: Callable[[str], list[str]] | None = None,
) -> str:
return _module_name_variants(weight_name, runtime_name_mapper)[-1]
def _scale_key_candidates(weight_name: str) -> list[str]:
candidates = [weight_name]
if weight_name.startswith("model.diffusion_model."):
candidates.append(
"velocity_model." + weight_name[len("model.diffusion_model.") :]
)
return candidates
def _resolve_scale_key(
weight_name: str,
scale_map: Mapping[str, Mapping[str, torch.Tensor]],
) -> str | None:
for candidate in _scale_key_candidates(weight_name):
if candidate in scale_map:
return candidate
return None
def _is_ltx2_x0_export(
*,
config: Mapping[str, object],
source_metadata: Mapping[str, str],
source_weight_map: Mapping[str, str],
) -> bool:
if config.get("_class_name") != "X0Model":
return False
if not any(name.startswith("model.diffusion_model.") for name in source_weight_map):
return False
try:
metadata_config = json.loads(str(source_metadata.get("config", "")))
except json.JSONDecodeError:
return False
return isinstance(metadata_config.get("transformer"), dict)
def _build_output_config(
*,
source_config: Mapping[str, object],
source_metadata: Mapping[str, str],
quant_config: Mapping[str, object],
is_ltx2_x0_export: bool,
) -> dict[str, object]:
if is_ltx2_x0_export:
metadata_config = json.loads(str(source_metadata["config"]))
output_config = dict(metadata_config["transformer"])
output_config["_class_name"] = "LTX2VideoTransformer3DModel"
else:
output_config = dict(source_config)
output_config["quantization_config"] = dict(quant_config)
return output_config
def _should_keep_ltx2_transformer_key(weight_name: str) -> bool:
if not weight_name.startswith("model.diffusion_model."):
return False
connector_prefixes = (
"model.diffusion_model.audio_embeddings_connector.",
"model.diffusion_model.video_embeddings_connector.",
)
return not weight_name.startswith(connector_prefixes)
def get_default_keep_bf16_patterns(
*, model_type: str, class_name: str | None
) -> list[str]:
if model_type == "ltx2":
return list(DEFAULT_LTX2_KEEP_BF16_PATTERNS)
if model_type == "flux1":
return list(DEFAULT_FLUX1_KEEP_BF16_PATTERNS)
if model_type == "flux2":
return list(DEFAULT_FLUX2_KEEP_BF16_PATTERNS)
if model_type == "hunyuan-video":
return list(DEFAULT_HUNYUANVIDEO_KEEP_BF16_PATTERNS)
if model_type == "qwen-image":
return list(DEFAULT_QWEN_IMAGE_KEEP_BF16_PATTERNS)
if model_type == "none":
return []
if class_name == "FluxTransformer2DModel":
return list(DEFAULT_FLUX1_KEEP_BF16_PATTERNS)
if class_name == "Flux2Transformer2DModel":
return list(DEFAULT_FLUX2_KEEP_BF16_PATTERNS)
if class_name == "HunyuanVideoTransformer3DModel":
return list(DEFAULT_HUNYUANVIDEO_KEEP_BF16_PATTERNS)
if class_name == "QwenImageTransformer2DModel":
return list(DEFAULT_QWEN_IMAGE_KEEP_BF16_PATTERNS)
return []
def should_keep_bf16(
weight_name: str,
keep_bf16_patterns: Sequence[str],
runtime_name_mapper: Callable[[str], list[str]] | None = None,
) -> bool:
if not keep_bf16_patterns:
return False
return any(
re.search(pattern, module_name)
for pattern in keep_bf16_patterns
for module_name in _module_name_variants(weight_name, runtime_name_mapper)
)
def is_ignored_by_modelopt(
weight_name: str,
ignore_patterns: Sequence[str],
runtime_name_mapper: Callable[[str], list[str]] | None = None,
) -> bool:
if not ignore_patterns:
return False
for pattern in ignore_patterns:
regex_str = pattern.replace(".", r"\.").replace("*", r".*")
if any(
re.fullmatch(regex_str, module_name)
for module_name in _module_name_variants(weight_name, runtime_name_mapper)
):
return True
return False
def build_fp8_scale_map(
model_state_dict: Mapping[str, torch.Tensor],
*,
maxbound: float = FP8_E4M3_MAXBOUND,
) -> dict[str, dict[str, torch.Tensor]]:
scale_map: dict[str, dict[str, torch.Tensor]] = {}
for key, value in model_state_dict.items():
if key.endswith(".weight_quantizer._amax"):
layer_name = key[: -len(".weight_quantizer._amax")]
scale_map.setdefault(f"{layer_name}.weight", {})["weight_scale"] = (
value.detach().to(torch.float32).reshape(1).cpu() / maxbound
)
elif key.endswith(".input_quantizer._amax"):
layer_name = key[: -len(".input_quantizer._amax")]
scale_map.setdefault(f"{layer_name}.weight", {})["input_scale"] = (
value.detach().to(torch.float32).reshape(1).cpu() / maxbound
)
return {
weight_name: scale_tensors
for weight_name, scale_tensors in scale_map.items()
if {"weight_scale", "input_scale"} <= set(scale_tensors)
}
def quantize_fp8_weight(
weight: torch.Tensor,
weight_scale: torch.Tensor,
) -> torch.Tensor:
if weight.dtype == torch.float8_e4m3fn:
return weight.contiguous()
scale = weight_scale.to(weight.device, dtype=torch.float32)
if scale.numel() != 1:
raise ValueError(
"Only per-tensor FP8 scales are supported for diffusion checkpoints, "
f"got shape {tuple(scale.shape)}."
)
quantized = (weight.to(torch.float32) / scale.reshape(1)).to(torch.float8_e4m3fn)
return quantized.cpu().contiguous()
def _copy_non_shard_files(source_dir: str, output_dir: str) -> None:
ignored = set(INDEX_FILENAMES)
for entry in os.listdir(source_dir):
if entry.endswith(".safetensors") or entry in ignored:
continue
source_path = os.path.join(source_dir, entry)
output_path = os.path.join(output_dir, entry)
if os.path.isdir(source_path):
shutil.copytree(source_path, output_path, dirs_exist_ok=True)
else:
shutil.copy2(source_path, output_path)
def _load_selected_tensors(
model_dir: str,
weight_map: Mapping[str, str],
tensor_names: Iterable[str],
) -> dict[str, torch.Tensor]:
tensors: dict[str, torch.Tensor] = {}
names_by_file: dict[str, list[str]] = defaultdict(list)
for name in tensor_names:
names_by_file[weight_map[name]].append(name)
for filename, names in names_by_file.items():
shard_path = os.path.join(model_dir, filename)
with safe_open(shard_path, framework="pt", device="cpu") as f:
for name in names:
tensors[name] = f.get_tensor(name).contiguous()
return tensors
def build_modelopt_fp8_transformer(
*,
modelopt_hf_dir: str,
modelopt_backbone_ckpt: str,
output_dir: str,
base_transformer_dir: str | None = None,
model_type: str = "auto",
keep_bf16_patterns: Sequence[str] | None = None,
maxbound: float = FP8_E4M3_MAXBOUND,
overwrite: bool = False,
) -> dict[str, int]:
source_dir = _resolve_transformer_dir(modelopt_hf_dir)
backbone_ckpt_path = _resolve_backbone_ckpt(modelopt_backbone_ckpt)
base_dir = (
_resolve_transformer_dir(base_transformer_dir) if base_transformer_dir else None
)
config = _load_config(source_dir)
quant_config = config.get("quantization_config")
if not isinstance(quant_config, dict):
raise ValueError(
"Expected a flat quantization_config dict in the ModelOpt export."
)
if quant_config.get("quant_method") != "modelopt":
raise ValueError(
"This tool only supports ModelOpt diffusers FP8 exports "
"(quant_method=modelopt)."
)
source_weight_map_all, index_filename = _load_weight_map(source_dir)
source_metadata = _load_first_shard_metadata(source_dir, source_weight_map_all)
is_ltx2_export = _is_ltx2_x0_export(
config=config,
source_metadata=source_metadata,
source_weight_map=source_weight_map_all,
)
class_name = config.get("_class_name")
runtime_name_mapper = _get_runtime_module_name_mapper(
model_type=model_type, class_name=class_name
)
ignore_patterns = list(quant_config.get("ignore", []) or [])
patterns = list(
get_default_keep_bf16_patterns(model_type=model_type, class_name=class_name)
)
if is_ltx2_export and model_type == "auto":
patterns.extend(DEFAULT_LTX2_KEEP_BF16_PATTERNS)
if keep_bf16_patterns:
patterns.extend(keep_bf16_patterns)
if patterns and base_dir is None and not is_ltx2_export:
raise ValueError(
"BF16 fallback patterns are enabled, but --base-transformer-dir was not provided."
)
output_path = Path(output_dir).expanduser().resolve()
if output_path.exists():
if not overwrite:
raise FileExistsError(
f"Output directory already exists: {output_path}. "
"Use --overwrite to replace it."
)
shutil.rmtree(output_path)
output_path.mkdir(parents=True, exist_ok=True)
_copy_non_shard_files(source_dir, str(output_path))
if is_ltx2_export:
source_weight_map = {
name: filename
for name, filename in source_weight_map_all.items()
if _should_keep_ltx2_transformer_key(name)
}
else:
source_weight_map = source_weight_map_all
base_weight_map: dict[str, str] = {}
if base_dir is not None:
base_weight_map, _ = _load_weight_map(base_dir)
fallback_weight_names = sorted(
weight_name
for weight_name in source_weight_map
if weight_name.endswith(".weight")
and should_keep_bf16(weight_name, patterns, runtime_name_mapper)
)
fallback_weight_names_set = set(fallback_weight_names)
backbone_state = torch.load(backbone_ckpt_path, map_location="cpu")[
"model_state_dict"
]
fp8_scale_map = build_fp8_scale_map(backbone_state, maxbound=maxbound)
quant_algo = str(quant_config.get("quant_algo", "")).upper()
if quant_algo and "FP8" not in quant_algo:
raise ValueError(
"This tool only supports ModelOpt diffusers FP8 exports, "
f"got quant_algo={quant_config.get('quant_algo')!r}."
)
if not quant_algo and not fp8_scale_map:
raise ValueError(
"Could not infer an FP8 ModelOpt export: quantization_config.quant_algo "
"is missing and backbone.pt does not contain FP8 scale tensors."
)
effective_quant_config = json.loads(json.dumps(quant_config))
if not quant_algo:
effective_quant_config["quant_algo"] = "FP8"
effective_quant_config = (
normalize_flat_modelopt_quant_config(effective_quant_config)
or effective_quant_config
)
auto_ignore_modules = sorted(
{
_preferred_module_name(weight_name, runtime_name_mapper)
for weight_name in source_weight_map
if weight_name.endswith(".weight")
and _resolve_scale_key(weight_name, fp8_scale_map) is None
}
)
fallback_ignore_modules = sorted(
{
_preferred_module_name(weight_name, runtime_name_mapper)
for weight_name in fallback_weight_names
}
)
ignore_patterns = sorted(
{
*ignore_patterns,
*auto_ignore_modules,
*fallback_ignore_modules,
}
)
effective_quant_config["ignore"] = ignore_patterns
serialized_quant_config = json.dumps(effective_quant_config, sort_keys=True)
output_config = _build_output_config(
source_config=config,
source_metadata=source_metadata,
quant_config=effective_quant_config,
is_ltx2_x0_export=is_ltx2_export,
)
fallback_tensors = (
_load_selected_tensors(base_dir, base_weight_map, fallback_weight_names)
if fallback_weight_names and base_dir is not None
else {}
)
fallback_scale_names = {
scale_name
for weight_name in fallback_weight_names
for scale_name in (
weight_name[:-7] + ".weight_scale",
weight_name[:-7] + ".input_scale",
)
}
weights_by_file: dict[str, list[str]] = defaultdict(list)
for weight_name, filename in source_weight_map.items():
weights_by_file[filename].append(weight_name)
updated_weight_map: dict[str, str] = {}
total_size = 0
added_scale_count = 0
preserved_ignored_weight_count = 0
for filename, names in sorted(weights_by_file.items()):
shard_path = os.path.join(source_dir, filename)
shard_tensors = load_file(shard_path, device="cpu")
selected_names = set(names)
with safe_open(shard_path, framework="pt", device="cpu") as f:
metadata = dict(f.metadata() or {})
metadata.setdefault("format", "pt")
metadata["_class_name"] = str(
output_config.get("_class_name", metadata.get("_class_name", ""))
)
metadata["config"] = json.dumps(output_config, sort_keys=True)
metadata["quantization_config"] = serialized_quant_config
metadata["_quantization_metadata"] = serialized_quant_config
for name in list(shard_tensors.keys()):
if name not in selected_names:
del shard_tensors[name]
continue
if "_quantizer." in name:
del shard_tensors[name]
continue
if name in fallback_scale_names:
del shard_tensors[name]
continue
if name in fallback_tensors:
shard_tensors[name] = fallback_tensors[name]
continue
if name.endswith(".weight") and is_ignored_by_modelopt(
name, ignore_patterns, runtime_name_mapper
):
preserved_ignored_weight_count += 1
continue
scale_key = _resolve_scale_key(name, fp8_scale_map)
if (
name.endswith(".weight")
and scale_key is not None
and name not in fallback_tensors
and name not in fallback_weight_names_set
):
scale_tensors = fp8_scale_map[scale_key]
shard_tensors[name] = quantize_fp8_weight(
shard_tensors[name], scale_tensors["weight_scale"]
)
shard_tensors[name[:-7] + ".weight_scale"] = scale_tensors[
"weight_scale"
]
shard_tensors[name[:-7] + ".input_scale"] = scale_tensors["input_scale"]
added_scale_count += 2
save_file(shard_tensors, os.path.join(output_path, filename), metadata=metadata)
for name, tensor in shard_tensors.items():
updated_weight_map[name] = filename
total_size += tensor.element_size() * tensor.numel()
del shard_tensors
gc.collect()
with open(output_path / index_filename, "w", encoding="utf-8") as f:
json.dump(
{
"metadata": {"total_size": total_size},
"weight_map": updated_weight_map,
},
f,
indent=2,
sort_keys=True,
)
with open(output_path / "config.json", "w", encoding="utf-8") as f:
json.dump(output_config, f, indent=2, sort_keys=True)
return {
"quantized_weights": sum(
1
for name in source_weight_map
if name.endswith(".weight")
and _resolve_scale_key(name, fp8_scale_map) is not None
and not is_ignored_by_modelopt(name, ignore_patterns, runtime_name_mapper)
),
"bf16_fallback_weights": len(fallback_weight_names),
"preserved_ignored_weights": preserved_ignored_weight_count,
"added_scale_tensors": added_scale_count,
"output_shards": len(weights_by_file),
}
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Build an SGLang-loadable ModelOpt FP8 diffusion transformer from a "
"ModelOpt diffusers export."
)
)
parser.add_argument(
"--modelopt-hf-dir",
required=True,
help="ModelOpt --hf-ckpt-dir output, or its transformer subdirectory.",
)
parser.add_argument(
"--modelopt-backbone-ckpt",
required=True,
help="Path to backbone.pt, or the directory that contains it.",
)
parser.add_argument(
"--output-dir",
required=True,
help="Directory to write the converted SGLang transformer checkpoint.",
)
parser.add_argument(
"--base-transformer-dir",
help=(
"Original BF16 transformer directory (or parent model dir). Required when "
"BF16 fallback layers are enabled."
),
)
parser.add_argument(
"--model-type",
choices=[
"auto",
"flux1",
"flux2",
"ltx2",
"hunyuan-video",
"qwen-image",
"none",
],
default="auto",
help=(
"Optional model-family BF16 fallback profile. 'none' uses the generic "
"conversion path. 'auto' enables the validated FLUX.1 / FLUX.2 / LTX-2 / "
"HunyuanVideo / Qwen Image fallback sets when the export config matches "
"those transformer classes."
),
)
parser.add_argument(
"--keep-bf16-pattern",
action="append",
default=[],
help=(
"Regex matched against module names without the trailing .weight. "
"Matching weights are copied from --base-transformer-dir instead of "
"staying in FP8."
),
)
parser.add_argument(
"--maxbound",
type=float,
default=FP8_E4M3_MAXBOUND,
help="FP8 maxbound used to turn ModelOpt amax into a scale. E4M3 uses 448.",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace --output-dir if it already exists.",
)
return parser.parse_args()
def main() -> None:
args = _parse_args()
stats = build_modelopt_fp8_transformer(
modelopt_hf_dir=args.modelopt_hf_dir,
modelopt_backbone_ckpt=args.modelopt_backbone_ckpt,
output_dir=args.output_dir,
base_transformer_dir=args.base_transformer_dir,
model_type=args.model_type,
keep_bf16_patterns=args.keep_bf16_pattern,
maxbound=args.maxbound,
overwrite=args.overwrite,
)
print(json.dumps(stats, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,400 @@
"""Build an SGLang-loadable ModelOpt NVFP4 diffusion transformer.
This tool keeps the ModelOpt-exported NVFP4 tensors for most transformer
modules, but can replace a validated subset of numerically sensitive modules
with their original BF16 tensors from the base transformer checkpoint.
It is primarily intended for FLUX.1-dev style ModelOpt NVFP4 exports where:
- the base pipeline should remain separate from the quantized transformer
- fallback BF16 modules are model-family specific
- the serialized FP4 weight byte order may already match the runtime kernel
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Iterable, Mapping, Sequence
from safetensors import safe_open
from safetensors.torch import load_file, save_file
INDEX_FILENAMES = [
"model.safetensors.index.json",
"diffusion_pytorch_model.safetensors.index.json",
]
DEFAULT_FLUX1_NVFP4_FALLBACK_PATTERNS = [
"transformer_blocks.*.norm1.linear*",
"transformer_blocks.*.norm1_context.linear*",
"transformer_blocks.*.ff.net.0.proj*",
"transformer_blocks.*.ff.net.2*",
"transformer_blocks.*.ff_context.net.0.proj*",
"transformer_blocks.*.ff_context.net.2*",
"single_transformer_blocks.*.norm.linear*",
"single_transformer_blocks.*.proj_mlp*",
]
_TENSOR_MODULE_SUFFIXES = (
".weight_scale_2",
".weight_scale",
".input_scale",
".weight",
".bias",
)
def _resolve_transformer_dir(path: str) -> str:
candidate = Path(path).expanduser().resolve()
if (candidate / "config.json").is_file():
return str(candidate)
transformer_dir = candidate / "transformer"
if (transformer_dir / "config.json").is_file():
return str(transformer_dir)
raise FileNotFoundError(f"Could not resolve a transformer directory from: {path}")
def _find_index_file(model_dir: str) -> str | None:
for filename in INDEX_FILENAMES:
candidate = os.path.join(model_dir, filename)
if os.path.isfile(candidate):
return filename
matches = sorted(
filename
for filename in os.listdir(model_dir)
if filename.endswith(".safetensors.index.json")
)
return matches[0] if matches else None
def _load_weight_map(model_dir: str) -> tuple[dict[str, str], str | None]:
index_filename = _find_index_file(model_dir)
if index_filename is not None:
with open(os.path.join(model_dir, index_filename), encoding="utf-8") as f:
index_data = json.load(f)
return dict(index_data["weight_map"]), index_filename
safetensors_files = sorted(
filename
for filename in os.listdir(model_dir)
if filename.endswith(".safetensors")
)
if len(safetensors_files) != 1:
raise ValueError(
f"Expected an index file or a single safetensors shard in {model_dir}, "
f"found {len(safetensors_files)} shard(s)."
)
shard_name = safetensors_files[0]
with safe_open(
os.path.join(model_dir, shard_name), framework="pt", device="cpu"
) as f:
weight_map = {key: shard_name for key in f.keys()}
index_filename = f"{Path(shard_name).stem}.safetensors.index.json"
return weight_map, index_filename
def _load_config(model_dir: str) -> dict:
config_path = os.path.join(model_dir, "config.json")
with open(config_path, encoding="utf-8") as f:
return json.load(f)
def _write_config(model_dir: Path, config: Mapping[str, object]) -> None:
with open(model_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, sort_keys=True)
f.write("\n")
def _copy_non_shard_files(source_dir: str, output_dir: str) -> None:
ignored = set(INDEX_FILENAMES)
for entry in os.listdir(source_dir):
if entry.endswith(".safetensors") or entry in ignored:
continue
source_path = os.path.join(source_dir, entry)
output_path = os.path.join(output_dir, entry)
if os.path.isdir(source_path):
shutil.copytree(source_path, output_path, dirs_exist_ok=True)
else:
shutil.copy2(source_path, output_path)
def _load_selected_tensors(
model_dir: str,
weight_map: Mapping[str, str],
tensor_names: Iterable[str],
):
tensors = {}
names_by_file: dict[str, list[str]] = defaultdict(list)
for name in tensor_names:
names_by_file[weight_map[name]].append(name)
for filename, names in names_by_file.items():
shard_path = os.path.join(model_dir, filename)
with safe_open(shard_path, framework="pt", device="cpu") as f:
for name in names:
tensors[name] = f.get_tensor(name).contiguous()
return tensors
def _module_name_for_tensor(tensor_name: str) -> str:
for suffix in _TENSOR_MODULE_SUFFIXES:
if tensor_name.endswith(suffix):
return tensor_name[: -len(suffix)]
return tensor_name
def _matches_any_pattern(module_name: str, patterns: Sequence[str]) -> bool:
if not patterns:
return False
for pattern in patterns:
regex_str = pattern.replace(".", r"\.").replace("*", r".*")
if re.fullmatch(regex_str, module_name):
return True
return False
def _preset_patterns(pattern_preset: str) -> list[str]:
if pattern_preset == "none":
return []
if pattern_preset == "flux1-nvfp4":
return list(DEFAULT_FLUX1_NVFP4_FALLBACK_PATTERNS)
raise ValueError(f"Unsupported pattern preset: {pattern_preset}")
def _updated_quant_config(
source_config: Mapping[str, object],
*,
fallback_patterns: Sequence[str],
swap_weight_nibbles: bool,
) -> dict[str, object]:
output_config = json.loads(json.dumps(source_config))
quant_config = output_config.get("quantization_config")
if not isinstance(quant_config, dict):
raise ValueError("Expected a flat quantization_config dict in config.json.")
if (
quant_config.get("quant_method") != "modelopt"
or "FP4" not in str(quant_config.get("quant_algo", "")).upper()
):
raise ValueError(
"This tool only supports ModelOpt diffusion NVFP4 exports "
"(quant_method=modelopt, quant_algo=FP4/NVFP4)."
)
ignore_patterns = list(quant_config.get("ignore", []) or [])
for pattern in fallback_patterns:
if pattern not in ignore_patterns:
ignore_patterns.append(pattern)
quant_config["ignore"] = ignore_patterns
quant_config.setdefault(
"quant_type", str(quant_config.get("quant_algo", "")).upper()
)
quant_config["swap_weight_nibbles"] = swap_weight_nibbles
return output_config
def build_modelopt_nvfp4_transformer(
*,
base_transformer_dir: str,
modelopt_hf_dir: str,
output_dir: str,
pattern_preset: str = "none",
keep_bf16_patterns: Sequence[str] | None = None,
swap_weight_nibbles: bool | None = None,
overwrite: bool = False,
) -> dict[str, int | bool]:
source_dir = _resolve_transformer_dir(modelopt_hf_dir)
base_dir = _resolve_transformer_dir(base_transformer_dir)
patterns = _preset_patterns(pattern_preset)
if keep_bf16_patterns:
patterns.extend(keep_bf16_patterns)
resolved_swap_weight_nibbles = (
swap_weight_nibbles if swap_weight_nibbles is not None else False
)
output_config = _updated_quant_config(
_load_config(source_dir),
fallback_patterns=patterns,
swap_weight_nibbles=resolved_swap_weight_nibbles,
)
quant_config = output_config["quantization_config"]
serialized_quant_config = json.dumps(quant_config, sort_keys=True)
output_path = Path(output_dir).expanduser().resolve()
if output_path.exists():
if not overwrite:
raise FileExistsError(
f"Output directory already exists: {output_path}. "
"Use --overwrite to replace it."
)
shutil.rmtree(output_path)
output_path.mkdir(parents=True, exist_ok=True)
_copy_non_shard_files(source_dir, str(output_path))
_write_config(output_path, output_config)
source_weight_map, index_filename = _load_weight_map(source_dir)
base_weight_map, _ = _load_weight_map(base_dir)
fallback_tensor_names = sorted(
name
for name in base_weight_map
if name in source_weight_map
and _matches_any_pattern(_module_name_for_tensor(name), patterns)
)
fallback_tensors = _load_selected_tensors(
base_dir,
base_weight_map,
fallback_tensor_names,
)
fallback_modules = {
_module_name_for_tensor(tensor_name) for tensor_name in fallback_tensor_names
}
weights_by_file: dict[str, list[str]] = defaultdict(list)
for tensor_name, filename in source_weight_map.items():
weights_by_file[filename].append(tensor_name)
updated_weight_map: dict[str, str] = {}
total_size = 0
replaced_tensor_count = 0
removed_aux_tensor_count = 0
for filename, tensor_names in sorted(weights_by_file.items()):
shard_path = os.path.join(source_dir, filename)
shard_tensors = load_file(shard_path, device="cpu")
with safe_open(shard_path, framework="pt", device="cpu") as f:
metadata = dict(f.metadata() or {})
metadata.setdefault("format", "pt")
metadata["quantization_config"] = serialized_quant_config
metadata["_quantization_metadata"] = serialized_quant_config
for name in list(shard_tensors.keys()):
if "_quantizer." in name:
del shard_tensors[name]
removed_aux_tensor_count += 1
continue
module_name = _module_name_for_tensor(name)
if module_name not in fallback_modules:
continue
if name in fallback_tensors:
shard_tensors[name] = fallback_tensors[name]
replaced_tensor_count += 1
else:
del shard_tensors[name]
removed_aux_tensor_count += 1
save_file(shard_tensors, os.path.join(output_path, filename), metadata=metadata)
for name, tensor in shard_tensors.items():
updated_weight_map[name] = filename
total_size += tensor.element_size() * tensor.numel()
if index_filename is None:
raise ValueError(
"Expected a sharded or indexed ModelOpt HF export, but no index file was found."
)
with open(output_path / index_filename, "w", encoding="utf-8") as f:
json.dump(
{
"metadata": {"total_size": total_size},
"weight_map": updated_weight_map,
},
f,
indent=2,
sort_keys=True,
)
f.write("\n")
return {
"fallback_modules": len(fallback_modules),
"replaced_tensors": replaced_tensor_count,
"removed_aux_tensors": removed_aux_tensor_count,
"output_shards": len(weights_by_file),
"swap_weight_nibbles": resolved_swap_weight_nibbles,
}
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Build an SGLang-loadable ModelOpt NVFP4 diffusion transformer and "
"optionally keep selected modules in BF16."
)
)
parser.add_argument(
"--base-transformer-dir",
required=True,
help="Original BF16 transformer directory, or a parent model directory.",
)
parser.add_argument(
"--modelopt-hf-dir",
required=True,
help="ModelOpt --hf-ckpt-dir output, or its transformer subdirectory.",
)
parser.add_argument(
"--output-dir",
required=True,
help="Directory to write the mixed transformer checkpoint.",
)
parser.add_argument(
"--pattern-preset",
choices=["none", "flux1-nvfp4"],
default="none",
help="Optional model-family BF16 fallback preset.",
)
parser.add_argument(
"--keep-bf16-pattern",
action="append",
default=[],
help=(
"Glob-style pattern matched against module names without trailing tensor "
"suffixes such as .weight or .bias."
),
)
parser.add_argument(
"--swap-weight-nibbles",
action=argparse.BooleanOptionalAction,
default=None,
help=(
"Whether the runtime should swap packed FP4 nibbles before padding. "
"Defaults to false."
),
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace --output-dir if it already exists.",
)
return parser.parse_args()
def main() -> None:
args = _parse_args()
stats = build_modelopt_nvfp4_transformer(
base_transformer_dir=args.base_transformer_dir,
modelopt_hf_dir=args.modelopt_hf_dir,
output_dir=args.output_dir,
pattern_preset=args.pattern_preset,
keep_bf16_patterns=args.keep_bf16_pattern,
swap_weight_nibbles=args.swap_weight_nibbles,
overwrite=args.overwrite,
)
print(json.dumps(stats, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,631 @@
"""Compare diffusion BF16 and quantized runs via trajectory-latent similarity.
This tool runs two SGLang diffusion variants with the same prompt and seed,
captures intermediate denoising latents via `return_trajectory_latents`, and
reports cosine / error metrics for each timestep plus final frame metrics.
The intended use is quant validation with reduced deterministic settings:
- same prompt / seed / resolution / step count for both variants
- BF16 reference on the base model
- FP8 candidate via `--candidate-transformer-path` and/or component overrides
Example:
python -m sglang.multimodal_gen.tools.compare_diffusion_trajectory_similarity \
--model-path /path/to/model \
--prompt "A futuristic cyberpunk city at night" \
--width 512 --height 512 --num-inference-steps 8 --seed 42 \
--text-encoder-cpu-offload \
--candidate-transformer-path /tmp/modelopt_flux2_fp8/sglang_transformer \
--output-json /tmp/flux2_similarity.json
"""
from __future__ import annotations
import argparse
import contextlib
import json
import math
import os
from pathlib import Path
from typing import Any, Sequence
import imageio.v3 as iio
import numpy as np
import torch
import torch.nn.functional as F
def parse_component_overrides(entries: Sequence[str] | None) -> dict[str, str]:
overrides: dict[str, str] = {}
for entry in entries or []:
if "=" not in entry:
raise ValueError(
f"Invalid component override '{entry}'. Expected format component=path."
)
component, path = entry.split("=", 1)
component = component.strip().replace("-", "_")
path = path.strip()
if not component or not path:
raise ValueError(
f"Invalid component override '{entry}'. Expected format component=path."
)
overrides[component] = path
return overrides
def _cosine_similarity(flat_a: torch.Tensor, flat_b: torch.Tensor) -> float:
norm_a = torch.linalg.vector_norm(flat_a).item()
norm_b = torch.linalg.vector_norm(flat_b).item()
if norm_a == 0.0 and norm_b == 0.0:
return 1.0
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return float(F.cosine_similarity(flat_a, flat_b, dim=0).item())
def compute_tensor_metrics(lhs: Any, rhs: Any) -> dict[str, float]:
lhs_tensor = torch.as_tensor(lhs).detach().cpu().float()
rhs_tensor = torch.as_tensor(rhs).detach().cpu().float()
if lhs_tensor.shape != rhs_tensor.shape:
raise ValueError(
f"Metric shape mismatch: {tuple(lhs_tensor.shape)} vs {tuple(rhs_tensor.shape)}"
)
diff = lhs_tensor - rhs_tensor
mse = float(diff.square().mean().item())
rmse = float(math.sqrt(mse))
mae = float(diff.abs().mean().item())
max_abs = float(diff.abs().max().item())
l2 = float(torch.linalg.vector_norm(diff).item())
cosine = _cosine_similarity(lhs_tensor.reshape(-1), rhs_tensor.reshape(-1))
return {
"cosine_similarity": cosine,
"mae": mae,
"mse": mse,
"rmse": rmse,
"max_abs": max_abs,
"l2": l2,
}
def compute_uint8_frame_metrics(lhs: Any, rhs: Any) -> dict[str, float]:
metrics = compute_tensor_metrics(lhs, rhs)
mse = metrics["mse"]
metrics["psnr_db"] = (
float("inf") if mse == 0.0 else 20 * math.log10(255.0) - 10 * math.log10(mse)
)
return metrics
def _normalize_step_index(step_index: int, num_steps: int) -> int:
if num_steps <= 0:
raise ValueError("num_steps must be positive.")
if step_index < 0:
step_index += num_steps
if step_index < 0 or step_index >= num_steps:
raise IndexError(
f"Requested step index {step_index} is outside the valid range [0, {num_steps})."
)
return step_index
def _maybe_scalar(timestep: torch.Tensor | None, index: int) -> float | None:
if timestep is None:
return None
value = timestep[index]
if isinstance(value, torch.Tensor):
value = value.detach().cpu()
if value.numel() == 1:
return float(value.item())
return float(value)
def summarize_trajectory_metrics(
reference_latents: Any,
candidate_latents: Any,
*,
reference_timesteps: Any = None,
candidate_timesteps: Any = None,
step_index: int = -1,
) -> dict[str, Any]:
ref = torch.as_tensor(reference_latents).detach().cpu().float()
cand = torch.as_tensor(candidate_latents).detach().cpu().float()
if ref.shape != cand.shape:
raise ValueError(
f"Trajectory shape mismatch: {tuple(ref.shape)} vs {tuple(cand.shape)}"
)
if ref.ndim < 2:
raise ValueError(
f"Expected trajectory latents with an explicit timestep dimension, got {tuple(ref.shape)}"
)
num_steps = ref.shape[1]
selected_step = _normalize_step_index(step_index, num_steps)
ref_t = (
torch.as_tensor(reference_timesteps).detach().cpu()
if reference_timesteps is not None
else None
)
cand_t = (
torch.as_tensor(candidate_timesteps).detach().cpu()
if candidate_timesteps is not None
else None
)
per_step: list[dict[str, Any]] = []
for idx in range(num_steps):
metrics = compute_tensor_metrics(ref[:, idx], cand[:, idx])
metrics["step_index"] = idx
metrics["reference_timestep"] = _maybe_scalar(ref_t, idx)
metrics["candidate_timestep"] = _maybe_scalar(cand_t, idx)
per_step.append(metrics)
return {
"trajectory_shape": list(ref.shape),
"num_steps": num_steps,
"selected_step_index": selected_step,
"selected_step_metrics": per_step[selected_step],
"per_step_metrics": per_step,
}
def summarize_output_frame_metrics(
reference_frames: Sequence[Any],
candidate_frames: Sequence[Any],
) -> dict[str, Any]:
if len(reference_frames) != len(candidate_frames):
raise ValueError(
f"Output frame count mismatch: {len(reference_frames)} vs {len(candidate_frames)}"
)
if not reference_frames:
raise ValueError("No output frames available for comparison.")
ref_stack = np.stack([np.asarray(frame) for frame in reference_frames], axis=0)
cand_stack = np.stack([np.asarray(frame) for frame in candidate_frames], axis=0)
frame0_metrics = compute_uint8_frame_metrics(ref_stack[0], cand_stack[0])
mid_index = len(reference_frames) // 2
mid_metrics = compute_uint8_frame_metrics(
ref_stack[mid_index], cand_stack[mid_index]
)
all_metrics = compute_uint8_frame_metrics(ref_stack, cand_stack)
return {
"num_frames": len(reference_frames),
"frame0_metrics": frame0_metrics,
"mid_frame_index": mid_index,
"mid_frame_metrics": mid_metrics,
"all_frames_metrics": all_metrics,
}
def extract_result_frames(result: Any) -> list[np.ndarray]:
if result.frames is not None:
return [np.asarray(frame) for frame in result.frames]
sample = result.samples
if sample is None:
if result.output_file_path:
output_path = Path(result.output_file_path)
if not output_path.exists():
raise ValueError(
"GenerationResult did not contain frames or samples, and its "
f"output_file_path does not exist: {output_path}"
)
if output_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
return [np.asarray(iio.imread(output_path))]
return [np.asarray(frame) for frame in iio.imiter(output_path)]
raise ValueError(
"GenerationResult did not contain frames, samples, or a readable output_file_path."
)
if isinstance(sample, torch.Tensor):
tensor = sample.detach().cpu().float()
if tensor.ndim == 3:
tensor = tensor.unsqueeze(1)
if tensor.ndim != 4:
raise ValueError(
f"Unsupported tensor sample shape for frame extraction: {tuple(tensor.shape)}"
)
tensor = (tensor * 255).clamp(0, 255).to(torch.uint8)
frames = tensor.permute(1, 2, 3, 0).contiguous().numpy()
return [frame for frame in frames]
array = np.asarray(sample)
if array.ndim == 2:
array = array[..., None]
if array.ndim == 3:
if array.shape[-1] in (1, 3, 4):
array = array[None, ...]
else:
array = array[..., None]
if array.ndim != 4:
raise ValueError(
f"Unsupported numpy sample shape for frame extraction: {tuple(array.shape)}"
)
if array.dtype != np.uint8:
array = (np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
return [frame for frame in array]
def build_server_kwargs(args: argparse.Namespace, *, variant: str) -> dict[str, Any]:
component_paths = parse_component_overrides(
getattr(args, f"{variant}_component_path") or []
)
transformer_path = getattr(args, f"{variant}_transformer_path")
kwargs: dict[str, Any] = {
"model_path": args.model_path,
"model_id": args.model_id,
"backend": args.backend,
"num_gpus": args.num_gpus,
"dit_cpu_offload": args.dit_cpu_offload,
"dit_layerwise_offload": args.dit_layerwise_offload,
"text_encoder_cpu_offload": args.text_encoder_cpu_offload,
"vae_cpu_offload": args.vae_cpu_offload,
"pin_cpu_memory": args.pin_cpu_memory,
"enable_cfg_parallel": args.enable_cfg_parallel,
"ulysses_degree": args.ulysses_degree,
}
if args.sp_degree is not None:
kwargs["sp_degree"] = args.sp_degree
if transformer_path is not None:
kwargs["transformer_weights_path"] = transformer_path
if component_paths:
kwargs["component_paths"] = component_paths
return kwargs
def build_sampling_kwargs(
args: argparse.Namespace, *, output_dir: str | None = None
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"prompt": args.prompt,
"width": args.width,
"height": args.height,
"num_inference_steps": args.num_inference_steps,
"guidance_scale": args.guidance_scale,
"seed": args.seed,
"return_frames": True,
"return_trajectory_latents": True,
"return_trajectory_decoded": args.return_trajectory_decoded,
"save_output": output_dir is not None,
}
if output_dir is not None:
kwargs["output_path"] = output_dir
if args.num_frames is not None:
kwargs["num_frames"] = args.num_frames
if args.guidance_scale_2 is not None:
kwargs["guidance_scale_2"] = args.guidance_scale_2
return kwargs
def _normalize_single_result(result: Any):
if isinstance(result, list):
if len(result) != 1:
raise ValueError(
f"Expected a single generation result, got {len(result)} results."
)
result = result[0]
if result is None:
raise RuntimeError("Generation returned no result.")
return result
def _clear_diffusion_fp4_backend_caches() -> None:
from sglang.multimodal_gen.runtime.layers.quantization import (
modelopt_quant as diffusion_modelopt_quant,
)
from sglang.multimodal_gen.runtime.platforms import current_platform
diffusion_modelopt_quant._get_fp4_gemm_op.cache_clear()
current_platform.__class__.get_modelopt_fp4_gemm_op.cache_clear()
current_platform.__class__.get_modelopt_flashinfer_fp4_backend.cache_clear()
@contextlib.contextmanager
def override_diffusion_fp4_backend(backend: str | None):
env_name = "SGLANG_DIFFUSION_FLASHINFER_FP4_GEMM_BACKEND"
previous = os.environ.get(env_name)
if backend is None:
os.environ.pop(env_name, None)
else:
os.environ[env_name] = backend
_clear_diffusion_fp4_backend_caches()
try:
yield
finally:
if previous is None:
os.environ.pop(env_name, None)
else:
os.environ[env_name] = previous
_clear_diffusion_fp4_backend_caches()
def _extract_total_duration_ms(result: Any) -> float | None:
metrics = getattr(result, "metrics", None)
if not isinstance(metrics, dict):
return None
total_duration_ms = metrics.get("total_duration_ms")
if total_duration_ms is None:
return None
return float(total_duration_ms)
def run_variant(
*,
server_kwargs: dict[str, Any],
sampling_kwargs: dict[str, Any],
fp4_gemm_backend: str | None,
warmup_runs: int,
measure_runs: int,
):
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import (
DiffGenerator,
)
if warmup_runs < 0:
raise ValueError("warmup_runs must be >= 0.")
if measure_runs <= 0:
raise ValueError("measure_runs must be >= 1.")
with override_diffusion_fp4_backend(fp4_gemm_backend):
with DiffGenerator.from_pretrained(
local_mode=True, **server_kwargs
) as generator:
for _ in range(warmup_runs):
_normalize_single_result(
generator.generate(sampling_params_kwargs=sampling_kwargs)
)
measured_results = []
for _ in range(measure_runs):
measured_results.append(
_normalize_single_result(
generator.generate(sampling_params_kwargs=sampling_kwargs)
)
)
final_result = measured_results[-1]
generation_times = [float(result.generation_time) for result in measured_results]
peak_memories = [float(result.peak_memory_mb) for result in measured_results]
total_duration_ms = [
duration
for duration in (
_extract_total_duration_ms(result) for result in measured_results
)
if duration is not None
]
return {
"result": final_result,
"fp4_gemm_backend": fp4_gemm_backend or "default",
"warmup_runs": warmup_runs,
"measure_runs": measure_runs,
"generation_time_s": generation_times[-1],
"avg_generation_time_s": sum(generation_times) / len(generation_times),
"per_run_generation_time_s": generation_times,
"peak_memory_mb": peak_memories[-1],
"max_peak_memory_mb": max(peak_memories) if peak_memories else 0.0,
"per_run_peak_memory_mb": peak_memories,
"total_duration_ms": total_duration_ms[-1] if total_duration_ms else None,
"avg_total_duration_ms": (
sum(total_duration_ms) / len(total_duration_ms)
if total_duration_ms
else None
),
"per_run_total_duration_ms": total_duration_ms,
}
def _to_jsonable(result: dict[str, Any]) -> dict[str, Any]:
return json.loads(json.dumps(result, allow_nan=True))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model-path", required=True)
parser.add_argument(
"--model-id",
help=(
"Optional model ID override passed to DiffGenerator.from_pretrained. "
"Use this when --model-path points to a local directory whose name "
"does not match a registered native SGLang model."
),
)
parser.add_argument("--backend", default="sglang")
parser.add_argument("--prompt", required=True)
parser.add_argument("--output-json", required=True)
parser.add_argument("--width", type=int, required=True)
parser.add_argument("--height", type=int, required=True)
parser.add_argument("--num-frames", type=int)
parser.add_argument("--num-inference-steps", type=int, required=True)
parser.add_argument("--guidance-scale", type=float, required=True)
parser.add_argument("--guidance-scale-2", type=float)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--num-gpus", type=int, default=1)
parser.add_argument("--ulysses-degree", type=int, default=1)
parser.add_argument("--sp-degree", type=int)
parser.add_argument("--trajectory-step-index", type=int, default=-1)
parser.add_argument("--reference-transformer-path")
parser.add_argument("--candidate-transformer-path")
parser.add_argument(
"--reference-fp4-gemm-backend",
help=(
"Optional NVFP4 GEMM backend override for the reference run, e.g. "
"'flashinfer_trtllm'."
),
)
parser.add_argument(
"--candidate-fp4-gemm-backend",
help=(
"Optional NVFP4 GEMM backend override for the candidate run, e.g. "
"'flashinfer_trtllm'."
),
)
parser.add_argument("--warmup-runs", type=int, default=0)
parser.add_argument("--measure-runs", type=int, default=1)
parser.add_argument(
"--reference-component-path",
action="append",
default=[],
help="Repeatable component override in the form component=path.",
)
parser.add_argument(
"--candidate-component-path",
action="append",
default=[],
help="Repeatable component override in the form component=path.",
)
parser.add_argument("--save-output-dir")
parser.add_argument(
"--return-trajectory-decoded",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--enable-cfg-parallel",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--text-encoder-cpu-offload",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--vae-cpu-offload",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--dit-cpu-offload",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--dit-layerwise-offload",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--pin-cpu-memory",
action=argparse.BooleanOptionalAction,
default=False,
)
args = parser.parse_args()
output_json = Path(args.output_json).expanduser().resolve()
output_json.parent.mkdir(parents=True, exist_ok=True)
save_root: Path | None = None
if args.save_output_dir:
save_root = Path(args.save_output_dir).expanduser().resolve()
save_root.mkdir(parents=True, exist_ok=True)
ref_server_kwargs = build_server_kwargs(args, variant="reference")
cand_server_kwargs = build_server_kwargs(args, variant="candidate")
ref_sampling_kwargs = build_sampling_kwargs(
args,
output_dir=str(save_root / "reference") if save_root else None,
)
cand_sampling_kwargs = build_sampling_kwargs(
args,
output_dir=str(save_root / "candidate") if save_root else None,
)
reference_run = run_variant(
server_kwargs=ref_server_kwargs,
sampling_kwargs=ref_sampling_kwargs,
fp4_gemm_backend=args.reference_fp4_gemm_backend,
warmup_runs=args.warmup_runs,
measure_runs=args.measure_runs,
)
candidate_run = run_variant(
server_kwargs=cand_server_kwargs,
sampling_kwargs=cand_sampling_kwargs,
fp4_gemm_backend=args.candidate_fp4_gemm_backend,
warmup_runs=args.warmup_runs,
measure_runs=args.measure_runs,
)
reference = reference_run["result"]
candidate = candidate_run["result"]
result = {
"model_path": args.model_path,
"prompt": args.prompt,
"seed": args.seed,
"warmup_runs": args.warmup_runs,
"measure_runs": args.measure_runs,
"server_kwargs": {
"reference": ref_server_kwargs,
"candidate": cand_server_kwargs,
},
"backend_overrides": {
"reference_fp4_gemm_backend": reference_run["fp4_gemm_backend"],
"candidate_fp4_gemm_backend": candidate_run["fp4_gemm_backend"],
},
"sampling_kwargs": {
"width": args.width,
"height": args.height,
"num_frames": args.num_frames,
"num_inference_steps": args.num_inference_steps,
"guidance_scale": args.guidance_scale,
"guidance_scale_2": args.guidance_scale_2,
},
"reference_generation": {
key: value for key, value in reference_run.items() if key != "result"
}
| {"output_file_path": reference.output_file_path},
"candidate_generation": {
key: value for key, value in candidate_run.items() if key != "result"
}
| {"output_file_path": candidate.output_file_path},
"trajectory_metrics": summarize_trajectory_metrics(
reference.trajectory_latents,
candidate.trajectory_latents,
reference_timesteps=reference.trajectory_timesteps,
candidate_timesteps=candidate.trajectory_timesteps,
step_index=args.trajectory_step_index,
),
"output_metrics": summarize_output_frame_metrics(
extract_result_frames(reference),
extract_result_frames(candidate),
),
}
output_json.write_text(
json.dumps(_to_jsonable(result), indent=2, sort_keys=True), encoding="utf-8"
)
selected = result["trajectory_metrics"]["selected_step_metrics"]
frame0 = result["output_metrics"]["frame0_metrics"]
print(
json.dumps(
{
"output_json": str(output_json),
"trajectory_selected_step": result["trajectory_metrics"][
"selected_step_index"
],
"reference_avg_generation_time_s": result["reference_generation"][
"avg_generation_time_s"
],
"candidate_avg_generation_time_s": result["candidate_generation"][
"avg_generation_time_s"
],
"trajectory_cosine": selected["cosine_similarity"],
"trajectory_mae": selected["mae"],
"frame0_psnr_db": frame0["psnr_db"],
"frame0_mae": frame0["mae"],
},
indent=2,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,320 @@
# copied and adapted from Slime
"""
Convert HuggingFace safetensors model to FP8 format for efficient inference.
Example usage:
# convert FLUX.1-dev transformer to FP8
python -m sglang.multimodal_gen.tools.convert_hf_to_fp8 \
--model-dir /path/to/FLUX.1-dev/transformer \
--save-dir /path/to/FLUX.1-dev/transformer-FP8 \
--strategy block \
--block-size 128 128
Options:
--model-dir MODEL_DIR
path to the directory of the HF safetensors model (e.g., transformer subfolder)
--save-dir SAVE_DIR
path to the directory to save the converted FP8 model
--strategy {block,channel,tensor}
quantization strategy (default: block)
--block-size [BLOCK_SIZE ...]
block size for block quantization, e.g., --block-size 128 128
--max-workers MAX_WORKERS
number of worker threads for parallel processing (default: 1)
"""
import argparse
import gc
import json
import os
import shutil
import threading
from concurrent.futures import ThreadPoolExecutor
import safetensors
import safetensors.torch
import torch
import torch.nn.functional as F
from tqdm import tqdm
FP8_INFO = torch.finfo(torch.float8_e4m3fn)
FP8_MAX, FP8_MIN = FP8_INFO.max, FP8_INFO.min
def ceildiv(a, b):
return -(-a // b)
def block_fp8(weight, block_size):
# per block quant
block_n, block_k = block_size[0], block_size[1]
shape_0, shape_1 = weight.shape
n_tiles = ceildiv(shape_0, block_n)
k_tiles = ceildiv(shape_1, block_k)
q_weight = F.pad(
weight,
(0, k_tiles * block_k - shape_1, 0, n_tiles * block_n - shape_0),
mode="constant",
value=0.0,
)
qweight = q_weight.reshape(n_tiles, block_n, k_tiles, block_k)
block_max = torch.max(torch.abs(qweight), dim=1, keepdim=True)[0]
block_max = torch.max(block_max, dim=3, keepdim=True)[0]
scale = block_max.to(torch.float32) / FP8_MAX
qweight = (
(qweight / scale)
.clamp(min=FP8_MIN, max=FP8_MAX)
.reshape((n_tiles * block_n, k_tiles * block_k))
.to(torch.float8_e4m3fn)
)
qweight = qweight[:shape_0, :shape_1].clone().detach()
scale = scale.squeeze()
return qweight, scale
def channel_fp8(weight):
channel_max = torch.max(weight.abs(), dim=-1, keepdim=True)[0]
scale = channel_max.clamp(min=1e-12).to(torch.float32) / FP8_MAX
qweight = (weight / scale).clamp(min=FP8_MIN, max=FP8_MAX)
qweight = qweight.to(torch.float8_e4m3fn)
return qweight, scale
def tensor_fp8(weight):
scale = weight.abs().max().clamp(min=1e-12).to(torch.float32) / FP8_MAX
qweight = (weight / scale).clamp(min=FP8_MIN, max=FP8_MAX)
qweight = qweight.to(torch.float8_e4m3fn)
scale = scale.view(1)
return qweight, scale
def quant_fp8(weight, strategy, block_size=None):
if strategy == "tensor":
return tensor_fp8(weight)
elif strategy == "channel":
return channel_fp8(weight)
else:
return block_fp8(weight, block_size)
class ConversionResult:
def __init__(self):
self.lock = threading.Lock()
self.weight_map = {}
self.param_count = 0
self.modules_to_not_convert = []
def add_result(self, filename, q_weights, module_names):
with self.lock:
for k, v in q_weights.items():
self.weight_map[k] = filename
self.param_count += v.numel()
self.modules_to_not_convert.extend(module_names)
def process_file(
input_path, output_path, filename, strategy, block_size, result_collector
):
if not filename.endswith(".safetensors"):
return
print(f"Processing {filename}, memory usage: {torch.cuda.memory_allocated()}")
weights = {}
q_weights = {}
with safetensors.safe_open(
os.path.join(input_path, filename), framework="pt", device="cuda"
) as f:
for k in f.keys():
weights[k] = f.get_tensor(k)
modules_to_not_convert = []
for key in weights.keys():
if (
"weight" in key
and "layernorm" not in key
and "embed" not in key
and "router" not in key
and "mlp.gate." not in key
and "norm" not in key
and "lm_head" not in key
and "eh_proj" not in key
and "net" not in key
and "txt_mod" not in key
and "img_mod" not in key
and "modulation" not in key
and "img_in" not in key
and "txt_in" not in key
and "time_in" not in key
and "vector_in" not in key
and "adaLN_modulation" not in key
and "all_final_layer" not in key
and "feed_forward" not in key
and "proj_out.weight" != key
):
qw, s = quant_fp8(weights[key], strategy, block_size)
q_weights[key] = qw
if block_size:
scale_name = key.replace(".weight", ".weight_scale_inv")
else:
scale_name = key.replace(".weight", ".weight_scale")
q_weights[scale_name] = s
else:
modules_to_not_convert.append(key.replace(".weight", ""))
q_weights[key] = weights[key]
safetensors.torch.save_file(
q_weights, os.path.join(output_path, filename), metadata={"format": "pt"}
)
result_collector.add_result(filename, q_weights, modules_to_not_convert)
def convert_fp8(input_path, output_path, strategy, block_size=None, max_workers=4):
input_path = os.path.abspath(input_path)
os.makedirs(output_path, exist_ok=True)
for filename in os.listdir(input_path):
if not filename.endswith(".safetensors") and not os.path.isdir(
os.path.join(input_path, filename)
):
shutil.copyfile(
os.path.join(input_path, filename), os.path.join(output_path, filename)
)
safetensors_files = [
f for f in os.listdir(input_path) if f.endswith(".safetensors")
]
result_collector = ConversionResult()
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for filename in safetensors_files:
future = executor.submit(
process_file,
input_path,
output_path,
filename,
strategy,
block_size,
result_collector,
)
futures.append(future)
for future in tqdm(futures, desc="Processing files"):
future.result()
if strategy == "block" or strategy == "tensor":
quantization_config = {
"activation_scheme": "dynamic",
"fmt": "e4m3",
"quant_method": "fp8",
}
if block_size:
quantization_config["weight_block_size"] = block_size
if len(result_collector.modules_to_not_convert) > 0:
quantization_config["modules_to_not_convert"] = list(
set(result_collector.modules_to_not_convert)
)
else:
quant_group = {
"group_0": {
"input_activations": {
"actorder": None,
"block_structure": None,
"dynamic": True,
"group_size": None,
"num_bits": 8,
"observer": None,
"observer_kwargs": {},
"strategy": "token",
"symmetric": True,
"type": "float",
},
"output_activations": None,
"targets": ["Linear"],
"weights": {
"actorder": None,
"block_structure": None,
"dynamic": False,
"group_size": None,
"num_bits": 8,
"observer": "minmax",
"observer_kwargs": {},
"strategy": strategy,
"symmetric": True,
"type": "float",
},
},
}
quantization_config = {
"config_groups": quant_group,
"format": "float-quantized",
"ignore": list(set(result_collector.modules_to_not_convert)),
"quant_method": "compressed-tensors",
"quantization_status": "compressed",
}
config_path = os.path.join(input_path, "config.json")
if os.path.exists(config_path):
cfg = json.load(open(config_path))
cfg["quantization_config"] = quantization_config
json.dump(cfg, open(os.path.join(output_path, "config.json"), "w"), indent=2)
index_dict = {
"weight_map": result_collector.weight_map,
"metadata": {"total_size": result_collector.param_count},
}
json.dump(
index_dict,
open(os.path.join(output_path, "model.safetensors.index.json"), "w"),
indent=2,
)
gc.collect()
torch.cuda.empty_cache()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--model-dir",
type=str,
help="Path to the directory of the HF safetensors model.",
)
parser.add_argument(
"--save-dir",
type=str,
help="Path to the directory to save the converted model.",
)
parser.add_argument(
"--strategy", type=str, default="block", choices=["block", "channel", "tensor"]
)
parser.add_argument(
"--block-size", type=int, nargs="*", default=None, help="eg. --block-size 32 32"
)
parser.add_argument(
"--max-workers",
type=int,
default=8,
help="Number of worker threads for parallel processing",
)
args = parser.parse_args()
if not os.path.exists(args.save_dir):
print(f"Creating directory {args.save_dir}")
os.makedirs(args.save_dir)
elif not os.path.isdir(args.save_dir):
raise ValueError("The save_dir should be a directory.")
convert_fp8(
args.model_dir, args.save_dir, args.strategy, args.block_size, args.max_workers
)
@@ -0,0 +1,228 @@
### Based on https://github.com/huggingface/diffusers/blob/main/scripts/convert_wan_to_diffusers.py
import argparse
import json
import pathlib
import shutil
from typing import Any, Dict, List
from safetensors.torch import load_file, save_file
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
TRANSFORMER_KEYS_RENAME_DICT = {
"time_embedding.0": "condition_embedder.time_embedder.linear_1",
"time_embedding.2": "condition_embedder.time_embedder.linear_2",
"text_embedding.0": "condition_embedder.text_embedder.linear_1",
"text_embedding.2": "condition_embedder.text_embedder.linear_2",
"time_projection.1": "condition_embedder.time_proj",
"head.modulation": "scale_shift_table",
"head.head": "proj_out",
"modulation": "scale_shift_table",
"ffn.0": "ffn.net.0.proj",
"ffn.2": "ffn.net.2",
# Hack to swap the layer names
# The original model calls the norms in following order: norm1, norm3, norm2
# We convert it to: norm1, norm2, norm3
"norm2": "norm__placeholder",
"norm3": "norm2",
"norm__placeholder": "norm3",
# For the I2V model
"img_emb.proj.0": "condition_embedder.image_embedder.norm1",
"img_emb.proj.1": "condition_embedder.image_embedder.ff.net.0.proj",
"img_emb.proj.3": "condition_embedder.image_embedder.ff.net.2",
"img_emb.proj.4": "condition_embedder.image_embedder.norm2",
# for the FLF2V model
"img_emb.emb_pos": "condition_embedder.image_embedder.pos_embed",
# Add attention component mappings
"self_attn.q": "attn1.to_q",
"self_attn.k": "attn1.to_k",
"self_attn.v": "attn1.to_v",
"self_attn.o": "attn1.to_out.0",
"self_attn.norm_q": "attn1.norm_q",
"self_attn.norm_k": "attn1.norm_k",
"cross_attn.q": "attn2.to_q",
"cross_attn.k": "attn2.to_k",
"cross_attn.v": "attn2.to_v",
"cross_attn.o": "attn2.to_out.0",
"cross_attn.norm_q": "attn2.norm_q",
"cross_attn.norm_k": "attn2.norm_k",
"attn2.to_k_img": "attn2.add_k_proj",
"attn2.to_v_img": "attn2.add_v_proj",
"attn2.norm_k_img": "attn2.norm_added_k",
# MXFP4 msmodelslim wraps Linear layers with a `.linear.` subpath;
# strip it so keys match the SGLang model parameters.
".linear.": ".",
# NonFusionSmoothQuantWrapper exports smooth quant scale as `.div.mul_scale`;
# strip `.div.` so it loads as a direct parameter `mul_scale` on the linear layer.
".div.": ".",
}
SUPPORTED_MODEL_TYPES = ["Wan2.2-T2V-A14B", "Wan2.2-I2V-A14B", "Wan2.2-TI2V-5B"]
# Cascade models have two transformers (high_noise + low_noise)
CASCADE_MODEL_TYPES = {"Wan2.2-T2V-A14B", "Wan2.2-I2V-A14B"}
def get_transformer_config(model_type: str) -> Dict[str, Any]:
if model_type in SUPPORTED_MODEL_TYPES:
return TRANSFORMER_KEYS_RENAME_DICT
else:
raise ValueError(
f"Unsupported model_type: {model_type}. Supported: {SUPPORTED_MODEL_TYPES}"
)
def get_transformer_dirs(model_type: str) -> List[str]:
"""Return the list of transformer directory names for a given model type."""
if model_type in CASCADE_MODEL_TYPES:
return ["transformer", "transformer_2"]
return ["transformer"]
def get_quant_subpath(
model_type: str, quant_path: pathlib.Path, transformer_dir: str
) -> pathlib.Path:
"""Return the quant weights subdirectory for a given transformer."""
if model_type in CASCADE_MODEL_TYPES:
sub = (
"high_noise_model"
if transformer_dir == "transformer"
else "low_noise_model"
)
return quant_path / sub
return quant_path
def update_dict_(d: Dict[str, Any], old_key: str, new_key: str) -> None:
d[new_key] = d.pop(old_key)
def load_sharded_safetensors(directory: pathlib.Path, pattern: str) -> dict:
candidates = sorted(directory.glob(pattern))
if not candidates:
raise FileNotFoundError(f"No file matching '{pattern}' found in {directory}")
state_dict = {}
for f in candidates:
state_dict.update(load_file(f))
return state_dict
def convert_transformer(
model_type: str, model_dir: pathlib.Path, output_dir: pathlib.Path
) -> None:
"""Convert a single quantized transformer directory into Diffusers format."""
model_path = pathlib.Path(model_dir)
out_path = pathlib.Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True)
RENAME_DICT = get_transformer_config(model_type)
state_dict = load_sharded_safetensors(model_path, "quant_model_weight*.safetensors")
json_candidates = sorted(model_path.glob("quant_model_description*.json"))
if not json_candidates:
raise FileNotFoundError(
f"No quant_model_description*.json found in {model_path}"
)
with open(json_candidates[0]) as f:
quant_config = json.load(f)
for key in list(state_dict.keys()):
new_key = key[:]
for replace_key, rename_key in RENAME_DICT.items():
new_key = new_key.replace(replace_key, rename_key)
if new_key != key:
update_dict_(state_dict, key, new_key)
# The quant JSON only covers quantized layers, not all model keys
if key in quant_config:
update_dict_(quant_config, key, new_key)
save_file(state_dict, out_path / "diffusion_pytorch_model.safetensors")
with open(out_path / "quant_model_description.json", "w") as f:
json.dump(quant_config, f, indent=2)
def repack(
model_type: str,
original_model_path: pathlib.Path,
quant_path: pathlib.Path,
output_path: pathlib.Path,
) -> None:
"""
Full one-step repack workflow:
1. Copy the original HF Diffusers model to output_path, excluding transformer dir(s).
2. For each transformer: convert quant weights and copy config.json from original.
"""
transformer_dirs = get_transformer_dirs(model_type)
# Step 1: Copy original model, skipping transformer dirs (they will be replaced)
logger.debug(f"Step 1: Copying original model to {output_path}")
logger.debug(f" (skipping: {transformer_dirs})")
shutil.copytree(
str(original_model_path),
str(output_path),
ignore=shutil.ignore_patterns(*transformer_dirs),
)
# Step 2+: Convert each transformer
for i, tdir in enumerate(transformer_dirs):
q_path = get_quant_subpath(model_type, quant_path, tdir)
out_tdir = output_path / tdir
logger.debug(
f"\nStep {i + 2}: Converting {tdir} (quant source: {q_path.name})..."
)
convert_transformer(model_type, q_path, out_tdir)
# Copy config.json from the original transformer dir
src_config = original_model_path / tdir / "config.json"
if src_config.is_file():
shutil.copy2(str(src_config), str(out_tdir / "config.json"))
logger.debug(f" Copied config.json from original {tdir}/")
logger.info(f"\nDone! Repacked model saved to: {output_path}")
def get_args():
parser = argparse.ArgumentParser(
description="Repack msmodelslim quantized Wan2.2 weights into HF Diffusers format"
)
parser.add_argument(
"--model-type",
type=str,
required=True,
choices=SUPPORTED_MODEL_TYPES,
help="Model type to convert",
)
parser.add_argument(
"--original-model-path",
type=str,
required=True,
help="Path to the original HF Diffusers model (e.g., /weights/Wan2.2-TI2V-5B-Diffusers)",
)
parser.add_argument(
"--quant-path",
type=str,
required=True,
help="Path to msmodelslim quantized weights directory",
)
parser.add_argument(
"--output-path",
type=str,
required=True,
help="Output path for the repacked model (e.g., /weights/Wan2.2-TI2V-5B-Diffusers-MXFP8)",
)
return parser.parse_args()
if __name__ == "__main__":
args = get_args()
repack(
model_type=args.model_type,
original_model_path=pathlib.Path(args.original_model_path),
quant_path=pathlib.Path(args.quant_path),
output_path=pathlib.Path(args.output_path),
)