[diffusion] feat: Improve LoRA compatibility by adding unified format detection and diffusers-based normalization (#14659)
This commit is contained in:
@@ -0,0 +1,418 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, Iterable, Mapping, Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from diffusers.loaders import lora_conversion_utils as lcu
|
||||||
|
|
||||||
|
logger = logging.getLogger("LoRAFormatAdapter")
|
||||||
|
|
||||||
|
|
||||||
|
class LoRAFormat(str, Enum):
|
||||||
|
"""Supported external LoRA formats before normalization."""
|
||||||
|
|
||||||
|
STANDARD = "standard"
|
||||||
|
NON_DIFFUSERS_SD = "non-diffusers-sd"
|
||||||
|
QWEN_IMAGE_STANDARD = "qwen-image-standard"
|
||||||
|
XLABS_FLUX = "xlabs-ai"
|
||||||
|
KOHYA_FLUX = "kohya-flux"
|
||||||
|
WAN = "wan"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_keys(keys: Iterable[str], k: int = 20) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for i, key in enumerate(keys):
|
||||||
|
if i >= k:
|
||||||
|
break
|
||||||
|
out.append(key)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _has_substring_key(keys: Iterable[str], substr: str) -> bool:
|
||||||
|
return any(substr in k for k in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_prefix_key(keys: Iterable[str], prefix: str) -> bool:
|
||||||
|
return any(k.startswith(prefix) for k in keys)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Format-specific heuristics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_xlabs_flux_key(k: str) -> bool:
|
||||||
|
"""XLabs FLUX-style keys under double_blocks/single_blocks with lora down/up."""
|
||||||
|
if not (k.endswith(".down.weight") or k.endswith(".up.weight")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not k.startswith(
|
||||||
|
(
|
||||||
|
"double_blocks.",
|
||||||
|
"single_blocks.",
|
||||||
|
"diffusion_model.double_blocks",
|
||||||
|
"diffusion_model.single_blocks",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return ".processor." in k or ".proj_lora" in k or ".qkv_lora" in k
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_kohya_flux(state_dict: Mapping[str, torch.Tensor]) -> bool:
|
||||||
|
"""Kohya FLUX LoRA (flux_lora.py) under lora_unet_double/single_blocks_ prefixes."""
|
||||||
|
if not state_dict:
|
||||||
|
return False
|
||||||
|
keys = state_dict.keys()
|
||||||
|
return any(
|
||||||
|
k.startswith("lora_unet_double_blocks_")
|
||||||
|
or k.startswith("lora_unet_single_blocks_")
|
||||||
|
for k in keys
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_non_diffusers_sd(state_dict: Mapping[str, torch.Tensor]) -> bool:
|
||||||
|
"""Classic non-diffusers SD LoRA (Kohya/A1111/sd-scripts)."""
|
||||||
|
if not state_dict:
|
||||||
|
return False
|
||||||
|
keys = state_dict.keys()
|
||||||
|
return all(
|
||||||
|
k.startswith(("lora_unet_", "lora_te_", "lora_te1_", "lora_te2_")) for k in keys
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_wan_lora(state_dict: Mapping[str, torch.Tensor]) -> bool:
|
||||||
|
"""Wan2.2 distill LoRAs (Wan-AI / Wan2.2-Distill-Loras style)."""
|
||||||
|
if not state_dict:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for k in state_dict.keys():
|
||||||
|
if not k.startswith("diffusion_model.blocks."):
|
||||||
|
continue
|
||||||
|
if ".lora_down" not in k and ".lora_up" not in k:
|
||||||
|
continue
|
||||||
|
if ".cross_attn." in k or ".self_attn." in k or ".ffn." in k or ".norm3." in k:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_qwen_image(state_dict: Mapping[str, torch.Tensor]) -> bool:
|
||||||
|
keys = list(state_dict.keys())
|
||||||
|
if not keys:
|
||||||
|
return False
|
||||||
|
return _has_prefix_key(keys, "transformer.transformer_blocks.") and (
|
||||||
|
_has_substring_key(keys, ".lora.down.weight")
|
||||||
|
or _has_substring_key(keys, ".lora.up.weight")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Format detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def detect_lora_format_from_state_dict(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
) -> LoRAFormat:
|
||||||
|
"""Classify LoRA format by key patterns only."""
|
||||||
|
keys = list(state_dict.keys())
|
||||||
|
if not keys:
|
||||||
|
return LoRAFormat.STANDARD
|
||||||
|
|
||||||
|
if _has_substring_key(keys, ".lora_A") or _has_substring_key(keys, ".lora_B"):
|
||||||
|
return LoRAFormat.STANDARD
|
||||||
|
|
||||||
|
if any(_looks_like_xlabs_flux_key(k) for k in keys):
|
||||||
|
return LoRAFormat.XLABS_FLUX
|
||||||
|
if _looks_like_kohya_flux(state_dict):
|
||||||
|
return LoRAFormat.KOHYA_FLUX
|
||||||
|
|
||||||
|
if _looks_like_wan_lora(state_dict):
|
||||||
|
return LoRAFormat.WAN
|
||||||
|
|
||||||
|
if _looks_like_qwen_image(state_dict):
|
||||||
|
return LoRAFormat.STANDARD
|
||||||
|
|
||||||
|
if _looks_like_non_diffusers_sd(state_dict):
|
||||||
|
return LoRAFormat.NON_DIFFUSERS_SD
|
||||||
|
|
||||||
|
if _has_substring_key(keys, ".lora.down") or _has_substring_key(keys, ".lora_up"):
|
||||||
|
return LoRAFormat.NON_DIFFUSERS_SD
|
||||||
|
|
||||||
|
return LoRAFormat.STANDARD
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Converters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_qwen_image_standard(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Qwen-Image: transformer.*.lora.down/up -> transformer_blocks.*.lora_A/B."""
|
||||||
|
out: Dict[str, torch.Tensor] = {}
|
||||||
|
|
||||||
|
for name, tensor in state_dict.items():
|
||||||
|
new_name = name
|
||||||
|
|
||||||
|
if new_name.startswith("transformer."):
|
||||||
|
new_name = new_name[len("transformer.") :]
|
||||||
|
|
||||||
|
if new_name.endswith(".lora.down.weight"):
|
||||||
|
new_name = new_name.replace(".lora.down.weight", ".lora_A.weight")
|
||||||
|
elif new_name.endswith(".lora.up.weight"):
|
||||||
|
new_name = new_name.replace(".lora.up.weight", ".lora_B.weight")
|
||||||
|
|
||||||
|
out[new_name] = tensor
|
||||||
|
|
||||||
|
sample = _sample_keys(out.keys(), 20)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_non_diffusers_sd_simple(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Generic down/up -> A/B conversion for non-diffusers SD-like formats."""
|
||||||
|
out: Dict[str, torch.Tensor] = {}
|
||||||
|
|
||||||
|
for name, tensor in state_dict.items():
|
||||||
|
new_name = name
|
||||||
|
|
||||||
|
if "lora_down.weight" in new_name:
|
||||||
|
new_name = new_name.replace("lora_down.weight", "lora_A.weight")
|
||||||
|
elif "lora_up.weight" in new_name:
|
||||||
|
new_name = new_name.replace("lora_up.weight", "lora_B.weight")
|
||||||
|
elif new_name.endswith(".lora_down"):
|
||||||
|
new_name = new_name.replace(".lora_down", ".lora_A")
|
||||||
|
elif new_name.endswith(".lora_up"):
|
||||||
|
new_name = new_name.replace(".lora_up", ".lora_B")
|
||||||
|
|
||||||
|
out[new_name] = tensor
|
||||||
|
|
||||||
|
sample = _sample_keys(out.keys(), 20)
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] after NON_DIFFUSERS_SD simple conversion, "
|
||||||
|
"sample keys (<=20): %s",
|
||||||
|
", ".join(sample),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_with_diffusers_utils_if_available(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Optional[Dict[str, torch.Tensor]]:
|
||||||
|
"""Use diffusers.lora_conversion_utils if available."""
|
||||||
|
try:
|
||||||
|
if hasattr(lcu, "maybe_convert_state_dict"):
|
||||||
|
converted = lcu.maybe_convert_state_dict( # type: ignore[attr-defined]
|
||||||
|
state_dict
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
converted = dict(state_dict)
|
||||||
|
|
||||||
|
if not isinstance(converted, dict):
|
||||||
|
converted = dict(converted)
|
||||||
|
|
||||||
|
sample = _sample_keys(converted.keys(), 20)
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] diffusers.lora_conversion_utils converted keys, "
|
||||||
|
"sample keys (<=20): %s",
|
||||||
|
", ".join(sample),
|
||||||
|
)
|
||||||
|
return converted
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
log.warning(
|
||||||
|
"[LoRAFormatAdapter] diffusers lora_conversion_utils failed, "
|
||||||
|
"falling back to internal converters. Error: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_via_diffusers_candidates(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
candidate_names: tuple[str, ...],
|
||||||
|
log: logging.Logger,
|
||||||
|
unavailable_warning: str,
|
||||||
|
no_converter_warning: str,
|
||||||
|
success_info: str,
|
||||||
|
all_failed_warning: str,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Try multiple named converters in lora_conversion_utils, use the first that works."""
|
||||||
|
converters = [
|
||||||
|
(n, getattr(lcu, n)) for n in candidate_names if callable(getattr(lcu, n, None))
|
||||||
|
]
|
||||||
|
if not converters:
|
||||||
|
log.warning(no_converter_warning)
|
||||||
|
return dict(state_dict)
|
||||||
|
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
|
||||||
|
for name, fn in converters:
|
||||||
|
try:
|
||||||
|
sd_copy = dict(state_dict)
|
||||||
|
out = fn(sd_copy)
|
||||||
|
if isinstance(out, tuple) and isinstance(out[0], dict):
|
||||||
|
out = out[0]
|
||||||
|
if not isinstance(out, dict):
|
||||||
|
raise TypeError(f"Converter {name} returned {type(out)}")
|
||||||
|
log.info(success_info.format(name=name))
|
||||||
|
return out
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = exc
|
||||||
|
|
||||||
|
log.warning(all_failed_warning.format(last_err=last_err))
|
||||||
|
return dict(state_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_xlabs_ai_via_diffusers(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Convert XLabs FLUX LoRA via diffusers helpers."""
|
||||||
|
return _convert_via_diffusers_candidates(
|
||||||
|
state_dict,
|
||||||
|
(
|
||||||
|
"_convert_xlabs_flux_lora_to_diffusers",
|
||||||
|
"convert_xlabs_lora_state_dict_to_diffusers",
|
||||||
|
"convert_xlabs_lora_to_diffusers",
|
||||||
|
"convert_xlabs_flux_lora_to_diffusers",
|
||||||
|
),
|
||||||
|
log=log,
|
||||||
|
unavailable_warning=(
|
||||||
|
"[LoRAFormatAdapter] XLabs FLUX detected but diffusers is unavailable."
|
||||||
|
),
|
||||||
|
no_converter_warning=(
|
||||||
|
"[LoRAFormatAdapter] No XLabs FLUX converter found in diffusers."
|
||||||
|
),
|
||||||
|
success_info="[LoRAFormatAdapter] Converted XLabs FLUX LoRA using {name}",
|
||||||
|
all_failed_warning=(
|
||||||
|
"[LoRAFormatAdapter] All XLabs FLUX converters failed; "
|
||||||
|
"last error: {last_err}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_kohya_flux_via_diffusers(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Convert Kohya FLUX LoRA via diffusers helpers."""
|
||||||
|
return _convert_via_diffusers_candidates(
|
||||||
|
state_dict,
|
||||||
|
(
|
||||||
|
"_convert_kohya_flux_lora_to_diffusers",
|
||||||
|
"convert_kohya_flux_lora_to_diffusers",
|
||||||
|
),
|
||||||
|
log=log,
|
||||||
|
unavailable_warning=(
|
||||||
|
"[LoRAFormatAdapter] Kohya FLUX detected but diffusers is unavailable."
|
||||||
|
),
|
||||||
|
no_converter_warning="[LoRAFormatAdapter] No Kohya FLUX converter found.",
|
||||||
|
success_info="[LoRAFormatAdapter] Converted Kohya FLUX LoRA using {name}",
|
||||||
|
all_failed_warning=(
|
||||||
|
"[LoRAFormatAdapter] Kohya FLUX conversion failed; "
|
||||||
|
"last error: {last_err}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conversion dispatcher
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def convert_lora_state_dict_by_format(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
fmt: LoRAFormat,
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Normalize a raw LoRA state_dict into A/B + .weight naming."""
|
||||||
|
if fmt == LoRAFormat.QWEN_IMAGE_STANDARD:
|
||||||
|
return _convert_qwen_image_standard(state_dict, log)
|
||||||
|
|
||||||
|
if fmt == LoRAFormat.XLABS_FLUX:
|
||||||
|
converted = _convert_xlabs_ai_via_diffusers(state_dict, log)
|
||||||
|
return _convert_non_diffusers_sd_simple(converted, log)
|
||||||
|
|
||||||
|
if fmt == LoRAFormat.KOHYA_FLUX:
|
||||||
|
converted = _convert_kohya_flux_via_diffusers(state_dict, log)
|
||||||
|
return _convert_non_diffusers_sd_simple(converted, log)
|
||||||
|
|
||||||
|
if fmt == LoRAFormat.WAN:
|
||||||
|
maybe = _convert_with_diffusers_utils_if_available(state_dict, log)
|
||||||
|
if maybe is None:
|
||||||
|
maybe = dict(state_dict)
|
||||||
|
return _convert_non_diffusers_sd_simple(maybe, log)
|
||||||
|
|
||||||
|
if fmt == LoRAFormat.STANDARD:
|
||||||
|
maybe = _convert_with_diffusers_utils_if_available(state_dict, log)
|
||||||
|
if maybe is None:
|
||||||
|
maybe = dict(state_dict)
|
||||||
|
|
||||||
|
if _looks_like_qwen_image(maybe):
|
||||||
|
return _convert_qwen_image_standard(maybe, log)
|
||||||
|
|
||||||
|
return maybe
|
||||||
|
|
||||||
|
if fmt == LoRAFormat.NON_DIFFUSERS_SD:
|
||||||
|
maybe = _convert_with_diffusers_utils_if_available(state_dict, log)
|
||||||
|
if maybe is None:
|
||||||
|
maybe = dict(state_dict)
|
||||||
|
return _convert_non_diffusers_sd_simple(maybe, log)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] format %s not handled specially, returning as-is",
|
||||||
|
fmt,
|
||||||
|
)
|
||||||
|
return dict(state_dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lora_state_dict(
|
||||||
|
state_dict: Mapping[str, torch.Tensor],
|
||||||
|
logger: Optional[logging.Logger] = None,
|
||||||
|
) -> Dict[str, torch.Tensor]:
|
||||||
|
"""Normalize any supported LoRA format into a single canonical layout."""
|
||||||
|
log = logger or globals()["logger"]
|
||||||
|
|
||||||
|
keys = list(state_dict.keys())
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] normalize_lora_state_dict called, #keys=%d",
|
||||||
|
len(keys),
|
||||||
|
)
|
||||||
|
if keys:
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] before convert, sample keys (<=20): %s",
|
||||||
|
", ".join(_sample_keys(keys, 20)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt = detect_lora_format_from_state_dict(state_dict)
|
||||||
|
log.info("[LoRAFormatAdapter] detected format: %s", fmt)
|
||||||
|
|
||||||
|
normalized = convert_lora_state_dict_by_format(state_dict, fmt, log)
|
||||||
|
|
||||||
|
norm_keys = list(normalized.keys())
|
||||||
|
if norm_keys:
|
||||||
|
log.info(
|
||||||
|
"[LoRAFormatAdapter] after convert, sample keys (<=20): %s",
|
||||||
|
", ".join(_sample_keys(norm_keys, 20)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
@@ -20,6 +20,9 @@ from sglang.multimodal_gen.runtime.loader.utils import get_param_names_mapping
|
|||||||
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
||||||
ComposedPipelineBase,
|
ComposedPipelineBase,
|
||||||
)
|
)
|
||||||
|
from sglang.multimodal_gen.runtime.pipelines_core.lora_format_adapter import (
|
||||||
|
normalize_lora_state_dict,
|
||||||
|
)
|
||||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||||
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import maybe_download_lora
|
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import maybe_download_lora
|
||||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||||
@@ -298,7 +301,9 @@ class LoRAPipeline(ComposedPipelineBase):
|
|||||||
"""
|
"""
|
||||||
assert lora_path is not None
|
assert lora_path is not None
|
||||||
lora_local_path = maybe_download_lora(lora_path)
|
lora_local_path = maybe_download_lora(lora_path)
|
||||||
lora_state_dict = load_file(lora_local_path)
|
|
||||||
|
raw_state_dict = load_file(lora_local_path)
|
||||||
|
lora_state_dict = normalize_lora_state_dict(raw_state_dict, logger=logger)
|
||||||
|
|
||||||
if lora_nickname in self.lora_adapters:
|
if lora_nickname in self.lora_adapters:
|
||||||
self.lora_adapters[lora_nickname].clear()
|
self.lora_adapters[lora_nickname].clear()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ SUITES = {
|
|||||||
"1-gpu": [
|
"1-gpu": [
|
||||||
"test_server_a.py",
|
"test_server_a.py",
|
||||||
"test_server_b.py",
|
"test_server_b.py",
|
||||||
|
"test_lora_format_adapter.py",
|
||||||
# add new 1-gpu test files here
|
# add new 1-gpu test files here
|
||||||
],
|
],
|
||||||
"2-gpu": [
|
"2-gpu": [
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
test_lora_format_adapter.py
|
||||||
|
|
||||||
|
Small regression test for the LoRA format adapter.
|
||||||
|
|
||||||
|
It downloads several public LoRA checkpoints from Hugging Face, runs
|
||||||
|
format detection and normalization, and prints a compact summary table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
|
||||||
|
from sglang.multimodal_gen.runtime.pipelines_core.lora_format_adapter import (
|
||||||
|
LoRAFormat,
|
||||||
|
detect_lora_format_from_state_dict,
|
||||||
|
normalize_lora_state_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, force=True)
|
||||||
|
logger = logging.getLogger("lora_test")
|
||||||
|
|
||||||
|
ROOT_DIR = os.path.join(tempfile.gettempdir(), "sglang_lora_tests")
|
||||||
|
os.makedirs(ROOT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def download_lora(
|
||||||
|
repo_id: str,
|
||||||
|
filename: str,
|
||||||
|
local_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Download a LoRA safetensors file into ROOT_DIR and return its local path.
|
||||||
|
"""
|
||||||
|
print(f"=== Downloading LoRA from {repo_id} ({filename}) ===")
|
||||||
|
path = hf_hub_download(
|
||||||
|
repo_id=repo_id,
|
||||||
|
filename=filename,
|
||||||
|
local_dir=ROOT_DIR,
|
||||||
|
local_dir_use_symlinks=False,
|
||||||
|
)
|
||||||
|
dst = os.path.join(ROOT_DIR, local_name)
|
||||||
|
if os.path.abspath(path) != os.path.abspath(dst):
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.copy2(path, dst)
|
||||||
|
except Exception:
|
||||||
|
dst = path
|
||||||
|
print(f"Saved to: {dst}")
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def is_diffusers_style_keys(
|
||||||
|
sd: Dict[str, torch.Tensor],
|
||||||
|
debug_name: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Relaxed structural check that a state_dict looks like diffusers-style LoRA.
|
||||||
|
|
||||||
|
The check verifies:
|
||||||
|
1) No known non-diffusers prefixes.
|
||||||
|
2) No non-diffusers suffixes such as alpha / dora_scale / magnitude vectors.
|
||||||
|
3) Most top-level roots match common diffusers module namespaces.
|
||||||
|
"""
|
||||||
|
if not sd:
|
||||||
|
print(f"[{debug_name}] diffusers-style check: EMPTY state_dict")
|
||||||
|
return False
|
||||||
|
|
||||||
|
keys: List[str] = list(sd.keys())
|
||||||
|
total = len(keys)
|
||||||
|
|
||||||
|
banned_prefixes = (
|
||||||
|
"lora_unet_",
|
||||||
|
"lora_te_",
|
||||||
|
"lora_te1_",
|
||||||
|
"lora_te2_",
|
||||||
|
"lora_unet_double_blocks_",
|
||||||
|
"lora_unet_single_blocks_",
|
||||||
|
)
|
||||||
|
bad_prefix_keys = [k for k in keys if k.startswith(banned_prefixes)]
|
||||||
|
cond1 = len(bad_prefix_keys) == 0
|
||||||
|
|
||||||
|
banned_suffixes = (
|
||||||
|
".alpha",
|
||||||
|
".dora_scale",
|
||||||
|
".lora_magnitude_vector",
|
||||||
|
)
|
||||||
|
bad_suffix_keys = [k for k in keys if k.endswith(banned_suffixes)]
|
||||||
|
cond2 = len(bad_suffix_keys) == 0
|
||||||
|
|
||||||
|
allowed_roots = {
|
||||||
|
"unet",
|
||||||
|
"text_encoder",
|
||||||
|
"text_encoder_2",
|
||||||
|
"transformer",
|
||||||
|
"prior",
|
||||||
|
"image_encoder",
|
||||||
|
"vae",
|
||||||
|
"diffusion_model",
|
||||||
|
}
|
||||||
|
root_names = [k.split(".", 1)[0] for k in keys]
|
||||||
|
root_ok_count = sum(r in allowed_roots for r in root_names)
|
||||||
|
cond3 = root_ok_count >= 0.6 * total
|
||||||
|
|
||||||
|
ok = cond1 and cond2 and cond3
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
print(f"[{debug_name}] diffusers-style check FAILED (relaxed):")
|
||||||
|
print(f" total keys = {total}")
|
||||||
|
print(
|
||||||
|
f" cond1(no banned prefixes) = {cond1}, bad_prefix_keys={len(bad_prefix_keys)}"
|
||||||
|
)
|
||||||
|
if not cond1 and bad_prefix_keys:
|
||||||
|
print(" example bad prefix key:", bad_prefix_keys[0])
|
||||||
|
print(
|
||||||
|
f" cond2(no banned suffixes) = {cond2}, bad_suffix_keys={len(bad_suffix_keys)}"
|
||||||
|
)
|
||||||
|
if not cond2 and bad_suffix_keys:
|
||||||
|
print(" example bad suffix key:", bad_suffix_keys[0])
|
||||||
|
print(f" cond3(allowed roots>=60%) = {cond3}, root_ok_count={root_ok_count}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def run_single_test(
|
||||||
|
name: str,
|
||||||
|
repo_id: str,
|
||||||
|
filename: str,
|
||||||
|
local_name: str,
|
||||||
|
expected_before: LoRAFormat,
|
||||||
|
expected_after: LoRAFormat = LoRAFormat.STANDARD,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Run a single end-to-end test for one LoRA checkpoint.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1) Download.
|
||||||
|
2) Detect format on raw keys.
|
||||||
|
3) Normalize via lora_format_adapter.
|
||||||
|
4) Detect again on the normalized dict.
|
||||||
|
5) Optionally check for diffusers-style key structure.
|
||||||
|
"""
|
||||||
|
logger.info(f"=== Running test: {name} ===")
|
||||||
|
local_path = download_lora(repo_id, filename, local_name)
|
||||||
|
raw_state = load_file(local_path)
|
||||||
|
|
||||||
|
detected_before = detect_lora_format_from_state_dict(raw_state)
|
||||||
|
norm_state = normalize_lora_state_dict(raw_state, logger=logger)
|
||||||
|
detected_after = detect_lora_format_from_state_dict(norm_state)
|
||||||
|
standard_like = is_diffusers_style_keys(norm_state, debug_name=name)
|
||||||
|
|
||||||
|
passed = detected_before == expected_before and detected_after == expected_after
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"expected_before": expected_before.value,
|
||||||
|
"detected_before": detected_before.value,
|
||||||
|
"expected_after": expected_after.value,
|
||||||
|
"detected_after": detected_after.value,
|
||||||
|
"standard_like_keys": standard_like,
|
||||||
|
"pass": passed,
|
||||||
|
"num_keys_raw": len(raw_state),
|
||||||
|
"num_keys_norm": len(norm_state),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all_tests() -> List[Dict]:
|
||||||
|
results: List[Dict] = []
|
||||||
|
|
||||||
|
# SDXL LoRA that is already in diffusers/PEFT format.
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="HF standard SDXL LoRA",
|
||||||
|
repo_id="jbilcke-hf/sdxl-cinematic-1",
|
||||||
|
filename="pytorch_lora_weights.safetensors",
|
||||||
|
local_name="sdxl_cinematic1_pytorch_lora_weights.safetensors",
|
||||||
|
expected_before=LoRAFormat.STANDARD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# XLabs FLUX LoRA (non-diffusers → diffusers).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="XLabs FLUX Realism LoRA",
|
||||||
|
repo_id="XLabs-AI/flux-RealismLora",
|
||||||
|
filename="lora.safetensors",
|
||||||
|
local_name="flux_realism_lora.safetensors",
|
||||||
|
expected_before=LoRAFormat.XLABS_FLUX,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kohya-style FLUX LoRA (sd-scripts flux_lora.py → diffusers).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Kohya-style Flux LoRA",
|
||||||
|
repo_id="kohya-ss/misc-models",
|
||||||
|
filename="flux-hasui-lora-d4-sigmoid-raw-gs1.0.safetensors",
|
||||||
|
local_name="flux_hasui_lora_d4_sigmoid_raw_gs1_0.safetensors",
|
||||||
|
expected_before=LoRAFormat.KOHYA_FLUX,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classic Kohya/A1111 SD LoRA (non-diffusers SD → diffusers).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Kohya-style SD LoRA",
|
||||||
|
repo_id="kohya-ss/misc-models",
|
||||||
|
filename="fp-1f-chibi-1024.safetensors",
|
||||||
|
local_name="fp_1f_chibi_1024.safetensors",
|
||||||
|
expected_before=LoRAFormat.NON_DIFFUSERS_SD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wan2.1 Fun Reward LoRA (ComfyUI format → diffusers).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Wan2.1 Fun Reward LoRA (Comfy)",
|
||||||
|
repo_id="alibaba-pai/Wan2.1-Fun-Reward-LoRAs",
|
||||||
|
filename="Wan2.1-Fun-1.3B-InP-MPS.safetensors",
|
||||||
|
local_name="wan21_fun_1_3b_inp_mps.safetensors",
|
||||||
|
expected_before=LoRAFormat.NON_DIFFUSERS_SD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qwen-Image EVA LoRA (already diffusers/PEFT-style).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Qwen-Image EVA LoRA",
|
||||||
|
repo_id="starsfriday/Qwen-Image-EVA-LoRA",
|
||||||
|
filename="qwen_image_eva.safetensors",
|
||||||
|
local_name="qwen_image_eva.safetensors",
|
||||||
|
expected_before=LoRAFormat.STANDARD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qwen-Image Lightning LoRA (non-diffusers Qwen → diffusers).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Qwen-Image Lightning LoRA",
|
||||||
|
repo_id="lightx2v/Qwen-Image-Lightning",
|
||||||
|
filename="Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
|
||||||
|
local_name="qwen_image_lightning_4steps_v1_bf16.safetensors",
|
||||||
|
expected_before=LoRAFormat.NON_DIFFUSERS_SD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classic Painting Z-Image Turbo LoRA (Z-Image family).
|
||||||
|
results.append(
|
||||||
|
run_single_test(
|
||||||
|
name="Classic Painting Z-Image LoRA",
|
||||||
|
repo_id="renderartist/Classic-Painting-Z-Image-Turbo-LoRA",
|
||||||
|
filename="Classic_Painting_Z_Image_Turbo_v1_renderartist_1750.safetensors",
|
||||||
|
local_name="classic_painting_z_image_turbo_v1_renderartist_1750.safetensors",
|
||||||
|
expected_before=LoRAFormat.STANDARD,
|
||||||
|
expected_after=LoRAFormat.STANDARD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(results: List[Dict]) -> None:
|
||||||
|
print("\n================ LoRA format adapter test ================")
|
||||||
|
|
||||||
|
header = (
|
||||||
|
f"{'Test Name':30} "
|
||||||
|
f"{'Exp(b)':12} "
|
||||||
|
f"{'Act(b)':12} "
|
||||||
|
f"{'Exp(a)':12} "
|
||||||
|
f"{'Act(a)':12} "
|
||||||
|
f"{'StdLike':8} "
|
||||||
|
f"{'#Raw':7} "
|
||||||
|
f"{'#Norm':7} "
|
||||||
|
f"{'PASS':5}"
|
||||||
|
)
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
print(
|
||||||
|
f"{r['name'][:30]:30} "
|
||||||
|
f"{r['expected_before'][:12]:12} "
|
||||||
|
f"{r['detected_before'][:12]:12} "
|
||||||
|
f"{r['expected_after'][:12]:12} "
|
||||||
|
f"{r['detected_after'][:12]:12} "
|
||||||
|
f"{str(r['standard_like_keys']):8} "
|
||||||
|
f"{r['num_keys_raw']:7d} "
|
||||||
|
f"{r['num_keys_norm']:7d} "
|
||||||
|
f"{str(r['pass']):5}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=========================================================\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
results = _run_all_tests()
|
||||||
|
_print_summary(results)
|
||||||
|
|
||||||
|
if not all(r["pass"] for r in results):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoRAFormatAdapter:
|
||||||
|
def test_lora_format_adapter_all_formats(self):
|
||||||
|
results = _run_all_tests()
|
||||||
|
assert all(
|
||||||
|
r["pass"] for r in results
|
||||||
|
), "At least one LoRA format adapter case failed"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user