mirror of
https://github.com/vladmandic/sdnext.git
synced 2026-10-12 02:24:13 +00:00
QwenImage21Transformer2DModel keeps attention and the SwiGLU split, while ComfyUI checkpoints fuse the SwiGLU input as img_mlp.gate_up with rows [gate_layer; proj], the layout ai-toolkit and musubi-tuner train against. pipelines/qwen/qwen21_lora.py splits that projection by row halves for LoRA, DoRA, LoKr and LoHA in dotted and kohya-flattened form and binds every other key verbatim; qwen21 joins allow_native and NATIVE_DISPATCH. The diffusers path loads none of the fused files and fails on transformer.-prefixed files that carry alpha tensors. The tests map every layout found on CivitAI and the Hub against the real class, check the split against the fused SwiGLU forward, and refuse Qwen-Image 1.x files.
694 lines
30 KiB
Python
694 lines
30 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Offline unit tests for the Qwen-Image 2.1 native adapter loader.
|
|
|
|
``diffusers.QwenImage21Transformer2DModel`` keeps attention and the SwiGLU
|
|
split (``img_mlp.gate_layer`` / ``img_mlp.proj`` / ``img_mlp.out``), while
|
|
ComfyUI checkpoints fuse the SwiGLU input as ``img_mlp.gate_up`` with rows
|
|
``[gate_layer; proj]``, the layout ai-toolkit and musubi-tuner train against.
|
|
``pipelines.qwen.qwen21_lora`` splits that projection; everything else binds
|
|
verbatim. These tests pin the split (including a forward-pass check against
|
|
the fused SwiGLU), every save layout seen on CivitAI and the Hub, and the
|
|
file-level alpha read from PEFT / DiffSynth metadata.
|
|
|
|
Save formats exercised:
|
|
|
|
- ai-toolkit (``diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_A.weight``)
|
|
- musubi-tuner (``lora_unet_transformer_blocks_0_img_mlp_gate_up.lora_down.weight`` + alpha)
|
|
- diffusers-PEFT (``transformer.transformer_blocks.0.img_mlp.proj.lora_A.weight``),
|
|
PEFT named adapter (``...lora_A.default.weight``), bare factors (``...lora_A``)
|
|
- naive diffusers-to-ComfyUI conversion (``diffusion_model.transformer.``)
|
|
- LoKr and DoRA on the fused projection, rank-padded fused re-exports
|
|
- Qwen-Image 1.x files, which must be refused
|
|
|
|
The reference module tree is the real class at tiny dims. When the local
|
|
``Lora/Qwen 2.1`` folder exists, every file in it is also mapped header-only
|
|
against a full-size model built on the meta device.
|
|
|
|
No running server required.
|
|
|
|
Usage:
|
|
python test/test-qwen21-native-adapters.py
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import math
|
|
import json
|
|
import tempfile
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
import safetensors.torch
|
|
|
|
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, script_dir)
|
|
os.chdir(script_dir)
|
|
|
|
os.environ['SD_INSTALL_QUIET'] = '1'
|
|
|
|
# Bootstrap cmd_args before any module that pulls in shared.py.
|
|
import modules.cmd_args # pylint: disable=wrong-import-position
|
|
import installer # pylint: disable=wrong-import-position
|
|
_orig_argv = sys.argv
|
|
sys.argv = [sys.argv[0]]
|
|
try:
|
|
modules.cmd_args.parse_args()
|
|
finally:
|
|
sys.argv = _orig_argv
|
|
installer.add_args(modules.cmd_args.parser)
|
|
modules.cmd_args.parsed, _ = modules.cmd_args.parser.parse_known_args([])
|
|
|
|
import diffusers # pylint: disable=wrong-import-position
|
|
from modules.errors import log # pylint: disable=wrong-import-position
|
|
from modules.lora import network, network_lora, network_lokr # pylint: disable=wrong-import-position
|
|
from modules.lora import native_adapter # pylint: disable=wrong-import-position
|
|
from modules.lora.native_adapter import ChunkSpec # pylint: disable=wrong-import-position
|
|
from pipelines.qwen import qwen21_lora as Q # pylint: disable=wrong-import-position
|
|
|
|
|
|
# ============================================================
|
|
# Test infrastructure
|
|
# ============================================================
|
|
|
|
results: dict[str, dict] = {}
|
|
|
|
|
|
def category(name: str):
|
|
if name not in results:
|
|
results[name] = {'passed': 0, 'failed': 0, 'tests': []}
|
|
return name
|
|
|
|
|
|
def record(cat: str, passed: bool, name: str, detail: str = ''):
|
|
status = 'PASS' if passed else 'FAIL'
|
|
results[cat]['passed' if passed else 'failed'] += 1
|
|
results[cat]['tests'].append((status, name))
|
|
msg = f' {status}: {name}'
|
|
if detail:
|
|
msg += f' ({detail})'
|
|
if passed:
|
|
log.info(msg)
|
|
else:
|
|
log.error(msg)
|
|
|
|
|
|
def run_test(cat: str, fn):
|
|
name = fn.__name__
|
|
try:
|
|
ok = fn()
|
|
record(cat, ok is not False, name)
|
|
except AssertionError as e:
|
|
record(cat, False, name, str(e))
|
|
except Exception as e: # pylint: disable=broad-except
|
|
record(cat, False, name, f'exception: {e}')
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
|
|
# ============================================================
|
|
# Reference Qwen-Image 2.1 transformer (real class, tiny dims)
|
|
# ============================================================
|
|
# Upstream: 32 layers, 32 heads x 128 = 4096, mlp_ratio 3, context 4096, 64 latent channels.
|
|
|
|
INNER = 32
|
|
MLP = INNER * 3
|
|
LAYERS = 2
|
|
RANK = 4
|
|
|
|
REF = diffusers.QwenImage21Transformer2DModel(
|
|
patch_size=1, in_channels=16, out_channels=16, num_layers=LAYERS,
|
|
attention_head_dim=16, num_attention_heads=2, context_in_dim=24,
|
|
mlp_ratio=3, axes_dims_rope=(4, 6, 6),
|
|
)
|
|
|
|
LINEAR_SHAPES = {name: tuple(m.weight.shape) for name, m in REF.named_modules() if isinstance(m, nn.Linear)}
|
|
LINEAR_NETKEYS = {'lora_transformer_' + name.replace('.', '_') for name in LINEAR_SHAPES}
|
|
BLOCK_LEAVES = ('attn.to_q', 'attn.to_k', 'attn.to_v', 'attn.to_out.0', 'img_mlp.gate_layer', 'img_mlp.proj', 'img_mlp.out')
|
|
EXTRAS = ('modulation.1', 'time_text_embed.timestep_embedder.linear_1', 'time_text_embed.timestep_embedder.linear_2',
|
|
'txt_in.in_layer', 'txt_in.out_layer', 'img_in', 'norm_out.linear', 'proj_out')
|
|
|
|
|
|
def shape(path):
|
|
assert path in LINEAR_SHAPES, f'reference has no Linear {path!r}'
|
|
return LINEAR_SHAPES[path]
|
|
|
|
|
|
class MockPipeline:
|
|
def __init__(self, transformer):
|
|
self.transformer = transformer
|
|
self.text_encoder = None
|
|
|
|
|
|
class MockSdModel:
|
|
def __init__(self, pipe):
|
|
self.pipe = pipe
|
|
self.network_layer_mapping = {}
|
|
self.embedding_db = None
|
|
self.__class__.__name__ = 'QwenImage21Pipeline'
|
|
|
|
|
|
def install_mock_pipe(transformer=REF):
|
|
"""Point shared.sd_model at a mock exposing ``transformer``; re-installed per load so stamps do not leak."""
|
|
from modules.modeldata import model_data
|
|
model_data.sd_model = MockSdModel(MockPipeline(transformer))
|
|
return model_data.sd_model
|
|
|
|
|
|
# ============================================================
|
|
# Synthesizers and file helpers
|
|
# ============================================================
|
|
|
|
|
|
def lora(key_base, out, inp, rank=RANK, names=('lora_A.weight', 'lora_B.weight'), alpha=None):
|
|
sd = {f'{key_base}.{names[0]}': torch.randn(rank, inp), f'{key_base}.{names[1]}': torch.randn(out, rank)}
|
|
if alpha is not None:
|
|
sd[f'{key_base}.alpha'] = torch.tensor(float(alpha))
|
|
return sd
|
|
|
|
|
|
def ai_toolkit_block(i, prefix='diffusion_model.', names=('lora_A.weight', 'lora_B.weight'), alpha=None):
|
|
"""One block as ai-toolkit saves it: split attention, fused gate_up, out."""
|
|
sd = {}
|
|
for leaf in ('attn.to_q', 'attn.to_k', 'attn.to_v', 'attn.to_out.0'):
|
|
sd.update(lora(f'{prefix}transformer_blocks.{i}.{leaf}', INNER, INNER, names=names, alpha=alpha))
|
|
sd.update(lora(f'{prefix}transformer_blocks.{i}.img_mlp.gate_up', 2 * MLP, INNER, names=names, alpha=alpha))
|
|
sd.update(lora(f'{prefix}transformer_blocks.{i}.img_mlp.out', INNER, MLP, names=names, alpha=alpha))
|
|
return sd
|
|
|
|
|
|
def musubi_block(i, alpha=RANK):
|
|
"""One block as musubi-tuner saves it: kohya-flattened names, lora_down/up, alpha."""
|
|
sd = {}
|
|
for leaf, (out, inp) in (('attn_to_q', (INNER, INNER)), ('attn_to_k', (INNER, INNER)), ('attn_to_v', (INNER, INNER)),
|
|
('attn_to_out_0', (INNER, INNER)), ('img_mlp_gate_up', (2 * MLP, INNER)), ('img_mlp_out', (INNER, MLP))):
|
|
sd.update(lora(f'lora_unet_transformer_blocks_{i}_{leaf}', out, inp, names=('lora_down.weight', 'lora_up.weight'), alpha=alpha))
|
|
return sd
|
|
|
|
|
|
def split_block(i, prefix='transformer.', names=('lora_A.weight', 'lora_B.weight')):
|
|
"""One block with diffusers names: split SwiGLU."""
|
|
sd = {}
|
|
for leaf in BLOCK_LEAVES:
|
|
out, inp = shape(f'transformer_blocks.{i}.{leaf}')
|
|
sd.update(lora(f'{prefix}transformer_blocks.{i}.{leaf}', out, inp, names=names))
|
|
return sd
|
|
|
|
|
|
class TempLora:
|
|
"""Context manager: writes a state dict (and optional metadata) to a temp safetensors file."""
|
|
|
|
def __init__(self, state_dict, name='test', metadata=None, cached_metadata=None):
|
|
self.state_dict = state_dict
|
|
self.name = name
|
|
self.metadata = metadata
|
|
self.cached_metadata = cached_metadata
|
|
self.path = None
|
|
|
|
def __enter__(self):
|
|
sd = {k: v.contiguous() for k, v in self.state_dict.items()}
|
|
fd, self.path = tempfile.mkstemp(suffix='.safetensors', prefix=f'{self.name}_')
|
|
os.close(fd)
|
|
safetensors.torch.save_file(sd, self.path, metadata=self.metadata)
|
|
return MockNetworkOnDisk(self.path, self.name, self.cached_metadata)
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
if self.path and os.path.exists(self.path):
|
|
os.unlink(self.path)
|
|
|
|
|
|
class MockNetworkOnDisk:
|
|
def __init__(self, filename, name, metadata=None):
|
|
self.filename = filename
|
|
self.name = name
|
|
self.shorthash = ''
|
|
self.sd_version = 'unknown'
|
|
if metadata is not None:
|
|
self.metadata = metadata
|
|
|
|
|
|
def load_via(try_fn, state_dict, metadata=None, cached_metadata=None, name='test'):
|
|
install_mock_pipe()
|
|
with TempLora(state_dict, name=name, metadata=metadata, cached_metadata=cached_metadata) as nod:
|
|
net = try_fn(name, nod, lora_scale=1.0)
|
|
if net is not None:
|
|
for module in net.modules.values():
|
|
module.network.te_multiplier = 1.0
|
|
module.network.unet_multiplier = 1.0
|
|
return net
|
|
|
|
|
|
def netkey(path):
|
|
return 'lora_transformer_' + path.replace('.', '_')
|
|
|
|
|
|
def updown(module, path):
|
|
delta, _ = module.calc_updown(REF.get_submodule(path).weight.detach().clone())
|
|
return delta
|
|
|
|
|
|
# ============================================================
|
|
# Tests - parsing
|
|
# ============================================================
|
|
|
|
CAT_PARSE = category('parse')
|
|
|
|
|
|
def test_parse_key_layouts():
|
|
bd = Q.BARE_DIFFUSERS_PREFIX_USED
|
|
cases = [
|
|
('diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_A.weight', ('diffusion_model.', 'transformer_blocks.0.img_mlp.gate_up', 'lora_down.weight')),
|
|
('transformer.transformer_blocks.0.attn.to_q.lora_B.weight', ('transformer.', 'transformer_blocks.0.attn.to_q', 'lora_up.weight')),
|
|
('diffusion_model.transformer.modulation.1.lora_A.weight', ('transformer.', 'modulation.1', 'lora_down.weight')),
|
|
('transformer_blocks.0.attn.to_k.lora_A.default.weight', (bd, 'transformer_blocks.0.attn.to_k', 'lora_down.weight')),
|
|
('transformer_blocks.0.attn.to_k.lora_B', (bd, 'transformer_blocks.0.attn.to_k', 'lora_up.weight')),
|
|
('lora_unet_transformer_blocks_0_img_mlp_gate_up.lora_down.weight', ('lora_unet_', 'transformer_blocks_0_img_mlp_gate_up', 'lora_down.weight')),
|
|
('lora_unet_transformer_blocks_0_attn_to_q.alpha', ('lora_unet_', 'transformer_blocks_0_attn_to_q', 'alpha')),
|
|
('img_in.lora_down', (bd, 'img_in', 'lora_down.weight')),
|
|
('diffusion_model.transformer_blocks.3.img_mlp.gate_up.dora_scale', ('diffusion_model.', 'transformer_blocks.3.img_mlp.gate_up', 'dora_scale')),
|
|
('random.unrelated.key', None),
|
|
]
|
|
for key, expected in cases:
|
|
got = Q.parse_key(key, Q.LORA_SUFFIXES)
|
|
assert got == expected, f'parse_key({key!r}) = {got}, expected {expected}'
|
|
return True
|
|
|
|
|
|
def test_markers():
|
|
assert Q.has_marker({'transformer_blocks.0.attn.to_q.lora_A': None}, Q.LORA_MARKERS), 'bare lora_A not detected'
|
|
assert Q.has_marker({'transformer_blocks.0.attn.to_q.lora_A.default.weight': None}, Q.LORA_MARKERS)
|
|
lokr = {'diffusion_model.transformer_blocks.0.img_mlp.gate_up.lokr_w1': None}
|
|
assert Q.has_marker(lokr, Q.LOKR_MARKERS) and not Q.has_marker(lokr, Q.LORA_MARKERS)
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Tests - target resolution
|
|
# ============================================================
|
|
|
|
CAT_RESOLVE = category('resolve')
|
|
|
|
GATE_UP_SPLIT = [('transformer_blocks.0.img_mlp.gate_layer', ChunkSpec(idx=0, total=2)), ('transformer_blocks.0.img_mlp.proj', ChunkSpec(idx=1, total=2))]
|
|
|
|
|
|
def test_resolve_gate_up_every_prefix():
|
|
for prefix in ('diffusion_model.', 'transformer.', Q.BARE_DIFFUSERS_PREFIX_USED, 'lora_transformer_'):
|
|
got = native_adapter.resolve_group_targets(Q.resolve_targets, prefix, 'transformer_blocks.0.img_mlp.gate_up')
|
|
assert got == GATE_UP_SPLIT, f'{prefix}: {got}'
|
|
return True
|
|
|
|
|
|
def test_resolve_gate_up_flattened():
|
|
expected = [('transformer_blocks_0_img_mlp_gate_layer', ChunkSpec(idx=0, total=2)), ('transformer_blocks_0_img_mlp_proj', ChunkSpec(idx=1, total=2))]
|
|
for prefix in ('lora_unet_', 'lycoris_', 'lora_transformer_'):
|
|
got = native_adapter.resolve_group_targets(Q.resolve_targets, prefix, 'transformer_blocks_0_img_mlp_gate_up')
|
|
assert got == expected, f'{prefix}: {got}'
|
|
assert {netkey(path) for path, _ in got} <= LINEAR_NETKEYS
|
|
return True
|
|
|
|
|
|
def test_resolve_verbatim_is_real():
|
|
for prefix in ('diffusion_model.', 'transformer.', Q.BARE_DIFFUSERS_PREFIX_USED):
|
|
for path in [f'transformer_blocks.1.{leaf}' for leaf in BLOCK_LEAVES] + list(EXTRAS):
|
|
got = native_adapter.resolve_group_targets(Q.resolve_targets, prefix, path)
|
|
assert got == [(path, None)], f'{prefix}{path}: {got}'
|
|
assert netkey(path) in LINEAR_NETKEYS, f'{path} is not a reference Linear'
|
|
got = native_adapter.resolve_group_targets(Q.resolve_targets, 'lora_unet_', 'transformer_blocks_1_attn_to_out_0')
|
|
assert got == [('transformer_blocks_1_attn_to_out_0', None)] and netkey('transformer_blocks.1.attn.to_out.0') in LINEAR_NETKEYS
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Tests - loading real-world layouts
|
|
# ============================================================
|
|
|
|
CAT_LOAD = category('load')
|
|
FULL = 7 * LAYERS
|
|
|
|
|
|
def test_load_ai_toolkit_fused():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(ai_toolkit_block(i))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL, f'modules={len(net.modules) if net else None}'
|
|
assert set(net.modules) <= LINEAR_NETKEYS
|
|
fused_up = sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_B.weight']
|
|
down = sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_A.weight']
|
|
gate = net.modules[netkey('transformer_blocks.0.img_mlp.gate_layer')]
|
|
proj = net.modules[netkey('transformer_blocks.0.img_mlp.proj')]
|
|
assert torch.equal(gate.up_model.weight, fused_up[:MLP]) and torch.equal(proj.up_model.weight, fused_up[MLP:]), 'gate_up rows landed on the wrong module'
|
|
assert torch.equal(gate.down_model.weight, down) and torch.equal(proj.down_model.weight, down)
|
|
return True
|
|
|
|
|
|
def test_load_musubi_flattened_alpha():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(musubi_block(i, alpha=RANK / 2))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL, f'modules={len(net.modules) if net else None}'
|
|
for key in (netkey('transformer_blocks.1.img_mlp.gate_layer'), netkey('transformer_blocks.1.img_mlp.proj')):
|
|
assert net.modules[key].alpha == RANK / 2 and net.modules[key].calc_scale() == 0.5
|
|
return True
|
|
|
|
|
|
def test_load_peft_named_adapter():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(split_block(i, prefix='', names=('lora_A.default.weight', 'lora_B.default.weight')))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL
|
|
return True
|
|
|
|
|
|
def test_load_bare_factor_names():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(split_block(i, prefix='', names=('lora_A', 'lora_B')))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL, f'modules={len(net.modules) if net else None}'
|
|
return True
|
|
|
|
|
|
def test_load_turbo_extras():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(split_block(i))
|
|
for path in ('modulation.1', 'time_text_embed.timestep_embedder.linear_1', 'time_text_embed.timestep_embedder.linear_2'):
|
|
sd.update(lora(f'transformer.{path}', *shape(path)))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL + 3
|
|
assert netkey('modulation.1') in net.modules
|
|
return True
|
|
|
|
|
|
def test_load_comfy_wrapped_diffusers():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(split_block(i, prefix='diffusion_model.transformer.'))
|
|
sd.update(lora('diffusion_model.transformer.modulation.1', *shape('modulation.1')))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL + 1, f'modules={len(net.modules) if net else None}'
|
|
return True
|
|
|
|
|
|
def test_load_pdd_backbone():
|
|
"""alibaba-pai exports: bare names, bare lora_down/lora_up, non-block targets."""
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(split_block(i, prefix='', names=('lora_down', 'lora_up')))
|
|
for path in ('img_in', 'txt_in.in_layer', 'txt_in.out_layer', 'norm_out.linear', 'modulation.1'):
|
|
sd.update(lora(path, *shape(path), names=('lora_down', 'lora_up')))
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == FULL + 5
|
|
return True
|
|
|
|
|
|
def test_load_partial_blocks():
|
|
net = load_via(Q.try_load, ai_toolkit_block(1))
|
|
assert net is not None and len(net.modules) == 7
|
|
assert all('_blocks_1_' in key for key in net.modules)
|
|
return True
|
|
|
|
|
|
def test_load_lokr_fused():
|
|
sd = {
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.lokr_w1': torch.randn(8, 4),
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.lokr_w2': torch.randn(2 * MLP // 8, INNER // 4),
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.alpha': torch.tensor(4.0),
|
|
}
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == 2
|
|
gate = net.modules[netkey('transformer_blocks.0.img_mlp.gate_layer')]
|
|
proj = net.modules[netkey('transformer_blocks.0.img_mlp.proj')]
|
|
assert isinstance(gate, network_lokr.NetworkModuleLokrChunk) and (gate.chunk_index, gate.num_chunks) == (0, 2)
|
|
assert isinstance(proj, network_lokr.NetworkModuleLokrChunk) and (proj.chunk_index, proj.num_chunks) == (1, 2)
|
|
return True
|
|
|
|
|
|
def test_load_dora_fused():
|
|
sd = lora('diffusion_model.transformer_blocks.0.img_mlp.gate_up', 2 * MLP, INNER)
|
|
sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.dora_scale'] = torch.rand(2 * MLP, 1) + 0.5
|
|
net = load_via(Q.try_load, sd)
|
|
assert net is not None and len(net.modules) == 2
|
|
ds = sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.dora_scale']
|
|
assert torch.equal(net.modules[netkey('transformer_blocks.0.img_mlp.gate_layer')].dora_scale, ds[:MLP])
|
|
assert torch.equal(net.modules[netkey('transformer_blocks.0.img_mlp.proj')].dora_scale, ds[MLP:])
|
|
return True
|
|
|
|
|
|
def test_refuse_qwen_image_1():
|
|
"""Qwen-Image 1.x names bind nothing; right names at the wrong width are a shape mismatch that refuses the file."""
|
|
names_only = lora('transformer.transformer_blocks.0.img_mod.1', 6 * INNER, INNER)
|
|
names_only.update(lora('transformer.transformer_blocks.0.txt_mlp.net.2', INNER, 4 * INNER))
|
|
assert load_via(Q.try_load, names_only) is None
|
|
wrong_width = lora('diffusion_model.transformer_blocks.0.attn.to_q', 3 * INNER, 3 * INNER)
|
|
wrong_width.update(lora('diffusion_model.transformer_blocks.0.attn.to_k', INNER, INNER))
|
|
assert load_via(Q.try_load, wrong_width) is None
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Tests - delta math
|
|
# ============================================================
|
|
|
|
CAT_MATH = category('math')
|
|
|
|
|
|
def swiglu_fused(x, w_gate_up, w_out):
|
|
"""ai-toolkit / ComfyUI forward: ``gate, up = gate_up(x).chunk(2)``."""
|
|
gate, up = (x @ w_gate_up.T).chunk(2, dim=-1)
|
|
return (torch.nn.functional.silu(gate) * up) @ w_out.T
|
|
|
|
|
|
def swiglu_split(x, w_gate, w_proj, w_out):
|
|
"""diffusers forward: ``out(silu(gate_layer(x)) * proj(x))``."""
|
|
return (torch.nn.functional.silu(x @ w_gate.T) * (x @ w_proj.T)) @ w_out.T
|
|
|
|
|
|
def test_gate_up_split_matches_fused_forward():
|
|
"""The split LoRA reproduces the fused SwiGLU output; the swapped split does not."""
|
|
mlp = REF.transformer_blocks[0].img_mlp
|
|
w_gate, w_proj, w_out = mlp.gate_layer.weight.detach(), mlp.proj.weight.detach(), mlp.out.weight.detach()
|
|
sd = lora('diffusion_model.transformer_blocks.0.img_mlp.gate_up', 2 * MLP, INNER)
|
|
delta = sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_B.weight'] @ sd['diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_A.weight']
|
|
x = torch.randn(5, INNER)
|
|
reference = swiglu_fused(x, torch.cat([w_gate, w_proj]) + delta, w_out)
|
|
net = load_via(Q.try_load, sd)
|
|
d_gate = updown(net.modules[netkey('transformer_blocks.0.img_mlp.gate_layer')], 'transformer_blocks.0.img_mlp.gate_layer')
|
|
d_proj = updown(net.modules[netkey('transformer_blocks.0.img_mlp.proj')], 'transformer_blocks.0.img_mlp.proj')
|
|
native = swiglu_split(x, w_gate + d_gate, w_proj + d_proj, w_out)
|
|
assert torch.allclose(native, reference, atol=1e-5), f'max diff {(native - reference).abs().max():.3e}'
|
|
swapped = swiglu_split(x, w_gate + d_proj, w_proj + d_gate, w_out)
|
|
assert not torch.allclose(swapped, reference, atol=1e-3), 'swapped halves also match; the check does not discriminate'
|
|
return True
|
|
|
|
|
|
def test_lokr_chunks_tile_the_kron():
|
|
w1, w2 = torch.randn(8, 4), torch.randn(2 * MLP // 8, INNER // 4)
|
|
sd = {
|
|
'diffusion_model.transformer_blocks.1.img_mlp.gate_up.lokr_w1': w1,
|
|
'diffusion_model.transformer_blocks.1.img_mlp.gate_up.lokr_w2': w2,
|
|
}
|
|
net = load_via(Q.try_load, sd)
|
|
full = torch.kron(w1, w2)
|
|
d_gate = updown(net.modules[netkey('transformer_blocks.1.img_mlp.gate_layer')], 'transformer_blocks.1.img_mlp.gate_layer')
|
|
d_proj = updown(net.modules[netkey('transformer_blocks.1.img_mlp.proj')], 'transformer_blocks.1.img_mlp.proj')
|
|
assert torch.allclose(torch.cat([d_gate, d_proj]), full, atol=1e-5)
|
|
return True
|
|
|
|
|
|
def test_rank_padded_fused_is_exact():
|
|
"""A split LoRA re-fused as ``down = [A_g; A_p]``, ``up = diag(B_g, B_p)`` returns each half exactly."""
|
|
a_g, b_g, a_p, b_p = torch.randn(RANK, INNER), torch.randn(MLP, RANK), torch.randn(RANK, INNER), torch.randn(MLP, RANK)
|
|
up = torch.zeros(2 * MLP, 2 * RANK)
|
|
up[:MLP, :RANK], up[MLP:, RANK:] = b_g, b_p
|
|
sd = {
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_down.weight': torch.cat([a_g, a_p]),
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.lora_up.weight': up,
|
|
'diffusion_model.transformer_blocks.0.img_mlp.gate_up.alpha': torch.tensor(float(2 * RANK)),
|
|
}
|
|
net = load_via(Q.try_load, sd)
|
|
d_gate = updown(net.modules[netkey('transformer_blocks.0.img_mlp.gate_layer')], 'transformer_blocks.0.img_mlp.gate_layer')
|
|
d_proj = updown(net.modules[netkey('transformer_blocks.0.img_mlp.proj')], 'transformer_blocks.0.img_mlp.proj')
|
|
assert torch.allclose(d_gate, b_g @ a_g, atol=1e-5) and torch.allclose(d_proj, b_p @ a_p, atol=1e-5)
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Tests - file-level alpha from metadata
|
|
# ============================================================
|
|
|
|
CAT_ALPHA = category('alpha')
|
|
|
|
|
|
def peft_metadata(**fields):
|
|
return {'lora_adapter_metadata': json.dumps({f'transformer.{k}': v for k, v in fields.items()}), 'format': 'pt'}
|
|
|
|
|
|
def test_peft_metadata_alpha():
|
|
"""Pruna ships r=64 lora_alpha=128 without alpha tensors: PEFT applies scale 2."""
|
|
net = load_via(Q.try_load, split_block(0), metadata=peft_metadata(r=RANK, lora_alpha=2 * RANK, alpha_pattern={}, use_rslora=False))
|
|
assert all(module.calc_scale() == 2.0 for module in net.modules.values())
|
|
return True
|
|
|
|
|
|
def test_peft_alpha_pattern():
|
|
net = load_via(Q.try_load, split_block(0), metadata=peft_metadata(r=RANK, lora_alpha=RANK, alpha_pattern={'transformer_blocks.0.attn.to_q': RANK / 2}))
|
|
assert net.modules[netkey('transformer_blocks.0.attn.to_q')].calc_scale() == 0.5
|
|
assert net.modules[netkey('transformer_blocks.0.attn.to_k')].calc_scale() == 1.0
|
|
return True
|
|
|
|
|
|
def test_peft_rslora():
|
|
net = load_via(Q.try_load, split_block(0), metadata=peft_metadata(r=RANK, lora_alpha=RANK, use_rslora=True))
|
|
assert all(math.isclose(module.calc_scale(), RANK / math.sqrt(RANK)) for module in net.modules.values())
|
|
return True
|
|
|
|
|
|
def test_alpha_tensors_win_over_metadata():
|
|
sd = {}
|
|
for i in range(LAYERS):
|
|
sd.update(musubi_block(i, alpha=RANK))
|
|
net = load_via(Q.try_load, sd, metadata=peft_metadata(r=RANK, lora_alpha=4 * RANK))
|
|
assert all(module.calc_scale() == 1.0 for module in net.modules.values())
|
|
return True
|
|
|
|
|
|
def test_flat_alpha_metadata():
|
|
"""DiffSynth-Studio stores alpha and rank as flat metadata keys."""
|
|
net = load_via(Q.try_load, split_block(0, prefix='', names=('lora_A.default.weight', 'lora_B.default.weight')), metadata={'alpha': str(RANK / 2), 'rank': str(RANK)})
|
|
assert all(module.calc_scale() == 0.5 for module in net.modules.values())
|
|
return True
|
|
|
|
|
|
def test_metadata_read_from_header_when_cache_empty():
|
|
"""An empty cached metadata dict (failed read, --no-metadata) falls back to the file header."""
|
|
net = load_via(Q.try_load, split_block(0), metadata=peft_metadata(r=RANK, lora_alpha=2 * RANK), cached_metadata={})
|
|
assert all(module.calc_scale() == 2.0 for module in net.modules.values())
|
|
return True
|
|
|
|
|
|
def test_text_encoder_component_is_separate():
|
|
components = native_adapter.peft_configs({'lora_adapter_metadata': json.dumps({'transformer.lora_alpha': 8, 'text_encoder.lora_alpha': 2})})
|
|
alpha = native_adapter.peft_alpha(components)
|
|
assert alpha('transformer.', 'transformer_blocks.0.attn.to_q', 4) == 8.0
|
|
assert alpha('lora_te_', 'model_layers_0_self_attn_q_proj', 4) == 2.0
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Tests - real files (header-only, full-size meta model)
|
|
# ============================================================
|
|
|
|
CAT_REAL = category('real-files')
|
|
LORA_DIR = '/home/ohiom/database/models/Lora/Qwen 2.1'
|
|
QWEN1_DIR = '/home/ohiom/database/models/Lora/Qwen'
|
|
|
|
|
|
def full_size_shapes():
|
|
with torch.device('meta'):
|
|
model = diffusers.QwenImage21Transformer2DModel()
|
|
return {'lora_transformer_' + name.replace('.', '_'): tuple(m.weight.shape) for name, m in model.named_modules() if isinstance(m, nn.Linear)}
|
|
|
|
|
|
def header_shapes(filename):
|
|
from safetensors import safe_open
|
|
with safe_open(filename, framework='pt', device='cpu') as f:
|
|
return {key: tuple(f.get_slice(key).get_shape()) for key in f.keys()}
|
|
|
|
|
|
def map_file(shapes, targets):
|
|
"""(mapped, unmapped, mismatch) for the LoRA, DoRA and LoKr groups of one file, resolved like the loaders do."""
|
|
mapped = unmapped = mismatch = 0
|
|
lora_groups = Q.group_by_suffixes(shapes, Q.LORA_SUFFIXES)
|
|
lokr_groups = Q.group_by_suffixes(shapes, Q.LOKR_SUFFIXES)
|
|
for (prefix, base), w in list(lora_groups.items()) + list(lokr_groups.items()):
|
|
is_lora = 'lora_down.weight' in w and 'lora_up.weight' in w
|
|
is_lokr = 'lokr_w1' in w and 'lokr_w2' in w
|
|
if not (is_lora or is_lokr):
|
|
continue
|
|
for path, chunk in native_adapter.resolve_group_targets(Q.resolve_targets, prefix, base):
|
|
key = 'lora_transformer_' + path.replace('.', '_')
|
|
if key not in targets:
|
|
unmapped += 1
|
|
continue
|
|
out, inp = targets[key]
|
|
total = chunk.total if chunk is not None else 1
|
|
if is_lora:
|
|
fits = w['lora_down.weight'][1] == inp and w['lora_up.weight'][0] == out * total
|
|
else:
|
|
fits = w['lokr_w1'][0] * w['lokr_w2'][0] == out * total and w['lokr_w1'][1] * w['lokr_w2'][1] == inp
|
|
mapped += fits
|
|
mismatch += not fits
|
|
return mapped, unmapped, mismatch
|
|
|
|
|
|
def test_real_qwen21_files_map_cleanly():
|
|
if not os.path.isdir(LORA_DIR):
|
|
log.warning(f' SKIP: {LORA_DIR} not present')
|
|
return True
|
|
targets = full_size_shapes()
|
|
files = sorted(f for f in os.listdir(LORA_DIR) if f.endswith('.safetensors'))
|
|
bad = []
|
|
for f in files:
|
|
mapped, unmapped, mismatch = map_file(header_shapes(os.path.join(LORA_DIR, f)), targets)
|
|
log.info(f' {f}: mapped={mapped} unmapped={unmapped} mismatch={mismatch}')
|
|
if mapped == 0 or unmapped or mismatch:
|
|
bad.append(f)
|
|
assert not bad, f'{len(bad)}/{len(files)} files do not map cleanly: {bad}'
|
|
return True
|
|
|
|
|
|
def test_real_qwen1_files_do_not_fit():
|
|
if not os.path.isdir(QWEN1_DIR):
|
|
log.warning(f' SKIP: {QWEN1_DIR} not present')
|
|
return True
|
|
targets = full_size_shapes()
|
|
files = sorted(f for f in os.listdir(QWEN1_DIR) if f.endswith('.safetensors'))[:6]
|
|
for f in files:
|
|
mapped, unmapped, mismatch = map_file(header_shapes(os.path.join(QWEN1_DIR, f)), targets)
|
|
log.info(f' {f}: mapped={mapped} unmapped={unmapped} mismatch={mismatch}')
|
|
assert unmapped or mismatch, f'{f} maps onto Qwen-Image 2.1 without a single mismatch'
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Main
|
|
# ============================================================
|
|
|
|
|
|
def main():
|
|
tests = {
|
|
CAT_PARSE: [test_parse_key_layouts, test_markers],
|
|
CAT_RESOLVE: [test_resolve_gate_up_every_prefix, test_resolve_gate_up_flattened, test_resolve_verbatim_is_real],
|
|
CAT_LOAD: [
|
|
test_load_ai_toolkit_fused, test_load_musubi_flattened_alpha, test_load_peft_named_adapter,
|
|
test_load_bare_factor_names, test_load_turbo_extras, test_load_comfy_wrapped_diffusers,
|
|
test_load_pdd_backbone, test_load_partial_blocks, test_load_lokr_fused, test_load_dora_fused,
|
|
test_refuse_qwen_image_1,
|
|
],
|
|
CAT_MATH: [test_gate_up_split_matches_fused_forward, test_lokr_chunks_tile_the_kron, test_rank_padded_fused_is_exact],
|
|
CAT_ALPHA: [
|
|
test_peft_metadata_alpha, test_peft_alpha_pattern, test_peft_rslora, test_alpha_tensors_win_over_metadata,
|
|
test_flat_alpha_metadata, test_metadata_read_from_header_when_cache_empty, test_text_encoder_component_is_separate,
|
|
],
|
|
CAT_REAL: [test_real_qwen21_files_map_cleanly, test_real_qwen1_files_do_not_fit],
|
|
}
|
|
torch.manual_seed(0)
|
|
for cat, fns in tests.items():
|
|
log.info(f'Category: {cat}')
|
|
for fn in fns:
|
|
run_test(cat, fn)
|
|
passed = sum(r['passed'] for r in results.values())
|
|
failed = sum(r['failed'] for r in results.values())
|
|
for cat, r in results.items():
|
|
log.info(f'{cat}: passed={r["passed"]} failed={r["failed"]}')
|
|
log.info(f'Total: passed={passed} failed={failed}')
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|