This commit is contained in:
Jaret Burkett
2026-08-27 18:18:31 -06:00
parent 45886f01b2
commit 520d96aac3
27 changed files with 333 additions and 253 deletions

View File

@@ -271,7 +271,7 @@ class AnimaModel(BaseModel):
flush()
self.print_and_status_update("Quantizing Text Conditioner")
quantize(text_conditioner, weights=get_qtype(self.model_config.qtype))
quantize(text_conditioner, weights=get_qtype(self.model_config.qtype_te))
freeze(text_conditioner)
flush()

View File

@@ -11,10 +11,9 @@ from toolkit.basic import flush
# from toolkit.pixel_shuffle_encoder import AutoencoderPixelMixer
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import CustomFlowMatchEulerDiscreteScheduler
from toolkit.dequantize import patch_dequantization_on_save
from toolkit.accelerator import unwrap_model
from optimum.quanto import freeze, QTensor
from toolkit.util.quantize import quantize, get_qtype
from optimum.quanto import QTensor
from toolkit.util.quantize import quantize, quantize_model
from .pipeline import ChromaPipeline, prepare_latent_image_ids
from einops import rearrange, repeat
import random
@@ -67,6 +66,9 @@ class FakeCLIP(torch.nn.Module):
class ChromaModel(BaseModel):
arch = "chroma"
def get_transformer_block_names(self):
return ["double_blocks", "single_blocks"]
def __init__(
self,
device,
@@ -163,13 +165,10 @@ class ChromaModel(BaseModel):
transformer.config.num_single_layers = transformer.params.depth_single_blocks
if self.model_config.quantize:
# patch the state dict method
patch_dequantization_on_save(transformer)
quantization_type = get_qtype(self.model_config.qtype)
# block-streaming quantize (handles dequant-on-save patching,
# excludes, ARA, and quantize_kwargs)
self.print_and_status_update("Quantizing transformer")
quantize(transformer, weights=quantization_type,
**self.model_config.quantize_kwargs)
freeze(transformer)
quantize_model(self, transformer)
transformer.to(self.device_torch)
else:
transformer.to(self.device_torch, dtype=dtype)
@@ -389,19 +388,16 @@ class ChromaModel(BaseModel):
return self.text_encoder[1].encoder.block[0].layer[0].SelfAttention.q.weight.requires_grad
def save_model(self, output_path, meta, save_dtype):
# comfy-format single-file save via the mixin (chroma's class keys ARE
# the original layout); handles torchao/Ostris dequant, not just quanto
if not output_path.endswith(".safetensors"):
output_path = output_path + ".safetensors"
# only save the unet
output_path = output_path + ".safetensors"
transformer: Chroma = unwrap_model(self.model)
state_dict = transformer.state_dict()
save_dict = {}
for k, v in state_dict.items():
if isinstance(v, QTensor):
v = v.dequantize()
save_dict[k] = v.clone().to('cpu', dtype=save_dtype)
meta = get_meta_for_safetensors(meta, name='chroma')
save_file(save_dict, output_path, metadata=meta)
transformer.save_model(
output_path,
dtype=save_dtype,
metadata=get_meta_for_safetensors(meta, name="chroma"),
)
def get_loss_target(self, *args, **kwargs):
noise = kwargs.get('noise')

View File

@@ -10,10 +10,9 @@ from toolkit.basic import flush
# from toolkit.pixel_shuffle_encoder import AutoencoderPixelMixer
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import CustomFlowMatchEulerDiscreteScheduler
from toolkit.dequantize import patch_dequantization_on_save
from toolkit.accelerator import unwrap_model
from optimum.quanto import freeze, QTensor
from toolkit.util.quantize import quantize, get_qtype
from optimum.quanto import QTensor
from toolkit.util.quantize import quantize, quantize_model
from .pipeline import ChromaPipeline, prepare_latent_image_ids
from einops import rearrange, repeat
import random
@@ -37,36 +36,16 @@ scheduler_config = {
"use_dynamic_shifting": True
}
class FakeConfig:
# for diffusers compatability
def __init__(self):
self.attention_head_dim = 128
self.guidance_embeds = True
self.in_channels = 64
self.joint_attention_dim = 4096
self.num_attention_heads = 24
self.num_layers = 19
self.num_single_layers = 38
self.patch_size = 1
class FakeCLIP(torch.nn.Module):
def __init__(self, device='cuda'):
super().__init__()
self.dtype = torch.bfloat16
# the pipeline derives its execution device from this attribute;
# nn.Module.to() does not update it
self.device = device
self.text_model = None
self.tokenizer = None
self.model_max_length = 77
def forward(self, *args, **kwargs):
return torch.zeros(1, 1, 1).to(self.device)
# shared with the base chroma model (identical stubs)
from .chroma_model import FakeCLIP, FakeConfig
class ChromaRadianceModel(BaseModel):
arch = "chroma_radiance"
def get_transformer_block_names(self):
return ["double_blocks", "single_blocks"]
def __init__(
self,
device,
@@ -165,13 +144,10 @@ class ChromaRadianceModel(BaseModel):
transformer.config.num_single_layers = transformer.params.depth_single_blocks
if self.model_config.quantize:
# patch the state dict method
patch_dequantization_on_save(transformer)
quantization_type = get_qtype(self.model_config.qtype)
# block-streaming quantize (handles dequant-on-save patching,
# excludes, ARA, and quantize_kwargs)
self.print_and_status_update("Quantizing transformer")
quantize(transformer, weights=quantization_type,
**self.model_config.quantize_kwargs)
freeze(transformer)
quantize_model(self, transformer)
transformer.to(self.device_torch)
else:
transformer.to(self.device_torch, dtype=dtype)
@@ -373,19 +349,16 @@ class ChromaRadianceModel(BaseModel):
return False
def save_model(self, output_path, meta, save_dtype):
# comfy-format single-file save via the mixin (chroma's class keys ARE
# the original layout); handles torchao/Ostris dequant, not just quanto
if not output_path.endswith(".safetensors"):
output_path = output_path + ".safetensors"
# only save the unet
output_path = output_path + ".safetensors"
transformer: Chroma = unwrap_model(self.model)
state_dict = transformer.state_dict()
save_dict = {}
for k, v in state_dict.items():
if isinstance(v, QTensor):
v = v.dequantize()
save_dict[k] = v.clone().to('cpu', dtype=save_dtype)
meta = get_meta_for_safetensors(meta, name='chroma')
save_file(save_dict, output_path, metadata=meta)
transformer.save_model(
output_path,
dtype=save_dtype,
metadata=get_meta_for_safetensors(meta, name="chroma"),
)
def get_loss_target(self, *args, **kwargs):
noise = kwargs.get('noise')

View File

@@ -11,10 +11,9 @@ from toolkit.models.v2.vae.autoencoder_kl import KLVAE
from toolkit.basic import flush
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import CustomFlowMatchEulerDiscreteScheduler
from toolkit.dequantize import patch_dequantization_on_save
from toolkit.accelerator import unwrap_model
from optimum.quanto import freeze, QTensor
from toolkit.util.quantize import quantize, get_qtype
from optimum.quanto import QTensor
from toolkit.util.quantize import quantize, quantize_model
from .src import FLitePipeline, DiT
if TYPE_CHECKING:
@@ -34,6 +33,9 @@ scheduler_config = {
class FLiteModel(BaseModel):
arch = "f-lite"
def get_transformer_block_names(self):
return ["blocks"]
def __init__(
self,
device,
@@ -79,13 +81,10 @@ class FLiteModel(BaseModel):
transformer.to(self.quantize_device, dtype=dtype)
if self.model_config.quantize:
# patch the state dict method
patch_dequantization_on_save(transformer)
quantization_type = get_qtype(self.model_config.qtype)
# block-streaming quantize (handles dequant-on-save patching,
# excludes, ARA, and quantize_kwargs)
self.print_and_status_update("Quantizing transformer")
quantize(transformer, weights=quantization_type,
**self.model_config.quantize_kwargs)
freeze(transformer)
quantize_model(self, transformer)
transformer.to(self.device_torch)
else:
transformer.to(self.device_torch, dtype=dtype)

View File

@@ -111,7 +111,7 @@ class Flux2Model(BaseModel):
if self.model_config.quantize_te:
self.print_and_status_update("Quantizing Mistral")
quantize(text_encoder, weights=get_qtype(self.model_config.qtype))
quantize(text_encoder, weights=get_qtype(self.model_config.qtype_te))
freeze(text_encoder)
flush()
@@ -136,9 +136,6 @@ class Flux2Model(BaseModel):
transformer_path = model_path
self.print_and_status_update("Loading transformer")
with torch.device("meta"):
transformer = Flux2(self.get_flux2_params())
# use local path if provided
if os.path.exists(os.path.join(transformer_path, self.flux2_te_filename)):
transformer_path = os.path.join(transformer_path, self.flux2_te_filename)
@@ -152,12 +149,9 @@ class Flux2Model(BaseModel):
)
transformer_state_dict = load_file(transformer_path, device="cpu")
# cast to dtype
for key in transformer_state_dict:
transformer_state_dict[key] = transformer_state_dict[key].to(dtype)
transformer.load_state_dict(transformer_state_dict, assign=True)
transformer = Flux2.load_from_state_dict(
transformer_state_dict, dtype, config=self.get_flux2_params()
)
if self.model_config.quantize:
# patch the state dict method

View File

@@ -1,4 +1,6 @@
import torch
from toolkit.models.v2._mixin import OstrisModelMixin
from einops import rearrange
from torch import Tensor, nn
import torch.utils.checkpoint as ckpt
@@ -54,7 +56,7 @@ class FakeConfig:
self.patch_size = 1
class Flux2(nn.Module):
class Flux2(nn.Module, OstrisModelMixin):
def __init__(self, params: Flux2Params):
super().__init__()
self.config = FakeConfig()

View File

@@ -17,11 +17,10 @@ from toolkit.basic import flush
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import CustomFlowMatchEulerDiscreteScheduler
from toolkit.models.flux import add_model_gpu_splitter_to_flux, bypass_flux_guidance, restore_flux_guidance
from toolkit.dequantize import patch_dequantization_on_save
from toolkit.accelerator import get_accelerator, unwrap_model
from optimum.quanto import freeze, QTensor
from optimum.quanto import QTensor
from toolkit.util.mask import generate_random_mask, random_dialate_mask
from toolkit.util.quantize import quantize, get_qtype
from toolkit.util.quantize import quantize, quantize_model
from einops import rearrange, repeat
import random
import torch.nn.functional as F
@@ -44,6 +43,9 @@ scheduler_config = {
class FluxKontextModel(BaseModel):
arch = "flux_kontext"
def get_transformer_block_names(self):
return ["transformer_blocks", "single_transformer_blocks"]
def __init__(
self,
device,
@@ -95,13 +97,10 @@ class FluxKontextModel(BaseModel):
transformer.to(self.quantize_device, dtype=dtype)
if self.model_config.quantize:
# patch the state dict method
patch_dequantization_on_save(transformer)
quantization_type = get_qtype(self.model_config.qtype)
# block-streaming quantize (handles dequant-on-save patching,
# excludes, ARA, and quantize_kwargs)
self.print_and_status_update("Quantizing transformer")
quantize(transformer, weights=quantization_type,
**self.model_config.quantize_kwargs)
freeze(transformer)
quantize_model(self, transformer)
transformer.to(self.device_torch)
else:
transformer.to(self.device_torch, dtype=dtype)

View File

@@ -238,9 +238,6 @@ class Ideogram4Model(BaseModel):
self.print_and_status_update("Loading transformer")
transformer_config = Ideogram4Config()
with torch.device("meta"):
transformer = Ideogram4Transformer2DModel(transformer_config)
self.print_and_status_update(" - fetching transformer weights")
state_dict = _load_component_state_dict(
base, "transformer", "diffusion_pytorch_model"
@@ -250,7 +247,9 @@ class Ideogram4Model(BaseModel):
state_dict, dtype, self.device_torch, self.model_config.low_vram
)
self.print_and_status_update(" - loading transformer state dict")
transformer.load_state_dict(state_dict, assign=True)
transformer = Ideogram4Transformer2DModel.load_from_state_dict(
state_dict, dtype, config=transformer_config
)
del state_dict
flush()

View File

@@ -12,6 +12,8 @@ import math
from dataclasses import dataclass
import torch
from toolkit.models.v2._mixin import OstrisModelMixin
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.checkpoint import checkpoint
@@ -355,7 +357,7 @@ class Ideogram4FinalLayer(nn.Module):
return self.linear(self.norm_final(x) * scale)
class Ideogram4Transformer2DModel(nn.Module):
class Ideogram4Transformer2DModel(nn.Module, OstrisModelMixin):
"""Ideogram 4 flow-matching transformer."""
def __init__(self, config: Ideogram4Config) -> None:

View File

@@ -217,21 +217,15 @@ class Krea2Model(QwenImageVAEHolderMixin, BaseModel):
mmdit_kwargs.update(self.model_config.model_kwargs.get("mmdit_config", {}))
config = SingleMMDiTConfig(**mmdit_kwargs)
# Build on meta, then materialize straight from the checkpoint.
with torch.device("meta"):
transformer = SingleStreamDiT(config)
self.print_and_status_update(" - fetching transformer weights")
state_dict = _load_mmdit_state_dict(
self.model_config.name_or_path,
self.model_config.model_kwargs.get("checkpoint_filename", None),
)
state_dict = {
k: (v.to(dtype) if v.is_floating_point() else v)
for k, v in state_dict.items()
}
self.print_and_status_update(" - loading transformer state dict")
transformer.load_state_dict(state_dict, strict=True, assign=True)
transformer = SingleStreamDiT.load_from_state_dict(
state_dict, dtype, config=config
)
del state_dict
flush()
return transformer

View File

@@ -20,6 +20,8 @@ import math
from dataclasses import dataclass
import torch
from toolkit.models.v2._mixin import OstrisModelMixin
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
@@ -408,7 +410,7 @@ class SingleStreamBlock(nn.Module):
return x
class SingleStreamDiT(nn.Module):
class SingleStreamDiT(nn.Module, OstrisModelMixin):
def __init__(self, config: SingleMMDiTConfig):
super().__init__()
self.config = config

View File

@@ -1279,7 +1279,7 @@ class LTX25Model(LTX2Model):
from the state dict. Works unchanged for bf16 checkpoints, where no
quant markers exist and everything strict-loads."""
from toolkit.util.comfy_quant_import import import_comfy_quantized_layers
from toolkit.util.ostris_quant import OstrisLinear
from toolkit.models.v2._mixin import OstrisModelMixin
state_dict, num_quantized = import_comfy_quantized_layers(
module, state_dict, orig_dtype=self.torch_dtype
@@ -1288,32 +1288,8 @@ class LTX25Model(LTX2Model):
self.print_and_status_update(
f" - attached {num_quantized} pre-quantized ConvRot layers to {name}"
)
result = module.load_state_dict(state_dict, assign=True, strict=False)
# quantized linears hold their weight as backend buffers and had their
# bias assigned by the importer, so both report as "missing" here
quantized_param_keys = set()
for mod_name, m in module.named_modules():
if isinstance(m, OstrisLinear):
quantized_param_keys.add(f"{mod_name}.weight")
if m.bias is not None:
quantized_param_keys.add(f"{mod_name}.bias")
bad_missing = [k for k in result.missing_keys if k not in quantized_param_keys]
if bad_missing or result.unexpected_keys:
raise ValueError(
f"LTX-2.5 {name} load mismatch: missing {bad_missing[:8]}, "
f"unexpected {result.unexpected_keys[:8]}"
)
# nothing may be left on the meta device (e.g. a bias the importer
# should have filled)
leftover_meta = [
param_name
for param_name, p in module.named_parameters()
if p.is_meta
]
if leftover_meta:
raise ValueError(
f"LTX-2.5 {name} load left meta parameters: {leftover_meta[:8]}"
)
# whitelist for quantized weights + leftover-meta check
OstrisModelMixin._load_state_dict_with_quantized(module, state_dict)
return num_quantized
def _load_gemma4_text_encoder(self, te_path: str, te_state_dict: dict):

View File

@@ -178,22 +178,13 @@ class MageFlowModel(BaseModel):
structure.update(self.model_config.model_kwargs.get("transformer_config", {}))
params = MageFlowParams(**structure)
# Build on meta, then materialize straight from the checkpoint.
with torch.device("meta"):
transformer = MageFlow(params)
self.print_and_status_update(" - fetching transformer weights")
state_dict = load_file(
self._get_model_file("transformer/diffusion_pytorch_model.safetensors")
)
state_dict = {
k: (v.to(dtype) if v.is_floating_point() else v)
for k, v in state_dict.items()
}
self.print_and_status_update(" - loading transformer state dict")
transformer.load_state_dict(state_dict, strict=True, assign=True)
# The RoPE tables are plain tensors (not buffers/params), so the meta
# init left them unmaterialized — rebuild them for real.
transformer = MageFlow.load_from_state_dict(state_dict, dtype, config=params)
# rebuild the plain-tensor RoPE tables at full precision
transformer.reset_rope()
del state_dict
flush()

View File

@@ -28,6 +28,8 @@ from dataclasses import dataclass
from typing import Any
import torch
from toolkit.models.v2._mixin import OstrisModelMixin
import torch.nn as nn
from torch import Tensor
from torch.utils.checkpoint import checkpoint
@@ -681,7 +683,7 @@ class MageFlowParams:
patch_size: int = 1
class MageFlow(nn.Module):
class MageFlow(nn.Module, OstrisModelMixin):
def __init__(self, params: MageFlowParams):
super().__init__()
self.params = params

View File

@@ -373,46 +373,12 @@ class MinimaxH3Model(BaseModel):
self.invert_assistant_lora = False
def _load_transformer(self) -> MiniMaxH3Transformer:
dtype = self.torch_dtype
dit_path = self._resolve_comfy_file(self._dit_component())
self.print_and_status_update(f"Loading transformer from {dit_path}")
state_dict = load_file(dit_path)
params = MiniMaxH3TransformerParams()
table = state_dict.get("adaln_t_table", None)
if table is not None:
# pruned checkpoint: factored timestep table instead of the MLP
params.adaln_t_table_size = table.shape[0]
params.time_embed_dim = table.shape[1]
with torch.device("meta"):
transformer = MiniMaxH3Transformer(params)
# attach the pre-quantized (int8 ConvRot) linears onto the toolkit's
# quantization backends; the rest loads at its stored precision (the
# checkpoint's bf16/fp16/fp32 mix is deliberate)
state_dict, num_quantized = import_comfy_quantized_layers(
transformer, state_dict, orig_dtype=dtype
)
if num_quantized:
self.print_and_status_update(
f" - attached {num_quantized} pre-quantized ConvRot layers"
)
result = transformer.load_state_dict(state_dict, assign=True, strict=False)
quantized_weight_keys = {
f"{name}.weight"
for name, m in transformer.named_modules()
if isinstance(m, OstrisLinear)
}
bad_missing = [k for k in result.missing_keys if k not in quantized_weight_keys]
if bad_missing or result.unexpected_keys:
raise ValueError(
f"MiniMax-H3 transformer load mismatch: missing {bad_missing[:8]}, "
f"unexpected {result.unexpected_keys[:8]}"
)
del state_dict
flush()
return transformer
# the mixin single-file path: config sniffed from the checkpoint
# (adaln_t_table), pre-quantized ConvRot linears attached, everything
# else at its stored precision (the bf16/fp16/fp32 mix is deliberate)
return MiniMaxH3Transformer.load_model(dit_path, dtype=self.torch_dtype)
def _load_text_encoder(self):
from accelerate import init_empty_weights

View File

@@ -30,6 +30,8 @@ import math
from dataclasses import dataclass
from typing import Optional, Tuple
from toolkit.models.v2._mixin import OstrisModelMixin
import torch
import torch.nn.functional as F
from torch import nn
@@ -356,7 +358,31 @@ class MiniMaxH3FinalLayer(nn.Module):
return self.video_out(h), self.audio_out(h)
class MiniMaxH3Transformer(nn.Module):
class MiniMaxH3Transformer(nn.Module, OstrisModelMixin):
# comfy checkpoints carry a deliberate bf16/fp16/fp32 mix
aitk_cast_on_load = False
@classmethod
def aitk_config_from_state_dict(cls, state_dict):
params = MiniMaxH3TransformerParams()
table = state_dict.get("adaln_t_table", None)
if table is not None:
# pruned checkpoint: factored timestep table instead of the MLP
params.adaln_t_table_size = table.shape[0]
params.time_embed_dim = table.shape[1]
return params
@classmethod
def aitk_from_config(cls, config):
from accelerate import init_empty_weights
with init_empty_weights(include_buffers=False):
return cls(config)
@classmethod
def get_transformer_block_names(cls):
return ["blocks"]
def __init__(self, params: Optional[MiniMaxH3TransformerParams] = None):
super().__init__()
if params is None:

View File

@@ -13,7 +13,7 @@ from toolkit.samplers.custom_flowmatch_sampler import (
)
from toolkit.accelerator import unwrap_model
from optimum.quanto import freeze
from toolkit.util.quantize import quantize, get_qtype
from toolkit.util.quantize import quantize, get_qtype, quantize_model
from .src.pipelines.omnigen2.pipeline_omnigen2 import OmniGen2Pipeline
from .src.models.transformers import OmniGen2Transformer2DModel
from .src.models.transformers.repo import OmniGen2RotaryPosEmbed
@@ -105,9 +105,9 @@ class OmniGen2Model(BaseModel):
if self.model_config.quantize:
self.print_and_status_update("Quantizing transformer")
quantization_type = get_qtype(self.model_config.qtype)
quantize(transformer, weights=quantization_type)
freeze(transformer)
quantize_model(self, transformer)
if not self.low_vram:
transformer.to(self.device_torch)
if self.low_vram:
# unload it for now

View File

@@ -22,6 +22,7 @@ from toolkit.models.v2.diffusion_models.wan import WanTransformer3DModel
from toolkit.data_transfer_object.data_loader import DataLoaderBatchDTO
from torchvision.transforms import functional as TF
from toolkit.metadata import get_meta_for_safetensors
from toolkit.models.wan21.wan21 import Wan21
from .wan22_5b_model import (
scheduler_config,
@@ -431,20 +432,20 @@ class Wan2214bModel(Wan21):
return False
def save_model(self, output_path, meta, save_dtype):
# comfy-format single-file saves, one per DiT (comfy convention:
# separate high/low noise files)
transformer_combo: DualWanTransformer3DModel = unwrap_model(self.model)
transformer_combo.transformer_1.save_pretrained(
save_directory=os.path.join(output_path, "transformer"),
safe_serialization=True,
base = output_path
if base.endswith(".safetensors"):
base = base[: -len(".safetensors")]
metadata = get_meta_for_safetensors(meta, name=self.arch)
transformer_combo.transformer_1.save_model(
f"{base}_high_noise.safetensors", dtype=save_dtype, metadata=metadata
)
transformer_combo.transformer_2.save_pretrained(
save_directory=os.path.join(output_path, "transformer_2"),
safe_serialization=True,
transformer_combo.transformer_2.save_model(
f"{base}_low_noise.safetensors", dtype=save_dtype, metadata=metadata
)
meta_path = os.path.join(output_path, "aitk_meta.yaml")
with open(meta_path, "w") as f:
yaml.dump(meta, f)
def save_lora(
self,
state_dict: Dict[str, torch.Tensor],

View File

@@ -92,10 +92,6 @@ class ZetaChromaModel(BaseModel):
transformer_state_dict = load_file(transformer_path, device="cpu")
# cast to dtype
for key in transformer_state_dict:
transformer_state_dict[key] = transformer_state_dict[key].to(dtype)
# Auto-detect use_x0 from checkpoint
use_x0 = "__x0__" in transformer_state_dict
@@ -107,10 +103,9 @@ class ZetaChromaModel(BaseModel):
use_x0=use_x0,
)
with torch.device("meta"):
transformer = ZImageDCT(model_params)
transformer.load_state_dict(transformer_state_dict, assign=True)
transformer = ZImageDCT.load_from_state_dict(
transformer_state_dict, dtype, config=model_params
)
del transformer_state_dict
transformer.to(self.quantize_device, dtype=dtype)

View File

@@ -6,6 +6,8 @@ from typing import List, Optional
import math
import torch
from toolkit.models.v2._mixin import OstrisModelMixin
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
@@ -449,7 +451,7 @@ class SimpleMLPAdaLN(nn.Module):
return self.final_layer(x)
class ZImageDCT(nn.Module):
class ZImageDCT(nn.Module, OstrisModelMixin):
def __init__(self, params: ZImageDCTParams):
super().__init__()
self.config = FakeConfig()

View File

@@ -207,8 +207,11 @@ loads via diffusers. Nothing about sources or outputs changes yet. Suggested ord
variants inherit
- [x] nucleus_image — `v2/diffusion_models/nucleus_image.py`, TE stanza
collapsed to prepare_text_encoder
- [ ] krea2, ideogram4, mageflow — TE/VAE migrated; their custom local DiT
classes still to be rebased onto the mixin
- [x] krea2, ideogram4, mageflow — DiTs on the mixin via the `config=`
passthrough (holder builds config from model_kwargs/holder state, mixin
does build/markers/whitelist/casting; plain nn.Module classes now work
with the default builder). krea2 + ideogram4 verified by harness;
mageflow untestable while its repo 404s
- [x] chroma, chroma_radiance — both vendored Chroma classes now carry
`OstrisModelMixin` with the block-count sniff moved into a new
`aitk_config_from_state_dict` hook (mixin now supports checkpoint-derived
@@ -217,14 +220,23 @@ loads via diffusers. Nothing about sources or outputs changes yet. Suggested ord
depends on holder state (patch_size), not the checkpoint
- [x] flux_kontext — `v2/diffusion_models/flux.py` (FluxTransformer2DModel);
whole model now loads through v2 (transformer, T5, CLIP, KLVAE)
- [ ] flux2 — TE/VAE partially migrated (flux2_kl); custom DiT still local.
krea2/mageflow/ideogram4/zeta_chroma DiTs stay model-specific: their
configs come from model_kwargs / holder state, so the mixin adds nothing
until the comfy-weights flip (Phase 2)
- [ ] minimax_h3 (+ ref2va), ltx2 family — already on the shared resolver +
comfy_quant_import; the full mixin port waits for Phase 2, when the
mixin's single-file precision policy (stored-precision loading, fp32-key
protection) is settled to match their deliberate behavior
- [x] flux2 + zeta_chroma DiTs on the mixin via `config=` passthrough
(flux2_klein_4b verified by harness). Every arch's DiT now loads
through the mixin except: ltx2 family (one-file→two-modules split,
helper delegated), anima (diffusers modular pipeline), ace_step
(bundled single-file loader), z_image_l2p's local subclass, and the
grandfathered legacy stable_diffusion_model archs
- [x] minimax_h3 (+ ref2va) transformer ported to the mixin: config sniffed
from the checkpoint via aitk_config_from_state_dict (adaln_t_table),
marker attach + stored-precision load via the new
`aitk_cast_on_load = False` knob. Verified on the real pruned convrot
file: 200 ConvRot linears, pruned table detected, fp32/fp16/bf16 mix
preserved, no meta leftovers. Its TE stays custom (50-layer truncation
+ key_map). ltx2.5's `_load_quantized_module` now delegates to the
mixin's whitelist/meta helper (~30 lines deleted); its full port is
blocked on the one-comfy-file → transformer+connectors split, which
doesn't fit the per-class single-file shape — revisit with the live
server's component model
- [x] wan21 / wan22 family — `v2/diffusion_models/wan.py`
(WanTransformer3DModel, both wan22 dual loads included) +
`v2/text_encoders/umt5.py` (UMT5TextEncoder + PatchedT5Tokenizer;
@@ -307,9 +319,16 @@ Decisions:
fp8 weight / scalar scale_weight, e.g. every wan *_fp8_scaled file):
imports onto the float8 backend; scale_input (activation quant) is
dropped, matmuls run dequantized.
- [x] wan comfy-format saves: `convert_state_dict_on_save` inverts diffusers'
rename table (base/t2v/i2v; vace/animate excluded — their reverse
mappings collide). Round-trip verified on both real comfy files
(exact; the 2.1 file's legacy model.diffusion_model. prefix drops per
the modern convention) and with real weights (load → save → 825-key
original-layout file → reload bit-equal). wan21 + wan22_5b save one
comfy file; wan22_14b saves the comfy-standard _high_noise/_low_noise
pair instead of two diffusers folders.
- [ ] Wire remaining archs' candidate lists (chroma/others as their key
conversions are verified per file); wan comfy-format save needs the
inverse key mapping (defer with the other save flips)
conversions are verified per file)
- [x] Fused-layout quantized attach for diffusers-split classes:
`split_fused_quantized_keys` / `fuse_split_quantized_keys`
(comfy_quant_import) do exact out-dim row surgery on quantized comfy
@@ -333,6 +352,14 @@ Decisions:
bit-exact fused qkv weights/scales/markers, and the reload's quantized
forward is bit-identical — toolkit saves are byte-compatible with
ComfyUI.
- [x] chroma + chroma_radiance saves flipped to the mixin (their class keys
ARE the original layout) — also fixes their quanto-only dequant bug
(torchao/Ostris weights now dequantize on save). Tiny-model round trip
verified. Save flips so far: z_image, qwen_image, wan21, wan22_5b,
wan22_14b (dual files), chroma ×2.
- [ ] flux_kontext comfy wiring deferred: its Comfy-Org repo ships a single
legacy-fp8 file in fused BFL layout — needs the flux fused-split
conversion (split_fused_quantized_keys pattern + BFL↔diffusers maps)
- [ ] Flip the remaining per-arch `save_model` overrides as each arch's
save-side key conversion is in place
- [ ] Publish/verify comfy repacks per model as they flip
@@ -354,6 +381,13 @@ Decisions:
- [x] Missing weights skip rather than fail: default is HF_HUB_OFFLINE=1 and
hub/file errors classify as SKIP; `--allow-download` opts into fetching.
(GPU + local-weights test, not CI-portable.)
- [x] Final certification sweep (2026-08-27, post-polish): 14/14 runnable
archs PASS — comfy-source loads (zimage convrot8, qwen fp8, wan ×2 +
fp8 umt5 TE), all ported holder-config DiTs, and the migrated
quantize_model paths (chroma, flux_kontext, f_light block-streamed) in
one run; mageflow remains the upstream 404 skip. One regression caught
and fixed: qwen's _load_single_file override needed the new config
kwarg.
- [x] Full sweep run 2026-08-27: 14/15 PASS (zimage, qwen_image, krea2,
boogu_image, ernie_image, ideogram4, hidream_o1, anima, wan21, wan22_5b,
chroma, flux_kontext, flux2_klein_4b, ltx2.3 — the quantized 22B ltx
@@ -378,20 +412,28 @@ Decisions:
## TODO / look at later
- [ ] Quantize-path consolidation quirks: `quantize_kwargs` is honored only by the
raw `quantize()` call sites and silently dropped by `quantize_model()`; the
ARA path inside `quantize_model` hardcodes `uint8`. Decide the unified
behavior when consolidating.
- [x] Quantize consolidation: `quantize_model` now honors `quantize_kwargs`
(blocks + extras) and tolerates missing block names; the chroma ×2,
flux_kontext, f_light, omnigen2 raw-quantize sites migrated onto it
(gaining block streaming, excludes, ARA, dequant-on-save patching) with
holder block names added. Remaining raw sites are legacy/extension
(flex2, cogview4, stable_diffusion_model). The ARA uint8 hardcode
stands — revisit if a non-uint8 ARA base is ever wanted.
- [x] Last known `qtype_te` bugs fixed (flux2's Mistral TE, anima's
text_conditioner) — 9/9 sites from the survey now correct outside the
grandfathered legacy monolith (cogview4/legacy SD remain as-is).
- [x] wan comfy-TE resolved for real: UMT5TextEncoder carries comfy
candidates (fp8_e4m3fn_scaled via the legacy importer, fp16), files
already in transformers key layout (spiece blob dropped, tied
embed_tokens materialized). Verified: wan21 samples with the local
comfy fp8 TE. The loaders/umt5.py `comfy_files` param stays as a
no-op shim for old callers.
- [ ] Registry hardening: error (don't fall back to SD1) on unknown arch; lazy
- [x] Registry hardening: unknown archs now raise with the known-arch list
(legacy monolith archs whitelisted via LEGACY_ARCHS). Still open: lazy
per-arch imports; single source of truth shared with the UI's
`options.tsx` model list.
- [ ] Fake/stub components: consolidate on `toolkit/models/FakeVAE.py` /
`toolkit/unloader.py`, delete local copies.
- [x] Stub dedup where identical: chroma_radiance imports FakeCLIP/FakeConfig
from chroma_model. The other Fake* copies (hidream_o1, flux2, zeta)
carry model-specific values — left in place.
- [ ] Vendored upstream code (hidream/src, omnigen2/src, ltx2 converter's private
comfy-quant parser): dedupe against toolkit utils where practical.

View File

@@ -67,6 +67,12 @@ class OstrisModelMixin:
aitk_processor_repo: Optional[str] = None
aitk_processor_subfolder: Optional[str] = None
# single-file loads without quant markers cast tensors to the requested
# dtype; classes whose checkpoints carry a deliberate precision mix (e.g.
# fp32 norms next to bf16 weights) set this False to load at stored
# precision instead
aitk_cast_on_load: bool = True
# ---- state set by the loader / quantizer ----
aitk_is_quantized: bool = False
aitk_qtype: Optional[str] = None
@@ -119,7 +125,10 @@ class OstrisModelMixin:
from accelerate import init_empty_weights
with init_empty_weights(include_buffers=False):
return cls.from_config(config)
if hasattr(cls, "from_config"):
return cls.from_config(config)
# plain nn.Module classes take their params/config object directly
return cls(config)
# ------------------------------------------------------------------
# loading
@@ -135,6 +144,7 @@ class OstrisModelMixin:
quantize_device: Optional[torch.device] = None,
exclude_quant_modules: Optional[List[str]] = None,
config_path: Optional[str] = None,
config=None,
subfolder: Optional[str] = None,
use_comfy_weights: bool = True,
**kwargs,
@@ -180,7 +190,11 @@ class OstrisModelMixin:
if name_or_path.endswith(".safetensors"):
file_path = cls._resolve_single_file(name_or_path)
model = cls._load_single_file(
file_path, dtype=dtype, config_path=config_path, subfolder=subfolder
file_path,
dtype=dtype,
config_path=config_path,
config=config,
subfolder=subfolder,
)
else:
if os.path.isdir(name_or_path):
@@ -243,11 +257,16 @@ class OstrisModelMixin:
file_path: str,
dtype: torch.dtype,
config_path: Optional[str] = None,
config=None,
subfolder: Optional[str] = None,
):
state_dict = load_file(file_path)
return cls.load_from_state_dict(
state_dict, dtype, config_path=config_path, subfolder=subfolder
state_dict,
dtype,
config_path=config_path,
config=config,
subfolder=subfolder,
)
@classmethod
@@ -263,16 +282,20 @@ class OstrisModelMixin:
state_dict: Dict[str, torch.Tensor],
dtype: torch.dtype,
config_path: Optional[str] = None,
config=None,
subfolder: Optional[str] = None,
):
"""Build the model and load an already-read single-file state dict
(the tail of the single-file path; also callable directly for
checkpoints read from non-safetensors sources)."""
checkpoints read from non-safetensors sources). ``config``, when
given, is used directly (for models whose config comes from the
holder, e.g. model_kwargs-driven archs)."""
state_dict = cls.convert_state_dict_on_load(state_dict)
has_quant_markers = "scaled_fp8" in state_dict or any(
k.endswith(".comfy_quant") for k in state_dict
)
config = cls.aitk_config_from_state_dict(state_dict)
if config is None:
config = cls.aitk_config_from_state_dict(state_dict)
if config is None:
config = cls._load_single_file_config(config_path, subfolder)
model = cls.aitk_from_config(config)
@@ -288,11 +311,15 @@ class OstrisModelMixin:
)
cls._load_state_dict_with_quantized(model, state_dict)
model.aitk_is_quantized = True
else:
elif cls.aitk_cast_on_load:
for key, value in state_dict.items():
state_dict[key] = value.to(dtype=dtype)
if value.is_floating_point():
state_dict[key] = value.to(dtype=dtype)
model.load_state_dict(state_dict, assign=True)
model.to(dtype=dtype)
else:
# stored-precision load (the checkpoint's dtype mix is deliberate)
model.load_state_dict(state_dict, assign=True)
del state_dict
flush()
return model

View File

@@ -29,7 +29,9 @@ class QwenImageTransformer2DModel(
return ["transformer_blocks"]
@classmethod
def _load_single_file(cls, file_path, dtype, config_path=None, subfolder=None):
def _load_single_file(
cls, file_path, dtype, config_path=None, config=None, subfolder=None
):
from safetensors import safe_open
with safe_open(file_path, framework="pt") as f:
@@ -38,7 +40,11 @@ class QwenImageTransformer2DModel(
# comfy prequantized checkpoint (diffusers key layout): the mixin
# path attaches the quantized layers
return super()._load_single_file(
file_path, dtype, config_path=config_path, subfolder=subfolder
file_path,
dtype,
config_path=config_path,
config=config,
subfolder=subfolder,
)
# other single-file checkpoints carry diffusers or original key
# layouts; diffusers' single-file machinery owns that conversion

View File

@@ -84,3 +84,54 @@ class WanTransformer3DModel(DiffusersWanTransformer3DModel, OstrisModelMixin):
)
return convert_wan_transformer_to_diffusers(dict(state_dict))
# inverse of diffusers' rename table, for the base/t2v/i2v variants (no
# vace/animate: their extra mappings collide in reverse). Order matters:
# longest/most-specific first, and the norm2/norm3 swap uses a placeholder.
_SAVE_RENAMES = [
("condition_embedder.time_embedder.linear_1", "time_embedding.0"),
("condition_embedder.time_embedder.linear_2", "time_embedding.2"),
("condition_embedder.text_embedder.linear_1", "text_embedding.0"),
("condition_embedder.text_embedder.linear_2", "text_embedding.2"),
("condition_embedder.time_proj", "time_projection.1"),
("condition_embedder.image_embedder.norm1", "img_emb.proj.0"),
("condition_embedder.image_embedder.ff.net.0.proj", "img_emb.proj.1"),
("condition_embedder.image_embedder.ff.net.2", "img_emb.proj.3"),
("condition_embedder.image_embedder.norm2", "img_emb.proj.4"),
("ffn.net.0.proj", "ffn.0"),
("ffn.net.2", "ffn.2"),
(".norm_added_k.", ".norm_k_img."),
(".add_k_proj.", ".k_img."),
(".add_v_proj.", ".v_img."),
(".to_out.0.", ".o."),
(".to_q.", ".q."),
(".to_k.", ".k."),
(".to_v.", ".v."),
("attn2", "cross_attn"),
("attn1", "self_attn"),
# norm2 <-> norm3 swap back
("norm3", "norm__placeholder"),
("norm2", "norm3"),
("norm__placeholder", "norm2"),
("proj_out", "head.head"),
]
@classmethod
def convert_state_dict_on_save(cls, state_dict):
"""Diffusers layout back to the original/comfy wan key layout
(rename-only, so quantized weight/scale/marker keys ride along)."""
if any(".self_attn." in k or ".cross_attn." in k for k in state_dict):
return state_dict # already original layout
new_sd = {}
for key, value in state_dict.items():
k = key
# scale_shift_table: blocks keep the name as `modulation`, the
# top-level one belongs to the output head
if k == "scale_shift_table":
k = "head.modulation"
elif k.endswith(".scale_shift_table"):
k = k[: -len("scale_shift_table")] + "modulation"
for src, dst in cls._SAVE_RENAMES:
k = k.replace(src, dst)
new_sd[k] = value
return new_sd

View File

@@ -44,6 +44,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
from toolkit.models.wan21.wan_lora_convert import convert_to_diffusers, convert_to_original
from toolkit.util.quantize import quantize_model
from toolkit.models.v2.text_encoders.umt5 import UMT5TextEncoder
from toolkit.metadata import get_meta_for_safetensors
# for generation only?
scheduler_configUniPC = {
@@ -680,17 +681,16 @@ class Wan21(BaseModel):
return False
def save_model(self, output_path, meta, save_dtype):
# only save the unet
transformer: Wan21 = unwrap_model(self.model)
transformer.save_pretrained(
save_directory=os.path.join(output_path, 'transformer'),
safe_serialization=True,
# comfy-format single-file save (original wan key layout)
transformer = unwrap_model(self.model)
if not output_path.endswith(".safetensors"):
output_path += ".safetensors"
transformer.save_model(
output_path,
dtype=save_dtype,
metadata=get_meta_for_safetensors(meta, name=self.arch),
)
meta_path = os.path.join(output_path, 'aitk_meta.yaml')
with open(meta_path, 'w') as f:
yaml.dump(meta, f)
def get_loss_target(self, *args, **kwargs):
noise = kwargs.get('noise')
batch = kwargs.get('batch')

View File

@@ -41,10 +41,34 @@ def get_all_models() -> List[BaseModel]:
return all_model_classes
# archs the legacy StableDiffusion monolith still serves (see the arch
# normalization in toolkit/config_modules.py)
LEGACY_ARCHS = {
"sd1",
"sd2",
"sd3",
"sdxl",
"pixart",
"pixart_sigma",
"auraflow",
"flux",
"lumina2",
"vega",
"ssd",
}
def get_model_class(config: ModelConfig):
all_models = get_all_models()
for ModelClass in all_models:
if ModelClass.arch == config.arch:
return ModelClass
# default to the legacy model
return StableDiffusion
if config.arch in LEGACY_ARCHS:
return StableDiffusion
# a typo'd or unregistered arch used to silently fall back to SD1; error
# instead (a broken extension import also lands here — its error was
# printed during get_all_models)
known = sorted({m.arch for m in all_models if m.arch} | LEGACY_ARCHS)
raise ValueError(
f"Unknown model arch {config.arch!r}. Known archs: {', '.join(known)}"
)

View File

@@ -429,9 +429,10 @@ def quantize_model(
# quantize model the original way without an accuracy recovery adapter
# move and quantize only certain pieces at a time.
quantization_type = get_qtype(base_model.model_config.qtype)
quantize_kwargs = base_model.model_config.quantize_kwargs or {}
# all_blocks = list(model_to_quantize.transformer_blocks)
all_blocks: List[torch.nn.Module] = []
transformer_block_names = base_model.get_transformer_block_names()
transformer_block_names = base_model.get_transformer_block_names() or []
for name in transformer_block_names:
# name may be a dotted path for models that nest their blocks
# (e.g. hidream_o1's "model.language_model.layers").
@@ -456,7 +457,12 @@ def quantize_model(
block.to(base_model.device_torch, dtype=base_model.torch_dtype, non_blocking=True)
# exclude patterns with a leading wildcard (e.g. "*adaln_proj*")
# also apply inside blocks, where names are block-relative
quantize(block, weights=quantization_type, exclude=exclude_modules)
quantize(
block,
weights=quantization_type,
exclude=exclude_modules,
**quantize_kwargs,
)
freeze(block)
# NOT non_blocking: an async D2H allocates the cpu destination in pinned
# memory, which the caching host allocator keeps forever (with power-of-2
@@ -472,5 +478,10 @@ def quantize_model(
# device without having to move the transformer blocks to the device first
base_model.print_and_status_update(" - quantizing extras")
# model_to_quantize.to(base_model.device_torch, dtype=base_model.torch_dtype)
quantize(model_to_quantize, weights=quantization_type, exclude=exclude_modules)
quantize(
model_to_quantize,
weights=quantization_type,
exclude=exclude_modules,
**quantize_kwargs,
)
freeze(model_to_quantize)