refactor(anima): port anima_transformer to native_transformer

217-line bespoke loader collapses to a 40-line ANIMA_SPEC in
pipelines/anima/__init__.py (Cosmos converter + llm_adapter sibling +
Cosmos 1.0 forbidden marker).

Drop the class-keyed REGISTRY: Anima and raw Cosmos share
CosmosTransformer3DModel but need different specs. Specs pass via
explicit native_spec= kwarg; make_default_spec(cls) covers the
auto-converter case.
This commit is contained in:
CalamitousFelicitousness
2026-05-25 05:47:56 +01:00
parent 08ad6196d0
commit cec6d0dce5
5 changed files with 94 additions and 317 deletions
+40
View File
@@ -0,0 +1,40 @@
"""Anima pipeline package.
Exports :data:`ANIMA_SPEC` for use by :mod:`pipelines.model_anima` together
with :mod:`pipelines.native_transformer`. The spec captures the Anima-specific
knobs that differ from the native-loader defaults:
- The bundled ``llm_adapter`` sibling: Anima community files frequently inline
the custom AnimaLLMAdapter weights in the same safetensors as the
transformer. The resolved adapter class is supplied at load time via
``sibling_classes`` because AnimaLLMAdapter is loaded dynamically through
``trust_remote_code`` and is not available at import time.
- Cosmos 1.0 structural marker: any community file whose state dict contains
a Cosmos 1.0 nested key (``net.blocks.block1.*``) is rejected with a clear
error since Anima is Cosmos 2.0 only.
- All other knobs (prefixes, ``acceptable_missing`` buffers) use the defaults
from :mod:`pipelines.native_transformer`.
"""
import diffusers
from diffusers.loaders.single_file_utils import convert_cosmos_transformer_checkpoint_to_diffusers
from pipelines.native_transformer import TransformerSpec, SiblingSpec
ANIMA_SPEC = TransformerSpec(
cls=diffusers.CosmosTransformer3DModel,
converter=convert_cosmos_transformer_checkpoint_to_diffusers,
siblings={
'llm_adapter': SiblingSpec(
subfolder='llm_adapter',
inline_prefix='llm_adapter.',
),
},
forbidden_markers=(
(
'net.blocks.block1.blocks.0.block.attn.to_q.0.weight',
'unsupported Cosmos 1.0 structure',
),
),
)
-217
View File
@@ -1,217 +0,0 @@
"""Anima custom-transformer loader.
Called from :func:`pipelines.model_anima.load_anima` when the user has selected
a transformer file via the UNET dropdown (``shared.opts.sd_unet``). Reads the
safetensors directly, strips the BFL-style prefix, splits off the bundled
``llm_adapter.*`` keys, and routes the two halves into the diffusers
``CosmosTransformer3DModel`` and the remote ``AnimaLLMAdapter`` respectively.
The transformer half is run through diffusers'
``convert_cosmos_transformer_checkpoint_to_diffusers`` (Cosmos 2.0 branch),
whose rename table covers Anima's native key fragments exactly, so the
converted state dict drops cleanly into ``CosmosTransformer3DModel`` with no
ad-hoc renames needed here. The adapter half matches the base repo's
``llm_adapter/diffusion_pytorch_model.safetensors`` exactly, so it loads
as-is.
Supported input formats (safetensors only; GGUF and .pth are rejected early):
- Bare BFL keys: ``blocks.0.self_attn.q_proj.weight`` (e.g. ``rdbtAnima_v027``)
- ``model.diffusion_model.`` prefix (e.g. ``animaika_v35``)
- ``diffusion_model.`` prefix (ComfyUI-style export)
- ``net.`` prefix (NVIDIA/Cosmos native export, e.g. ``animayume_v04``)
Quantization: SDNQ (pre/post/auto) and ``layerwise_quantization`` are honored.
SDNQ pre-mode is applied post-load here because this path bypasses
``from_pretrained``, where ``quantization_config`` normally takes effect.
TensorRT (``NVIDIAModelOptConfig``) is not supported and is skipped with a
warning. GGUF would require a separate converter and is not supported.
"""
import os
import time
import diffusers
import huggingface_hub as hf
from modules import shared, devices, sd_models, model_quant, errors
from modules.logger import log
KNOWN_PREFIXES = ("model.diffusion_model.", "diffusion_model.", "net.")
ADAPTER_PREFIX = "llm_adapter."
COSMOS_1_MARKER = "net.blocks.block1.blocks.0.block.attn.to_q.0.weight"
# Buffer keys that CosmosTransformer3DModel creates at __init__ time and do
# not appear in trainer state dicts. Acceptable in the "missing" set.
ACCEPTABLE_MISSING = ("rope.", "pos_embedder.", "learnable_pos_embed.")
def load_custom_transformer(repo_id, local_file, diffusers_load_config, adapter_cls):
"""Load a custom Anima transformer (and optional bundled adapter) from a safetensors file.
Returns ``(transformer, llm_adapter_or_none)``. If the file does not bundle
an adapter, the second element is ``None`` and the caller should fall back
to the base repo's adapter via ``AnimaLLMAdapter.from_pretrained``.
Raises on any hard failure (prefix mix, shape mismatch, missing configs).
"""
t0 = time.time()
if not local_file.lower().endswith('.safetensors'):
raise ValueError(f'Load model: type=Anima custom transformer requires .safetensors, got "{local_file}"')
# from_config + load_state_dict does not consume load_args (device_map,
# torch_dtype, etc.); dtype is applied via explicit .to() below. Only
# quant_type is read from this call.
_, quant_args = model_quant.get_dit_args(
diffusers_load_config, module='Model', device_map=True, allow_quant=True,
)
quant_type = model_quant.get_quant_type(quant_args)
transformer_cfg = fetch_component_config(repo_id, 'transformer/config.json')
adapter_cfg = fetch_component_config(repo_id, 'llm_adapter/config.json')
state_dict = sd_models.read_state_dict(local_file, what='transformer')
state_dict = strip_prefix(state_dict)
transformer_sd, adapter_sd = partition_adapter(state_dict)
del state_dict
if COSMOS_1_MARKER in transformer_sd:
raise ValueError(f'Load model: type=Anima custom transformer has unsupported Cosmos 1.0 structure (file="{local_file}")')
log.info(f'Load model: type=Anima custom="{os.path.basename(local_file)}" transformer_keys={len(transformer_sd)} adapter_keys={len(adapter_sd)}')
transformer = build_transformer(transformer_sd, transformer_cfg, quant_args, quant_type)
del transformer_sd
devices.torch_gc()
if adapter_sd:
llm_adapter = build_adapter(adapter_sd, adapter_cfg, adapter_cls)
else:
log.info('Load model: type=Anima custom transformer has no bundled adapter, caller will load from base repo')
llm_adapter = None
sd_models.allow_post_quant = False # transformer already quantized above
devices.torch_gc()
log.debug(f'Load model: type=Anima custom transformer time={time.time()-t0:.2f}')
return transformer, llm_adapter
def fetch_component_config(repo_id, relative_path):
"""Download and parse a component config.json from the base repo."""
try:
local = hf.hf_hub_download(repo_id, filename=relative_path, cache_dir=shared.opts.diffusers_dir)
except Exception as e:
raise RuntimeError(f'Load model: type=Anima failed to download {relative_path} from repo="{repo_id}": {e}') from e
return shared.readfile(local, as_type='dict')
def strip_prefix(state_dict):
"""Detect and uniformly strip the BFL-style prefix from all keys.
Supported prefixes (longest first, so ``model.diffusion_model.`` beats ``diffusion_model.``):
``model.diffusion_model.``, ``diffusion_model.``, or no prefix. Raises
ValueError if some keys match the dominant prefix and others do not,
since mixed prefixes indicate a malformed file.
"""
counts = {p: sum(1 for k in state_dict if k.startswith(p)) for p in KNOWN_PREFIXES}
total = len(state_dict)
dominant = max(counts, key=counts.get)
if counts[dominant] == 0:
log.debug('Load model: type=Anima custom transformer prefix=bare')
return state_dict
if counts[dominant] != total:
raise ValueError(
f'Load model: type=Anima custom transformer has mixed prefixes '
f'(total={total} {dominant}={counts[dominant]})'
)
log.debug(f'Load model: type=Anima custom transformer prefix="{dominant}"')
offset = len(dominant)
return {k[offset:]: v for k, v in state_dict.items()}
def partition_adapter(state_dict):
"""Split into (transformer_sd, adapter_sd) by the ``llm_adapter.`` prefix."""
transformer_sd = {}
adapter_sd = {}
for key, value in state_dict.items():
if key.startswith(ADAPTER_PREFIX):
adapter_sd[key[len(ADAPTER_PREFIX):]] = value
else:
transformer_sd[key] = value
return transformer_sd, adapter_sd
def build_transformer(transformer_sd, transformer_cfg, quant_args, quant_type):
"""Convert, instantiate, load, dtype-cast, quantize, and (if offloading) move to CPU."""
from diffusers.loaders.single_file_utils import convert_cosmos_transformer_checkpoint_to_diffusers
try:
converted = convert_cosmos_transformer_checkpoint_to_diffusers(transformer_sd)
transformer = diffusers.CosmosTransformer3DModel.from_config(transformer_cfg)
missing, unexpected = transformer.load_state_dict(converted, strict=False)
validate_state_dict_load('transformer', missing, unexpected)
del converted
devices.torch_gc()
transformer = transformer.to(dtype=devices.dtype)
except Exception as e:
log.error(f'Load model: type=Anima transformer load failed: {e}')
errors.display(e, 'Load')
raise
apply_quant(transformer, quant_type)
if shared.opts.diffusers_offload_mode != 'none':
sd_models.move_model(transformer, devices.cpu)
if not hasattr(transformer, 'quantization_config'):
if hasattr(transformer, 'config') and hasattr(transformer.config, 'quantization_config'):
transformer.quantization_config = transformer.config.quantization_config
elif (quant_type is not None) and (quant_args.get('quantization_config', None) is not None):
transformer.quantization_config = quant_args.get('quantization_config', None)
return transformer
def build_adapter(adapter_sd, adapter_cfg, adapter_cls):
"""Instantiate AnimaLLMAdapter from the base repo config and load bundled weights."""
try:
adapter = adapter_cls.from_config(adapter_cfg)
missing, unexpected = adapter.load_state_dict(adapter_sd, strict=False)
validate_state_dict_load('adapter', missing, unexpected)
adapter = adapter.to(dtype=devices.dtype)
except Exception as e:
log.error(f'Load model: type=Anima adapter load failed: {e}')
errors.display(e, 'Load')
raise
if shared.opts.diffusers_offload_mode != 'none':
sd_models.move_model(adapter, devices.cpu)
return adapter
def validate_state_dict_load(component, missing, unexpected):
"""Raise ValueError if load_state_dict produced unexpected keys or non-buffer missing keys."""
if unexpected:
sample = ', '.join(unexpected[:5])
raise ValueError(f'Load model: type=Anima {component} has {len(unexpected)} unexpected keys (sample: {sample})')
hard_missing = [k for k in missing if not any(k.startswith(p) for p in ACCEPTABLE_MISSING)]
if hard_missing:
sample = ', '.join(hard_missing[:5])
raise ValueError(f'Load model: type=Anima {component} missing {len(hard_missing)} required keys (sample: {sample})')
if missing:
log.debug(f'Load model: type=Anima {component} ignored {len(missing)} buffer-only missing keys')
def apply_quant(transformer, quant_type):
"""Apply SDNQ / layerwise quantization to the bare transformer.
SDNQ 'pre' and 'auto' would normally route through ``quantization_config``
at ``from_pretrained`` time; since we bypass that boundary, we call the
per-module quant path directly. SDNQ 'post' and ``layerwise_quantization``
go through ``do_post_load_quant`` as usual.
"""
if quant_type == 'NVIDIAModelOptConfig':
log.warning('Load model: type=Anima quant=TRT not supported on custom transformer path, skipping')
elif quant_type == 'SDNQConfig':
if shared.opts.sdnq_quantize_mode == 'pre':
log.info('Load model: type=Anima quant=SDNQ pre-mode applied post-load on custom transformer path')
model_quant.sdnq_quantize_model(transformer, op='transformer')
# allow=False avoids double-applying SDNQ in auto mode (applied directly
# above); post mode fires regardless of allow, and layerwise always fires.
model_quant.do_post_load_quant(transformer, allow=False)
+13 -25
View File
@@ -16,37 +16,25 @@ def _import_from_file(module_name, file_path):
return mod
def resolve_custom_transformer_path():
"""Return an absolute path if the user selected a transformer in the UNET
dropdown and the file is resolvable, else ``None``.
"""
sel = shared.opts.sd_unet
if sel is None or sel in ('Default', 'None'):
return None
from modules import sd_unet
if sel not in list(sd_unet.unet_dict):
log.error(f'Load module: type=transformer file="{sel}" not found')
return None
path = sd_unet.unet_dict[sel]
if not os.path.exists(path):
log.error(f'Load module: type=transformer path="{path}" does not exist')
return None
return path
def load_transformer_components(repo_id, diffusers_load_config, adapter_cls):
"""Load (transformer, llm_adapter_or_none).
If the UNET dropdown points at a valid safetensors, route through the
custom-transformer helper, which also extracts the bundled adapter
weights. Otherwise fall back to ``generic.load_transformer`` and return
``None`` for the adapter so the caller loads it from the base repo.
If the UNET dropdown points at a valid safetensors, route through
:mod:`pipelines.native_transformer` with :data:`pipelines.anima.ANIMA_SPEC`,
which extracts any bundled ``llm_adapter`` weights inline with the
transformer. Otherwise fall back to :func:`generic.load_transformer` and
return ``None`` for the adapter so the caller loads it from the base repo.
"""
local_file = resolve_custom_transformer_path()
from pipelines import native_transformer
local_file = native_transformer.resolve_path()
if local_file is not None:
from pipelines.anima import anima_transformer
from pipelines.anima import ANIMA_SPEC
try:
return anima_transformer.load_custom_transformer(repo_id, local_file, diffusers_load_config, adapter_cls)
transformer, siblings = native_transformer.load(
local_file, repo_id, ANIMA_SPEC, diffusers_load_config,
sibling_classes={'llm_adapter': adapter_cls},
)
return transformer, siblings.get('llm_adapter')
except Exception as e:
log.error(f'Load model: type=Anima custom transformer="{local_file}": {e}')
errors.display(e, 'Load')
+18 -29
View File
@@ -6,11 +6,15 @@ Bypasses :func:`diffusers.loaders.FromOriginalModelMixin.from_single_file` so
sdnext owns prefix detection, optional sibling partitioning, dtype/quant/offload
handling, and explicit validation of missing/unexpected keys.
The per-arch knobs are captured in :class:`TransformerSpec`. Arches register a
spec at import time via :func:`register`; arches without a registration get a
default spec that handles BFL-style ``model.diffusion_model.`` prefix stripping
and opportunistically picks up a diffusers converter from
``SINGLE_FILE_LOADABLE_CLASSES`` if the class has one.
The per-arch knobs are captured in :class:`TransformerSpec`. Each pipeline
defines its spec in ``pipelines/<arch>/__init__.py`` and passes it explicitly
to :func:`load` (or to :func:`pipelines.generic.load_transformer` via the
``native_spec`` kwarg). No class-keyed registry: two pipelines may share a
transformer class but need different specs (e.g. Anima vs raw Cosmos both
use ``CosmosTransformer3DModel`` but Anima has a bundled ``llm_adapter``
sibling). Pipelines without a custom spec fall back to
:func:`make_default_spec`, which opportunistically picks up a real converter
from diffusers' ``SINGLE_FILE_LOADABLE_CLASSES`` table.
Algorithm:
@@ -90,32 +94,17 @@ class TransformerSpec:
forbidden_markers: tuple[tuple[str, str], ...] = ()
REGISTRY: dict[type, TransformerSpec] = {}
def make_default_spec(cls: type) -> TransformerSpec:
"""Synthesize a default spec for ``cls``: default prefixes, no siblings,
no forbidden markers, and a converter picked up automatically from
diffusers' ``SINGLE_FILE_LOADABLE_CLASSES`` table if one exists (and is
not the no-op identity lambda that ``QwenImageTransformer2DModel`` and
a few other classes register).
def register(cls: type, spec: TransformerSpec | None = None) -> None:
"""Register a transformer class with an explicit spec, or with the default
spec if ``spec`` is None. Idempotent: re-registering the same class
replaces the previous entry.
Used by callers (notably :func:`pipelines.generic.load_transformer`) when
a pipeline does not supply a custom ``TransformerSpec`` of its own.
"""
if spec is None:
spec = TransformerSpec(cls=cls)
if spec.cls is not cls:
raise ValueError(f"register: spec.cls ({spec.cls.__name__}) does not match cls ({cls.__name__})")
REGISTRY[cls] = spec
def lookup(cls: type) -> TransformerSpec:
"""Return the registered spec for ``cls``, or synthesize a default one.
The synthesized default opportunistically pulls a converter from diffusers'
``SINGLE_FILE_LOADABLE_CLASSES`` table if one exists for the class name and
is not a pass-through no-op lambda.
"""
if cls in REGISTRY:
return REGISTRY[cls]
converter = auto_pickup_converter(cls)
return TransformerSpec(cls=cls, converter=converter)
return TransformerSpec(cls=cls, converter=auto_pickup_converter(cls))
def auto_pickup_converter(cls: type) -> Callable[[dict], dict] | None:
+23 -46
View File
@@ -9,7 +9,7 @@ Covers the pure helpers that own per-arch knob handling:
- ``check_forbidden_markers`` for structural-mismatch rejection
- ``is_noop_converter`` for diffusers no-op lambda detection
- ``validate_state_dict_load`` for unexpected / missing key handling
- ``register`` / ``lookup`` registry behavior and default spec synthesis
- ``make_default_spec`` default-spec synthesis with diffusers converter pickup
- ``auto_pickup_converter`` for diffusers ``SINGLE_FILE_LOADABLE_CLASSES`` integration
- ``TransformerSpec`` / ``SiblingSpec`` defaults
@@ -306,58 +306,37 @@ def test_validate_empty_passes():
# ============================================================
# register / lookup
# make_default_spec
# ============================================================
class FakeTransformer:
"""Minimal stand-in for a diffusers transformer class."""
class FakeTransformer2:
"""Second stand-in for register/lookup tests."""
def test_register_with_explicit_spec():
nt.REGISTRY.clear()
spec = nt.TransformerSpec(cls=FakeTransformer, subfolder='custom_sub')
nt.register(FakeTransformer, spec)
assert nt.lookup(FakeTransformer) is spec
def test_register_with_default_spec():
nt.REGISTRY.clear()
nt.register(FakeTransformer)
spec = nt.lookup(FakeTransformer)
def test_make_default_spec_for_unknown_class():
spec = nt.make_default_spec(FakeTransformer)
assert spec.cls is FakeTransformer
assert spec.subfolder == 'transformer'
assert spec.prefixes == nt.DEFAULT_PREFIXES
assert spec.converter is None
assert spec.converter is None # no diffusers entry for FakeTransformer
assert spec.siblings == {}
assert spec.forbidden_markers == ()
def test_register_idempotent_replaces():
nt.REGISTRY.clear()
spec1 = nt.TransformerSpec(cls=FakeTransformer, subfolder='sub_one')
spec2 = nt.TransformerSpec(cls=FakeTransformer, subfolder='sub_two')
nt.register(FakeTransformer, spec1)
nt.register(FakeTransformer, spec2)
assert nt.lookup(FakeTransformer) is spec2
def test_make_default_spec_picks_up_real_diffusers_converter():
import diffusers
spec = nt.make_default_spec(diffusers.FluxTransformer2DModel)
assert spec.converter is not None
assert spec.converter.__name__ == 'convert_flux_transformer_checkpoint_to_diffusers'
def test_register_rejects_mismatched_cls():
try:
nt.register(FakeTransformer, nt.TransformerSpec(cls=FakeTransformer2))
raise AssertionError('expected ValueError')
except ValueError as e:
assert 'does not match' in str(e)
def test_lookup_synthesizes_default_for_unregistered():
nt.REGISTRY.clear()
spec = nt.lookup(FakeTransformer)
assert spec.cls is FakeTransformer
assert spec.subfolder == 'transformer' # default
assert spec.converter is None # FakeTransformer has no diffusers entry
def test_make_default_spec_skips_qwen_image_noop():
"""QwenImageTransformer2DModel's diffusers entry is a no-op lambda; the
default spec must NOT pick it up, leaving converter=None so the caller
sees only their own (potentially absent) override."""
import diffusers
spec = nt.make_default_spec(diffusers.QwenImageTransformer2DModel)
assert spec.converter is None
# ============================================================
@@ -701,14 +680,12 @@ def run_all():
]:
run_test(cat, fn)
log.warning('=== register / lookup ===')
cat = category('registry')
log.warning('=== make_default_spec ===')
cat = category('default_spec')
for fn in [
test_register_with_explicit_spec,
test_register_with_default_spec,
test_register_idempotent_replaces,
test_register_rejects_mismatched_cls,
test_lookup_synthesizes_default_for_unregistered,
test_make_default_spec_for_unknown_class,
test_make_default_spec_picks_up_real_diffusers_converter,
test_make_default_spec_skips_qwen_image_noop,
]:
run_test(cat, fn)