Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

1095 lines
48 KiB
Python

# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team
"""Class for Z-Image model loading in InvokeAI."""
from pathlib import Path
from typing import Any, Optional
import accelerate
import torch
from transformers import AutoTokenizer, Qwen3ForCausalLM
from invokeai.backend.model_manager.configs.base import Checkpoint_Config_Base, Diffusers_Config_Base
from invokeai.backend.model_manager.configs.controlnet import ControlNet_Checkpoint_ZImage_Config
from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.configs.main import Main_Checkpoint_ZImage_Config, Main_GGUF_ZImage_Config
from invokeai.backend.model_manager.configs.qwen3_encoder import (
Qwen3Encoder_Checkpoint_Config,
Qwen3Encoder_GGUF_Config,
Qwen3Encoder_Qwen3Encoder_Config,
)
from invokeai.backend.model_manager.load.load_default import ModelLoader
from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry
from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader
from invokeai.backend.model_manager.taxonomy import (
AnyModel,
BaseModelType,
ModelFormat,
ModelType,
SubModelType,
)
from invokeai.backend.quantization.gguf.loaders import gguf_sd_loader
from invokeai.backend.util.devices import TorchDevice
def _convert_z_image_gguf_to_diffusers(sd: dict[str, Any]) -> dict[str, Any]:
"""Convert Z-Image GGUF state dict keys to diffusers format.
The GGUF format uses original model keys that differ from diffusers:
- qkv.weight (fused) -> to_q.weight, to_k.weight, to_v.weight (split)
- out.weight -> to_out.0.weight
- q_norm.weight -> norm_q.weight
- k_norm.weight -> norm_k.weight
- x_embedder.* -> all_x_embedder.2-1.*
- final_layer.* -> all_final_layer.2-1.*
- norm_final.* -> skipped (diffusers uses non-learnable LayerNorm)
- x_pad_token, cap_pad_token: [dim] -> [1, dim] (diffusers expects batch dimension)
"""
new_sd: dict[str, Any] = {}
for key, value in sd.items():
if not isinstance(key, str):
new_sd[key] = value
continue
# Handle padding tokens: GGUF has shape [dim], diffusers expects [1, dim]
if key in ("x_pad_token", "cap_pad_token"):
if hasattr(value, "shape") and len(value.shape) == 1:
# GGMLTensor doesn't support unsqueeze, so dequantize first if needed
if hasattr(value, "get_dequantized_tensor"):
value = value.get_dequantized_tensor()
# Use reshape instead of unsqueeze for better compatibility
value = torch.as_tensor(value).reshape(1, -1)
new_sd[key] = value
continue
# Handle x_embedder -> all_x_embedder.2-1
if key.startswith("x_embedder."):
suffix = key[len("x_embedder.") :]
new_key = f"all_x_embedder.2-1.{suffix}"
new_sd[new_key] = value
continue
# Handle final_layer -> all_final_layer.2-1
if key.startswith("final_layer."):
suffix = key[len("final_layer.") :]
new_key = f"all_final_layer.2-1.{suffix}"
new_sd[new_key] = value
continue
# Skip norm_final keys - the diffusers model uses LayerNorm with elementwise_affine=False
# (no learnable weight/bias), but some checkpoints (e.g., FP8) include these as all-zeros
if key.startswith("norm_final."):
continue
# Handle fused QKV weights - need to split
if ".attention.qkv." in key:
# Get the layer prefix and suffix
prefix = key.rsplit(".attention.qkv.", 1)[0]
suffix = key.rsplit(".attention.qkv.", 1)[1] # "weight" or "bias"
# Skip non-weight/bias tensors (e.g., FP8 scale_weight tensors)
# These are quantization metadata and should not be split
if suffix not in ("weight", "bias"):
new_sd[key] = value
continue
# Split the fused QKV tensor into Q, K, V
tensor = value
if hasattr(tensor, "shape"):
if tensor.shape[0] % 3 != 0:
raise ValueError(
f"Cannot split QKV tensor '{key}': first dimension ({tensor.shape[0]}) "
"is not divisible by 3. The model file may be corrupted or incompatible."
)
dim = tensor.shape[0] // 3
q = tensor[:dim]
k = tensor[dim : 2 * dim]
v = tensor[2 * dim :]
new_sd[f"{prefix}.attention.to_q.{suffix}"] = q
new_sd[f"{prefix}.attention.to_k.{suffix}"] = k
new_sd[f"{prefix}.attention.to_v.{suffix}"] = v
continue
# Handle attention key renaming
if ".attention." in key:
new_key = key.replace(".q_norm.", ".norm_q.")
new_key = new_key.replace(".k_norm.", ".norm_k.")
new_key = new_key.replace(".attention.out.", ".attention.to_out.0.")
new_sd[new_key] = value
continue
# For all other keys, just copy as-is
new_sd[key] = value
return new_sd
@ModelLoaderRegistry.register(base=BaseModelType.ZImage, type=ModelType.Main, format=ModelFormat.Diffusers)
class ZImageDiffusersModel(GenericDiffusersLoader):
"""Class to load Z-Image main models (Z-Image-Turbo, Z-Image-Base, Z-Image-Edit)."""
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if isinstance(config, Checkpoint_Config_Base):
raise NotImplementedError("CheckpointConfigBase is not implemented for Z-Image models.")
if submodel_type is None:
raise Exception("A submodel type must be provided when loading main pipelines.")
model_path = Path(config.path)
load_class = self.get_hf_load_class(model_path, submodel_type)
repo_variant = config.repo_variant if isinstance(config, Diffusers_Config_Base) else None
variant = repo_variant.value if repo_variant else None
model_path = model_path / submodel_type.value
# Z-Image prefers bfloat16, but use safe dtype based on target device capabilities.
target_device = TorchDevice.choose_torch_device()
dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
try:
result: AnyModel = load_class.from_pretrained(
model_path,
torch_dtype=dtype,
variant=variant,
)
except OSError as e:
if variant and "no file named" in str(
e
): # try without the variant, just in case user's preferences changed
result = load_class.from_pretrained(model_path, torch_dtype=dtype)
else:
raise e
result = self._apply_fp8_layerwise_casting(result, config, submodel_type)
return result
@ModelLoaderRegistry.register(base=BaseModelType.ZImage, type=ModelType.Main, format=ModelFormat.Checkpoint)
class ZImageCheckpointModel(ModelLoader):
"""Class to load Z-Image transformer models from single-file checkpoints (safetensors, etc)."""
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Checkpoint_Config_Base):
raise ValueError("Only CheckpointConfigBase models are currently supported here.")
match submodel_type:
case SubModelType.Transformer:
return self._load_from_singlefile(config)
raise ValueError(
f"Only Transformer submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}"
)
def _load_from_singlefile(
self,
config: AnyModelConfig,
) -> AnyModel:
from diffusers import ZImageTransformer2DModel
from safetensors.torch import load_file
if not isinstance(config, Main_Checkpoint_ZImage_Config):
raise TypeError(
f"Expected Main_Checkpoint_ZImage_Config, got {type(config).__name__}. "
"Model configuration type mismatch."
)
model_path = Path(config.path)
# Load the state dict from safetensors/checkpoint file
sd = load_file(model_path)
# Some Z-Image checkpoint files have keys prefixed with "diffusion_model." or
# "model.diffusion_model." (ComfyUI-style format). Check if we need to strip this prefix.
prefix_to_strip = None
for prefix in ["model.diffusion_model.", "diffusion_model."]:
if any(k.startswith(prefix) for k in sd.keys() if isinstance(k, str)):
prefix_to_strip = prefix
break
if prefix_to_strip:
stripped_sd = {}
for key, value in sd.items():
if isinstance(key, str) and key.startswith(prefix_to_strip):
stripped_sd[key[len(prefix_to_strip) :]] = value
else:
stripped_sd[key] = value
sd = stripped_sd
# Check if the state dict is in original format (not diffusers format)
# Original format has keys like "x_embedder.weight" instead of "all_x_embedder.2-1.weight"
needs_conversion = any(k.startswith("x_embedder.") for k in sd.keys() if isinstance(k, str))
if needs_conversion:
# Convert from original format to diffusers format
sd = _convert_z_image_gguf_to_diffusers(sd)
# Create an empty model with the default Z-Image config
# Z-Image-Turbo uses these default parameters from diffusers
with accelerate.init_empty_weights():
model = ZImageTransformer2DModel(
all_patch_size=(2,),
all_f_patch_size=(1,),
in_channels=16,
dim=3840,
n_layers=30,
n_refiner_layers=2,
n_heads=30,
n_kv_heads=30,
norm_eps=1e-05,
qk_norm=True,
cap_feat_dim=2560,
rope_theta=256.0,
t_scale=1000.0,
axes_dims=[32, 48, 48],
axes_lens=[1024, 512, 512],
)
# Determine safe dtype based on target device capabilities
target_device = TorchDevice.choose_torch_device()
model_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
# Filter out keys that don't belong to the ZImageTransformer2DModel.
# Merged checkpoints (e.g. LoRA-baked models) may bundle text encoder weights
# (text_encoders.*) or other non-transformer keys alongside the transformer weights.
# Also filter FP8 quantization metadata (scale_weight, scaled_fp8).
valid_prefixes = (
"all_x_embedder.",
"all_final_layer.",
"layers.",
"noise_refiner.",
"context_refiner.",
"t_embedder.",
"cap_embedder.",
"rope_embedder.",
)
valid_exact = {"x_pad_token", "cap_pad_token"}
keys_to_remove = [
k
for k in sd.keys()
if not (k.startswith(valid_prefixes) or k in valid_exact)
or k.endswith(".scale_weight")
or k == "scaled_fp8"
]
for k in keys_to_remove:
del sd[k]
# Handle memory management and dtype conversion
new_sd_size = sum([ten.nelement() * model_dtype.itemsize for ten in sd.values()])
self._ram_cache.make_room(new_sd_size)
# Convert to target dtype
for k in sd.keys():
sd[k] = sd[k].to(model_dtype)
model.load_state_dict(sd, assign=True)
return model
@ModelLoaderRegistry.register(base=BaseModelType.ZImage, type=ModelType.Main, format=ModelFormat.GGUFQuantized)
class ZImageGGUFCheckpointModel(ModelLoader):
"""Class to load GGUF-quantized Z-Image transformer models."""
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Checkpoint_Config_Base):
raise ValueError("Only CheckpointConfigBase models are currently supported here.")
match submodel_type:
case SubModelType.Transformer:
return self._load_from_singlefile(config)
raise ValueError(
f"Only Transformer submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}"
)
def _load_from_singlefile(
self,
config: AnyModelConfig,
) -> AnyModel:
from diffusers import ZImageTransformer2DModel
if not isinstance(config, Main_GGUF_ZImage_Config):
raise TypeError(
f"Expected Main_GGUF_ZImage_Config, got {type(config).__name__}. Model configuration type mismatch."
)
model_path = Path(config.path)
# Determine safe dtype based on target device capabilities
target_device = TorchDevice.choose_torch_device()
compute_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
# Load the GGUF state dict
sd = gguf_sd_loader(model_path, compute_dtype=compute_dtype)
# Some Z-Image GGUF models have keys prefixed with "diffusion_model." or
# "model.diffusion_model." (ComfyUI-style format). Check if we need to strip this prefix.
prefix_to_strip = None
for prefix in ["model.diffusion_model.", "diffusion_model."]:
if any(k.startswith(prefix) for k in sd.keys() if isinstance(k, str)):
prefix_to_strip = prefix
break
if prefix_to_strip:
stripped_sd = {}
for key, value in sd.items():
if isinstance(key, str) and key.startswith(prefix_to_strip):
stripped_sd[key[len(prefix_to_strip) :]] = value
else:
stripped_sd[key] = value
sd = stripped_sd
# Convert GGUF format keys to diffusers format
sd = _convert_z_image_gguf_to_diffusers(sd)
# Create an empty model with the default Z-Image config
# Z-Image-Turbo uses these default parameters from diffusers
with accelerate.init_empty_weights():
model = ZImageTransformer2DModel(
all_patch_size=(2,),
all_f_patch_size=(1,),
in_channels=16,
dim=3840,
n_layers=30,
n_refiner_layers=2,
n_heads=30,
n_kv_heads=30,
norm_eps=1e-05,
qk_norm=True,
cap_feat_dim=2560,
rope_theta=256.0,
t_scale=1000.0,
axes_dims=[32, 48, 48],
axes_lens=[1024, 512, 512],
)
model.load_state_dict(sd, assign=True)
return model
@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.Qwen3Encoder, format=ModelFormat.Qwen3Encoder)
class Qwen3EncoderLoader(ModelLoader):
"""Class to load standalone Qwen3 Encoder models for Z-Image (directory format)."""
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Qwen3Encoder_Qwen3Encoder_Config):
raise ValueError("Only Qwen3Encoder_Qwen3Encoder_Config models are supported here.")
model_path = Path(config.path)
# Support both structures:
# 1. Full model: model_root/text_encoder/ and model_root/tokenizer/
# 2. Standalone download: model_root/ contains text_encoder files directly
text_encoder_path = model_path / "text_encoder"
tokenizer_path = model_path / "tokenizer"
# Check if this is a standalone text_encoder download (no nested text_encoder folder)
is_standalone = not text_encoder_path.exists() and (model_path / "config.json").exists()
if is_standalone:
text_encoder_path = model_path
tokenizer_path = model_path # Tokenizer files should also be in root
match submodel_type:
case SubModelType.Tokenizer:
# Use local_files_only=True to prevent network requests for validation
# The tokenizer files should already exist locally in the model directory
return AutoTokenizer.from_pretrained(tokenizer_path, local_files_only=True)
case SubModelType.TextEncoder:
# Determine safe dtype based on target device capabilities
target_device = TorchDevice.choose_torch_device()
model_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
# Use local_files_only=True to prevent network requests for validation
return Qwen3ForCausalLM.from_pretrained(
text_encoder_path,
torch_dtype=model_dtype,
low_cpu_mem_usage=True,
local_files_only=True,
)
raise ValueError(
f"Only Tokenizer and TextEncoder submodels are supported. Received: {submodel_type.value if submodel_type else 'None'}"
)
@ModelLoaderRegistry.register(base=BaseModelType.ZImage, type=ModelType.ControlNet, format=ModelFormat.Checkpoint)
class ZImageControlCheckpointModel(ModelLoader):
"""Class to load Z-Image Control adapter models from safetensors checkpoint.
Z-Image Control models are standalone adapters containing control layers
(control_layers, control_all_x_embedder, control_noise_refiner) that can be
combined with a base ZImageTransformer2DModel at runtime for spatial conditioning
(Canny, HED, Depth, Pose, MLSD).
"""
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Checkpoint_Config_Base):
raise ValueError("Only CheckpointConfigBase models are supported here.")
# ControlNet type models don't use submodel_type - load the adapter directly
return self._load_control_adapter(config)
def _load_control_adapter(
self,
config: AnyModelConfig,
) -> AnyModel:
from safetensors.torch import load_file
from invokeai.backend.z_image.z_image_control_adapter import ZImageControlAdapter
assert isinstance(config, ControlNet_Checkpoint_ZImage_Config)
model_path = Path(config.path)
# Load the safetensors state dict
sd = load_file(model_path)
# Determine number of control blocks from state dict
# Control blocks are named control_layers.0, control_layers.1, etc.
control_block_indices = set()
for key in sd.keys():
if key.startswith("control_layers."):
parts = key.split(".")
if len(parts) > 1 and parts[1].isdigit():
control_block_indices.add(int(parts[1]))
num_control_blocks = len(control_block_indices) if control_block_indices else 6
# Determine number of refiner layers from state dict
refiner_indices: set[int] = set()
for key in sd.keys():
if key.startswith("control_noise_refiner."):
parts = key.split(".")
if len(parts) > 1 and parts[1].isdigit():
refiner_indices.add(int(parts[1]))
n_refiner_layers = len(refiner_indices) if refiner_indices else 2
# Determine control_in_dim from embedder weight shape
# control_in_dim = weight.shape[1] / (f_patch_size * patch_size * patch_size)
# For patch_size=2, f_patch_size=1: control_in_dim = weight.shape[1] / 4
control_in_dim = 16 # Default for V1
embedder_key = "control_all_x_embedder.2-1.weight"
if embedder_key in sd:
weight_shape = sd[embedder_key].shape
# weight_shape[1] = f_patch_size * patch_size * patch_size * control_in_dim
control_in_dim = weight_shape[1] // 4 # 4 = 1 * 2 * 2
# Log detected configuration for debugging
from invokeai.backend.util.logging import InvokeAILogger
logger = InvokeAILogger.get_logger(self.__class__.__name__)
version = "V2.0" if control_in_dim > 16 else "V1"
logger.info(
f"Z-Image ControlNet detected: {version} "
f"(control_in_dim={control_in_dim}, num_control_blocks={num_control_blocks}, "
f"n_refiner_layers={n_refiner_layers})"
)
# Create an empty control adapter
dim = 3840
with accelerate.init_empty_weights():
model = ZImageControlAdapter(
num_control_blocks=num_control_blocks,
control_in_dim=control_in_dim,
all_patch_size=(2,),
all_f_patch_size=(1,),
dim=dim,
n_refiner_layers=n_refiner_layers,
n_heads=30,
n_kv_heads=30,
norm_eps=1e-05,
qk_norm=True,
)
# Load state dict with strict=False to handle missing keys like x_pad_token
# Some control adapters may not include x_pad_token in their checkpoint
missing_keys, unexpected_keys = model.load_state_dict(sd, assign=True, strict=False)
# Initialize x_pad_token if it was missing from the checkpoint
if "x_pad_token" in missing_keys:
import torch.nn as nn
model.x_pad_token = nn.Parameter(torch.empty(dim))
nn.init.normal_(model.x_pad_token, std=0.02)
return model
@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.Qwen3Encoder, format=ModelFormat.Checkpoint)
class Qwen3EncoderCheckpointLoader(ModelLoader):
"""Class to load single-file Qwen3 Encoder models for Z-Image (safetensors format)."""
# Default HuggingFace model to load tokenizer from when using single-file Qwen3 encoder
# Must be Qwen3 (not Qwen2.5) to match Z-Image's text encoder architecture and special tokens
DEFAULT_TOKENIZER_SOURCE = "Qwen/Qwen3-4B"
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Qwen3Encoder_Checkpoint_Config):
raise ValueError("Only Qwen3Encoder_Checkpoint_Config models are supported here.")
match submodel_type:
case SubModelType.TextEncoder:
return self._load_from_singlefile(config)
case SubModelType.Tokenizer:
# For single-file Qwen3, load tokenizer from HuggingFace
# Try local cache first to support offline usage after initial download
return self._load_tokenizer_with_offline_fallback()
raise ValueError(
f"Only TextEncoder and Tokenizer submodels are supported. Received: {submodel_type.value if submodel_type else 'None'}"
)
def _load_tokenizer_with_offline_fallback(self) -> AnyModel:
"""Load tokenizer with local_files_only fallback for offline support.
First tries to load from local cache (offline), falling back to network download
if the tokenizer hasn't been cached yet. This ensures offline operation after
the initial download.
"""
try:
# Try loading from local cache first (supports offline usage)
return AutoTokenizer.from_pretrained(self.DEFAULT_TOKENIZER_SOURCE, local_files_only=True)
except OSError:
# Not in cache yet, download from HuggingFace
return AutoTokenizer.from_pretrained(self.DEFAULT_TOKENIZER_SOURCE)
def _load_from_singlefile(
self,
config: AnyModelConfig,
) -> AnyModel:
from safetensors.torch import load_file
from transformers import Qwen3Config, Qwen3ForCausalLM
from invokeai.backend.util.logging import InvokeAILogger
logger = InvokeAILogger.get_logger(self.__class__.__name__)
if not isinstance(config, Qwen3Encoder_Checkpoint_Config):
raise TypeError(
f"Expected Qwen3Encoder_Checkpoint_Config, got {type(config).__name__}. "
"Model configuration type mismatch."
)
model_path = Path(config.path)
# Determine safe dtype based on target device capabilities
target_device = TorchDevice.choose_torch_device()
model_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
# Load the state dict from safetensors file
sd = load_file(model_path)
# Handle ComfyUI quantized checkpoints
# ComfyUI stores quantized weights with accompanying scale factors:
# - layer.weight: quantized data (FP8)
# - layer.weight_scale: scale factor (FP32 scalar)
# Dequantization formula: dequantized = weight.to(dtype) * weight_scale
# Reference: https://github.com/Comfy-Org/ComfyUI/blob/master/QUANTIZATION.md
original_key_count = len(sd)
weight_scale_keys = [k for k in sd.keys() if k.endswith(".weight_scale")]
dequantized_count = 0
for scale_key in weight_scale_keys:
# Get the corresponding weight key (remove "_scale" suffix)
weight_key = scale_key.replace(".weight_scale", ".weight")
if weight_key in sd:
weight = sd[weight_key]
scale = sd[scale_key]
# Dequantize: convert to float and multiply by scale
# Handle block-wise quantization (e.g., FP4 with block_size=8)
# where scale has shape [weight_dim / block_size, ...]
# Note: Float8 types (e.g., float8_e4m3fn) require .float() instead of .to(torch.float32)
# as PyTorch doesn't support direct type promotion for Float8 types
weight_float = weight.float()
scale = scale.float()
if scale.shape != weight_float.shape and scale.numel() > 1:
# Block-wise quantization: need to expand scale to match weight shape
# Find which dimension differs and repeat scale along that dimension
for dim in range(len(weight_float.shape)):
if dim < len(scale.shape) and scale.shape[dim] != weight_float.shape[dim]:
block_size = weight_float.shape[dim] // scale.shape[dim]
if block_size > 1:
# Repeat scale along this dimension to match weight shape
scale = scale.repeat_interleave(block_size, dim=dim)
sd[weight_key] = weight_float * scale
dequantized_count += 1
if dequantized_count > 0:
logger.info(f"Dequantized {dequantized_count} ComfyUI quantized weights")
# Filter out ComfyUI quantization metadata keys (comfy_quant, weight_scale)
# These are no longer needed after dequantization
comfy_metadata_keys = [k for k in sd.keys() if "comfy_quant" in k or "weight_scale" in k]
for k in comfy_metadata_keys:
del sd[k]
if comfy_metadata_keys:
logger.info(f"Filtered out {len(comfy_metadata_keys)} ComfyUI quantization metadata keys")
logger.info(f"Loaded state dict with {len(sd)} keys (originally {original_key_count})")
# Count the number of layers by looking at layer keys
layer_count = 0
for key in sd.keys():
if isinstance(key, str) and key.startswith("model.layers."):
parts = key.split(".")
if len(parts) > 2:
try:
layer_idx = int(parts[2])
layer_count = max(layer_count, layer_idx + 1)
except ValueError:
pass
# Get vocab size from embed_tokens weight shape
embed_weight = sd.get("model.embed_tokens.weight")
if embed_weight is None:
raise ValueError("Could not find model.embed_tokens.weight in state dict")
vocab_size = embed_weight.shape[0]
embed_hidden_size = embed_weight.shape[1]
# Detect model variant based on embed_tokens hidden size and layer count
# FLUX 2 Klein / Z-Image uses Qwen3 configurations from ComfyUI:
# Reference: https://github.com/comfyanonymous/ComfyUI/blob/master/comfy/text_encoders/llama.py
# - Qwen3-4B: hidden_size=2560, 36 layers, 32 heads, 8 KV heads, intermediate=9728
# - Qwen3-8B: hidden_size=4096, 36 layers, 32 heads, 8 KV heads, intermediate=12288
if embed_hidden_size == 2560 and layer_count == 36:
# Qwen3-4B variant (FLUX 2 Klein / Z-Image)
logger.info("Detected Qwen3-4B variant (FLUX 2 Klein / Z-Image)")
hidden_size = 2560
num_attention_heads = 32
num_kv_heads = 8
intermediate_size = 9728
head_dim = 128
max_position_embeddings = 40960
elif embed_hidden_size == 4096 and layer_count == 36:
# Qwen3-8B variant
logger.info("Detected Qwen3-8B variant")
hidden_size = 4096
num_attention_heads = 32
num_kv_heads = 8
intermediate_size = 12288
head_dim = 128
max_position_embeddings = 40960
else:
# Unknown variant - try to detect from weights
logger.warning(
f"Unknown Qwen3 variant: embed_hidden_size={embed_hidden_size}, layers={layer_count}. "
"Attempting to detect configuration from weights..."
)
q_proj_weight = sd.get("model.layers.0.self_attn.q_proj.weight")
k_proj_weight = sd.get("model.layers.0.self_attn.k_proj.weight")
gate_proj_weight = sd.get("model.layers.0.mlp.gate_proj.weight")
if q_proj_weight is None or k_proj_weight is None or gate_proj_weight is None:
raise ValueError("Could not find attention/mlp weights to determine configuration")
hidden_size = embed_hidden_size
head_dim = 128
num_attention_heads = q_proj_weight.shape[0] // head_dim
num_kv_heads = k_proj_weight.shape[0] // head_dim
intermediate_size = gate_proj_weight.shape[0]
max_position_embeddings = 40960
logger.info(
f"Qwen3 config: hidden_size={hidden_size}, layers={layer_count}, "
f"heads={num_attention_heads}, kv_heads={num_kv_heads}, intermediate={intermediate_size}"
)
# Create Qwen3 config
qwen_config = Qwen3Config(
vocab_size=vocab_size,
hidden_size=hidden_size,
intermediate_size=intermediate_size,
num_hidden_layers=layer_count,
num_attention_heads=num_attention_heads,
num_key_value_heads=num_kv_heads,
head_dim=head_dim,
max_position_embeddings=max_position_embeddings,
rms_norm_eps=1e-6,
tie_word_embeddings=True,
rope_theta=1000000.0,
use_sliding_window=False,
attention_bias=False,
attention_dropout=0.0,
torch_dtype=model_dtype,
)
# Handle memory management
new_sd_size = sum([ten.nelement() * model_dtype.itemsize for ten in sd.values()])
self._ram_cache.make_room(new_sd_size)
# Convert to target dtype
for k in sd.keys():
sd[k] = sd[k].to(model_dtype)
# Use Qwen3ForCausalLM - the correct model class for Z-Image text encoder
# Use init_empty_weights for fast model creation, then load weights with assign=True
with accelerate.init_empty_weights():
model = Qwen3ForCausalLM(qwen_config)
# Load the text model weights from checkpoint
# assign=True replaces meta tensors with real ones from state dict
model.load_state_dict(sd, strict=False, assign=True)
# Handle tied weights: lm_head shares weight with embed_tokens when tie_word_embeddings=True
# This doesn't work automatically with init_empty_weights, so we need to manually tie them
if qwen_config.tie_word_embeddings:
model.tie_weights()
# Re-initialize any remaining meta tensor buffers (like rotary embeddings inv_freq)
# These are computed from config, not loaded from checkpoint
for name, buffer in list(model.named_buffers()):
if buffer.is_meta:
# Get parent module and buffer name
parts = name.rsplit(".", 1)
if len(parts) == 2:
parent = model.get_submodule(parts[0])
buffer_name = parts[1]
else:
parent = model
buffer_name = name
# Re-initialize the buffer based on expected shape and dtype
# For rotary embeddings, this is inv_freq which is computed from config
if buffer_name == "inv_freq":
# Compute inv_freq from config (same logic as Qwen3RotaryEmbedding.__init__)
# NB: transformers 5.x moved rope_theta into the rope_parameters/rope_scaling dict
rope_params = (
getattr(qwen_config, "rope_parameters", None)
or getattr(qwen_config, "rope_scaling", None)
or {}
)
base = rope_params.get("rope_theta") or getattr(qwen_config, "rope_theta", 1000000.0)
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
parent.register_buffer(buffer_name, inv_freq.to(model_dtype), persistent=False)
else:
# For other buffers, log warning
logger.warning(f"Re-initializing unknown meta buffer: {name}")
return model
@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.Qwen3Encoder, format=ModelFormat.GGUFQuantized)
class Qwen3EncoderGGUFLoader(ModelLoader):
"""Class to load GGUF-quantized Qwen3 Encoder models for Z-Image."""
# Default HuggingFace model to load tokenizer from when using GGUF Qwen3 encoder
# Must be Qwen3 (not Qwen2.5) to match Z-Image's text encoder architecture and special tokens
DEFAULT_TOKENIZER_SOURCE = "Qwen/Qwen3-4B"
def _load_model(
self,
config: AnyModelConfig,
submodel_type: Optional[SubModelType] = None,
) -> AnyModel:
if not isinstance(config, Qwen3Encoder_GGUF_Config):
raise ValueError("Only Qwen3Encoder_GGUF_Config models are supported here.")
match submodel_type:
case SubModelType.TextEncoder:
return self._load_from_gguf(config)
case SubModelType.Tokenizer:
# For GGUF Qwen3, load tokenizer from HuggingFace
# Try local cache first to support offline usage after initial download
return self._load_tokenizer_with_offline_fallback()
raise ValueError(
f"Only TextEncoder and Tokenizer submodels are supported. Received: {submodel_type.value if submodel_type else 'None'}"
)
def _load_tokenizer_with_offline_fallback(self) -> AnyModel:
"""Load tokenizer with local_files_only fallback for offline support.
First tries to load from local cache (offline), falling back to network download
if the tokenizer hasn't been cached yet. This ensures offline operation after
the initial download.
"""
try:
# Try loading from local cache first (supports offline usage)
return AutoTokenizer.from_pretrained(self.DEFAULT_TOKENIZER_SOURCE, local_files_only=True)
except OSError:
# Not in cache yet, download from HuggingFace
return AutoTokenizer.from_pretrained(self.DEFAULT_TOKENIZER_SOURCE)
def _load_from_gguf(
self,
config: AnyModelConfig,
) -> AnyModel:
from transformers import Qwen3Config, Qwen3ForCausalLM
from invokeai.backend.util.logging import InvokeAILogger
logger = InvokeAILogger.get_logger(self.__class__.__name__)
if not isinstance(config, Qwen3Encoder_GGUF_Config):
raise TypeError(
f"Expected Qwen3Encoder_GGUF_Config, got {type(config).__name__}. Model configuration type mismatch."
)
model_path = Path(config.path)
# Determine safe dtype based on target device capabilities
target_device = TorchDevice.choose_torch_device()
compute_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
# Load the GGUF state dict - this returns GGMLTensor wrappers (on CPU)
# We keep them on CPU and let the model cache system handle GPU movement
# via apply_custom_layers_to_model() and the partial loading cache
sd = gguf_sd_loader(model_path, compute_dtype=compute_dtype)
# Check if this is llama.cpp format (blk.X.) or PyTorch format (model.layers.X.)
is_llamacpp_format = any(k.startswith("blk.") for k in sd.keys() if isinstance(k, str))
if is_llamacpp_format:
logger.info("Detected llama.cpp GGUF format, converting keys to PyTorch format")
sd = self._convert_llamacpp_to_pytorch(sd)
# Determine Qwen model configuration from state dict
# Count the number of layers by looking at layer keys
layer_count = 0
for key in sd.keys():
if isinstance(key, str) and key.startswith("model.layers."):
parts = key.split(".")
if len(parts) > 2:
try:
layer_idx = int(parts[2])
layer_count = max(layer_count, layer_idx + 1)
except ValueError:
pass
# Get vocab size from embed_tokens weight shape
embed_weight = sd.get("model.embed_tokens.weight")
if embed_weight is None:
raise ValueError("Could not find model.embed_tokens.weight in state dict")
# Handle GGMLTensor shape access
embed_shape = embed_weight.shape if hasattr(embed_weight, "shape") else embed_weight.tensor_shape
if len(embed_shape) != 2:
raise ValueError(
f"Expected 2D embed_tokens weight tensor, got shape {embed_shape}. "
"The model file may be corrupted or incompatible."
)
vocab_size = embed_shape[0]
# Detect attention configuration from layer weights
# IMPORTANT: Use layer 1 (not layer 0) because some models like FLUX 2 Klein have a special
# first layer with different dimensions (input projection layer) while the rest of the
# transformer layers have a different hidden_size. Using a middle layer ensures we get
# the representative hidden_size for the bulk of the model.
# Fall back to layer 0 if layer 1 doesn't exist.
q_proj_weight = sd.get("model.layers.1.self_attn.q_proj.weight")
k_proj_weight = sd.get("model.layers.1.self_attn.k_proj.weight")
gate_proj_weight = sd.get("model.layers.1.mlp.gate_proj.weight")
# Fall back to layer 0 if layer 1 doesn't exist (single-layer model edge case)
if q_proj_weight is None:
q_proj_weight = sd.get("model.layers.0.self_attn.q_proj.weight")
k_proj_weight = sd.get("model.layers.0.self_attn.k_proj.weight")
gate_proj_weight = sd.get("model.layers.0.mlp.gate_proj.weight")
if q_proj_weight is None or k_proj_weight is None or gate_proj_weight is None:
raise ValueError("Could not find attention/mlp weights in state dict to determine configuration")
# Handle GGMLTensor shape access
q_shape = q_proj_weight.shape if hasattr(q_proj_weight, "shape") else q_proj_weight.tensor_shape
k_shape = k_proj_weight.shape if hasattr(k_proj_weight, "shape") else k_proj_weight.tensor_shape
gate_shape = gate_proj_weight.shape if hasattr(gate_proj_weight, "shape") else gate_proj_weight.tensor_shape
# Calculate dimensions from actual weights
# IMPORTANT: Use hidden_size from k_proj input dimension (not q_proj or embed_tokens).
# Some models (like FLUX 2 Klein) have unusual architectures where:
# - embed_tokens has a larger dimension (e.g., 2560)
# - q_proj may have a larger input dimension for query expansion
# - k_proj/v_proj have the actual transformer hidden_size (e.g., 1280)
# Using k_proj ensures we get the correct internal hidden_size.
head_dim = 128 # Standard head dimension for Qwen3 models
hidden_size = k_shape[1] # Use k_proj input dim as the hidden_size
num_attention_heads = q_shape[0] // head_dim
num_kv_heads = k_shape[0] // head_dim
intermediate_size = gate_shape[0]
logger.info(
f"Qwen3 GGUF Encoder config detected: layers={layer_count}, hidden={hidden_size}, "
f"heads={num_attention_heads}, kv_heads={num_kv_heads}, intermediate={intermediate_size}, "
f"head_dim={head_dim}"
)
# Create Qwen3 config
qwen_config = Qwen3Config(
vocab_size=vocab_size,
hidden_size=hidden_size,
intermediate_size=intermediate_size,
num_hidden_layers=layer_count,
num_attention_heads=num_attention_heads,
num_key_value_heads=num_kv_heads,
head_dim=head_dim,
max_position_embeddings=40960,
rms_norm_eps=1e-6,
tie_word_embeddings=True,
rope_theta=1000000.0,
use_sliding_window=False,
attention_bias=False,
attention_dropout=0.0,
torch_dtype=compute_dtype,
)
# Use Qwen3ForCausalLM with empty weights, then load GGUF tensors
with accelerate.init_empty_weights():
model = Qwen3ForCausalLM(qwen_config)
# Load the GGUF weights with assign=True
# GGMLTensor wrappers will be dequantized on-the-fly during inference
model.load_state_dict(sd, strict=False, assign=True)
# Dequantize embed_tokens weight - embedding lookups require indexed access
# which quantized GGMLTensors can't efficiently provide (no __torch_dispatch__ for embedding)
from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
embed_tokens_weight = model.model.embed_tokens.weight
if isinstance(embed_tokens_weight, GGMLTensor):
dequantized = embed_tokens_weight.get_dequantized_tensor()
model.model.embed_tokens.weight = torch.nn.Parameter(dequantized, requires_grad=False)
logger.info("Dequantized embed_tokens weight for embedding lookups")
# Handle tied weights - llama.cpp GGUF doesn't include lm_head.weight when embeddings are tied
# So we need to manually tie them after loading
if qwen_config.tie_word_embeddings:
# Check if lm_head.weight is still a meta tensor (wasn't in GGUF state dict)
if model.lm_head.weight.is_meta:
# Directly assign embed_tokens weight to lm_head (now dequantized)
model.lm_head.weight = model.model.embed_tokens.weight
logger.info("Tied lm_head.weight to embed_tokens.weight (GGUF tied embeddings)")
else:
# If lm_head.weight was loaded, use standard tie_weights
model.tie_weights()
# Re-initialize any remaining meta tensor buffers (like rotary embeddings inv_freq)
for name, buffer in list(model.named_buffers()):
if buffer.is_meta:
parts = name.rsplit(".", 1)
if len(parts) == 2:
parent = model.get_submodule(parts[0])
buffer_name = parts[1]
else:
parent = model
buffer_name = name
if buffer_name == "inv_freq":
# Compute inv_freq from config - keep on CPU, cache system will move to GPU as needed
# NB: transformers 5.x moved rope_theta into the rope_parameters/rope_scaling dict
rope_params = (
getattr(qwen_config, "rope_parameters", None)
or getattr(qwen_config, "rope_scaling", None)
or {}
)
base = rope_params.get("rope_theta") or getattr(qwen_config, "rope_theta", 1000000.0)
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
parent.register_buffer(buffer_name, inv_freq.to(dtype=compute_dtype), persistent=False)
else:
logger.warning(f"Re-initializing unknown meta buffer: {name}")
# Final check: ensure no meta tensors remain in parameters
meta_params = [(name, p) for name, p in model.named_parameters() if p.is_meta]
if meta_params:
meta_names = [name for name, _ in meta_params]
raise RuntimeError(
f"Failed to load all parameters from GGUF. The following remain as meta tensors: {meta_names}. "
"This may indicate missing keys in the GGUF file or a key mapping issue."
)
return model
def _convert_llamacpp_to_pytorch(self, sd: dict[str, Any]) -> dict[str, Any]:
"""Convert llama.cpp GGUF keys to PyTorch/HuggingFace format for Qwen models.
llama.cpp format:
- blk.X.attn_q.weight -> model.layers.X.self_attn.q_proj.weight
- blk.X.attn_k.weight -> model.layers.X.self_attn.k_proj.weight
- blk.X.attn_v.weight -> model.layers.X.self_attn.v_proj.weight
- blk.X.attn_output.weight -> model.layers.X.self_attn.o_proj.weight
- blk.X.attn_q_norm.weight -> model.layers.X.self_attn.q_norm.weight (Qwen3 QK norm)
- blk.X.attn_k_norm.weight -> model.layers.X.self_attn.k_norm.weight (Qwen3 QK norm)
- blk.X.ffn_gate.weight -> model.layers.X.mlp.gate_proj.weight
- blk.X.ffn_up.weight -> model.layers.X.mlp.up_proj.weight
- blk.X.ffn_down.weight -> model.layers.X.mlp.down_proj.weight
- blk.X.attn_norm.weight -> model.layers.X.input_layernorm.weight
- blk.X.ffn_norm.weight -> model.layers.X.post_attention_layernorm.weight
- token_embd.weight -> model.embed_tokens.weight
- output_norm.weight -> model.norm.weight
- output.weight -> lm_head.weight (if not tied)
"""
import re
key_map = {
"attn_q": "self_attn.q_proj",
"attn_k": "self_attn.k_proj",
"attn_v": "self_attn.v_proj",
"attn_output": "self_attn.o_proj",
"attn_q_norm": "self_attn.q_norm", # Qwen3 QK normalization
"attn_k_norm": "self_attn.k_norm", # Qwen3 QK normalization
"ffn_gate": "mlp.gate_proj",
"ffn_up": "mlp.up_proj",
"ffn_down": "mlp.down_proj",
"attn_norm": "input_layernorm",
"ffn_norm": "post_attention_layernorm",
}
new_sd: dict[str, Any] = {}
blk_pattern = re.compile(r"^blk\.(\d+)\.(.+)$")
for key, value in sd.items():
if not isinstance(key, str):
new_sd[key] = value
continue
# Handle block layers
match = blk_pattern.match(key)
if match:
layer_idx = match.group(1)
rest = match.group(2)
# Split rest into component and suffix (e.g., "attn_q.weight" -> "attn_q", "weight")
parts = rest.split(".", 1)
component = parts[0]
suffix = parts[1] if len(parts) > 1 else ""
if component in key_map:
new_component = key_map[component]
new_key = f"model.layers.{layer_idx}.{new_component}"
if suffix:
new_key += f".{suffix}"
new_sd[new_key] = value
else:
# Unknown component, keep as-is with model.layers prefix
new_sd[f"model.layers.{layer_idx}.{rest}"] = value
continue
# Handle non-block keys
if key == "token_embd.weight":
new_sd["model.embed_tokens.weight"] = value
elif key == "output_norm.weight":
new_sd["model.norm.weight"] = value
elif key == "output.weight":
new_sd["lm_head.weight"] = value
else:
# Keep other keys as-is
new_sd[key] = value
return new_sd