Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu
242 lines
13 KiB
Python
242 lines
13 KiB
Python
"""A LoRA written for the Diffusers layout of the H3 transformer, loaded onto ComfyUI's own H3 class (tiny, CPU).
|
|
|
|
The file is built here with random factors, so the expected change to each weight is known exactly and the loader's
|
|
answer is compared to it: names, the fused qkv rows, and the swapped halves of the gated MLP."""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
RANK = 4
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def patcher(comfyui):
|
|
import comfy.model_patcher as mp
|
|
import comfy.supported_models as sm
|
|
config = sm.MiniMaxH3(dict(
|
|
image_model="minimax_h3", hidden_size=64, num_layers=2, token_refiner_num_layers=1, num_attention_heads=2,
|
|
attention_head_dim=32, ffn_hidden_size=64, text_dim=48, timestep_input_dim=32, time_embed_hidden_size=64,
|
|
time_embed_dim=32, rope_inv_freq_len=4))
|
|
config.set_inference_dtype(torch.float32, None)
|
|
base = config.get_model({}, device=torch.device("cpu"))
|
|
return mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
|
|
|
|
def _pair(out_dim, in_dim, gen):
|
|
return torch.randn(RANK, in_dim, generator=gen), torch.randn(out_dim, RANK, generator=gen)
|
|
|
|
|
|
def _file(prefix, gen, dtype=torch.float32, device="cpu"):
|
|
"""{key: tensor} and the delta each native weight should get."""
|
|
lora, want = {}, {}
|
|
for name, (o, i) in {"attn.to_q": (64, 64), "attn.to_k": (64, 64), "attn.to_v": (64, 64), "attn.to_out.0": (64, 64),
|
|
"ff.net.0.proj": (128, 64), "ff.net.2": (64, 64)}.items():
|
|
a, b = _pair(o, i, gen)
|
|
lora[f"transformer.{prefix}.{name}.lora_A.weight"] = a.to(device=device, dtype=dtype)
|
|
lora[f"transformer.{prefix}.{name}.lora_B.weight"] = b.to(device=device, dtype=dtype)
|
|
want[name] = b @ a
|
|
return lora, want
|
|
|
|
|
|
def _delta(patcher, key, shape):
|
|
import comfy.lora
|
|
return comfy.lora.calculate_weight(patcher.patches[key], torch.zeros(shape), key)
|
|
|
|
|
|
@pytest.mark.parametrize("prefix,native", [("transformer_blocks.1", "blocks.1"), ("token_refiner.refiner_blocks.0", "token_refiner.blocks.0")])
|
|
def test_every_weight_gets_exactly_its_change(patcher, prefix, native):
|
|
from modules.loaders.lora.keys import match
|
|
lora, want = _file(prefix, torch.Generator().manual_seed(0))
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert dropped == 0 and len(patches) == 6, (len(patches), how)
|
|
|
|
clone = patcher.clone()
|
|
clone.add_patches(patches, 1.0)
|
|
p = f"diffusion_model.{native}."
|
|
assert torch.allclose(_delta(clone, p + "attn.qkv_proj.weight", (192, 64)),
|
|
torch.cat([want["attn.to_q"], want["attn.to_k"], want["attn.to_v"]], 0), atol=1e-5)
|
|
assert torch.allclose(_delta(clone, p + "attn.out_proj.weight", (64, 64)), want["attn.to_out.0"], atol=1e-5)
|
|
assert torch.allclose(_delta(clone, p + "mlp.fc2.weight", (64, 64)), want["ff.net.2"], atol=1e-5)
|
|
up_first = want["ff.net.0.proj"]
|
|
assert torch.allclose(_delta(clone, p + "mlp.fc1.weight", (128, 64)), torch.cat([up_first[64:], up_first[:64]], 0), atol=1e-5)
|
|
|
|
|
|
def test_the_gated_mlp_swap_makes_the_native_layer_compute_what_diffusers_would(patcher):
|
|
"""Not just the bookkeeping: x through the converted fc1 + swiglu equals x through the Diffusers order (up, gate)."""
|
|
from modules.loaders.lora.keys import from_diffusers
|
|
gen = torch.Generator().manual_seed(1)
|
|
a, b = _pair(128, 64, gen)
|
|
out, _ = from_diffusers({"transformer.transformer_blocks.0.ff.net.0.proj.lora_A.weight": a,
|
|
"transformer.transformer_blocks.0.ff.net.0.proj.lora_B.weight": b})
|
|
x = torch.randn(3, 64, generator=gen)
|
|
native = x @ (out["blocks.0.mlp.fc1.lora_A.weight"].T @ out["blocks.0.mlp.fc1.lora_B.weight"].T)
|
|
gate, up = native.chunk(2, -1)
|
|
diffusers = x @ (a.T @ b.T)
|
|
d_up, d_gate = diffusers.chunk(2, -1) # Diffusers' SwiGLU: hidden, gate = proj(x).chunk(2)
|
|
assert torch.allclose(torch.nn.functional.silu(gate) * up, d_up * torch.nn.functional.silu(d_gate), atol=1e-5)
|
|
|
|
|
|
def test_a_native_file_is_left_alone(patcher):
|
|
from modules.loaders.lora.keys import from_diffusers
|
|
lora = {"diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight": torch.zeros(2, 2)}
|
|
out, extra = from_diffusers(lora)
|
|
assert out is lora and extra == {}
|
|
|
|
|
|
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16])
|
|
def test_conversion_keeps_device_and_dtype_of_every_tensor(dtype):
|
|
from modules.loaders.lora.keys import from_diffusers
|
|
lora, _ = _file("transformer_blocks.0", torch.Generator().manual_seed(2), dtype=dtype)
|
|
meta = {k: v.to("meta") for k, v in lora.items()} # the stand-in for a tensor that lives on another device
|
|
for src in (lora, meta):
|
|
out, _ = from_diffusers(src)
|
|
assert len(out) == len(src)
|
|
first = next(iter(src.values()))
|
|
assert all(v.dtype == first.dtype and v.device == first.device for v in out.values())
|
|
|
|
|
|
def test_an_ltx_lora_with_transformer_blocks_names_is_left_as_it_is(tiny_ltx, monkeypatch):
|
|
"""`transformer_blocks` is LTX's own name too: only the H3 transformer's LoRAs are renamed to H3's layout."""
|
|
from modules.loaders.lora import keys
|
|
sd = tiny_ltx.patcher.model.state_dict()
|
|
name = "diffusion_model.transformer_blocks.0.attn1.to_q.weight"
|
|
assert name in sd
|
|
out_dim, in_dim = sd[name].shape
|
|
monkeypatch.setattr(keys.comfy.lora, "model_lora_keys_unet", # the tiny stand-in has no model config to derive Diffusers names from
|
|
lambda model, key_map: {**key_map, **{k[:-len(".weight")]: k for k in sd if k.endswith(".weight")}})
|
|
lora = {name.replace(".weight", ".lora_A.weight"): torch.randn(RANK, in_dim),
|
|
name.replace(".weight", ".lora_B.weight"): torch.randn(out_dim, RANK)}
|
|
patches, _how, dropped, _unplaced = keys.match(tiny_ltx.patcher, None, lora)
|
|
assert len(patches) == 1 and dropped == 0
|
|
|
|
|
|
@pytest.mark.parametrize("wrapper", ["", "transformer.", "diffusion_model.", "base_model.model.", "model.diffusion_model.",
|
|
"base_model.model.diffusion_model.", "base_model.model.dit.", "dit.", "model.dit.", "lora_model.", "net."])
|
|
def test_a_wrapped_diffusers_file_gets_every_change_on_a_weight_the_model_has(patcher, wrapper):
|
|
from modules.loaders.lora.keys import match
|
|
lora, want = _file("transformer_blocks.1", torch.Generator().manual_seed(0))
|
|
lora = {wrapper + k[len("transformer."):]: v for k, v in lora.items()}
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert dropped == 0 and len(patches) == 6, (wrapper, how, len(patches))
|
|
state = patcher.model.state_dict()
|
|
assert all((k[0] if isinstance(k, tuple) else k) in state for k in patches) # every patch aims at a weight that exists
|
|
clone = patcher.clone()
|
|
clone.add_patches(patches, 1.0)
|
|
got = _delta(clone, "diffusion_model.blocks.1.attn.qkv_proj.weight", (192, 64))
|
|
assert torch.allclose(got, torch.cat([want["attn.to_q"], want["attn.to_k"], want["attn.to_v"]], 0), atol=1e-5)
|
|
assert torch.allclose(_delta(clone, "diffusion_model.blocks.1.attn.out_proj.weight", (64, 64)), want["attn.to_out.0"], atol=1e-5)
|
|
|
|
|
|
def test_a_qkv_pair_of_the_wrong_width_is_dropped_not_merged_scrambled(patcher):
|
|
from modules.loaders.lora.keys import match
|
|
lora, _ = _file("transformer_blocks.1", torch.Generator().manual_seed(0))
|
|
k = "transformer.transformer_blocks.1.attn.to_q.lora_B.weight"
|
|
lora[k] = torch.randn(96, RANK) # 96 rows where this model has 64 per part
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert dropped >= 1 and len(patches) == 5
|
|
|
|
|
|
def test_a_refiner_layout_file_without_a_wrapper_reaches_the_refiner_blocks(patcher):
|
|
from modules.loaders.lora.keys import match
|
|
lora, want = _file("token_refiner.refiner_blocks.0", torch.Generator().manual_seed(0))
|
|
lora = {k[len("transformer."):]: v for k, v in lora.items()}
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert dropped == 0 and len(patches) == 6, (how, len(patches))
|
|
state = patcher.model.state_dict()
|
|
assert all((k[0] if isinstance(k, tuple) else k) in state for k in patches)
|
|
|
|
|
|
def test_a_block_the_model_does_not_have_is_dropped_and_counted_not_reported_as_applied(patcher):
|
|
from modules.loaders.lora.keys import match
|
|
lora, _ = _file("transformer_blocks.9", torch.Generator().manual_seed(0)) # the tiny model has 2 blocks
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert not patches and dropped >= 1
|
|
|
|
|
|
def test_a_dense_lokr_on_a_native_weight_is_not_mistaken_for_a_misfit_pair(patcher):
|
|
from modules.loaders.lora.keys import match
|
|
gen = torch.Generator().manual_seed(0)
|
|
lora = {"diffusion_model.blocks.1.attn.out_proj.lokr_w1": torch.randn(8, 8, generator=gen),
|
|
"diffusion_model.blocks.1.attn.out_proj.lokr_w2": torch.randn(8, 8, generator=gen)}
|
|
patches, how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert len(patches) == 1 and dropped == 0
|
|
|
|
|
|
@pytest.mark.parametrize("keep", [("to_q",), ("to_k",), ("to_v",), ("to_q", "to_v")])
|
|
def test_a_file_that_trains_only_some_of_q_k_v_puts_each_in_its_own_rows(patcher, keep):
|
|
from modules.loaders.lora.keys import match
|
|
lora, want = _file("transformer_blocks.1", torch.Generator().manual_seed(3))
|
|
lora = {k: v for k, v in lora.items() if not any(f"attn.{q}." in k for q in ("to_q", "to_k", "to_v") if q not in keep)}
|
|
patches, _how, dropped, _unplaced = match(patcher, None, lora)
|
|
assert dropped == 0 and len(patches) == 3 + len(keep)
|
|
clone = patcher.clone()
|
|
clone.add_patches(patches, 1.0)
|
|
got = _delta(clone, "diffusion_model.blocks.1.attn.qkv_proj.weight", (192, 64))
|
|
zero = torch.zeros(64, 64)
|
|
assert torch.allclose(got, torch.cat([want[f"attn.{q}"] if q in keep else zero for q in ("to_q", "to_k", "to_v")], 0), atol=1e-5)
|
|
|
|
|
|
def test_the_older_lora_down_up_spelling_is_translated_like_lora_a_b():
|
|
from modules.loaders.lora.keys import from_diffusers
|
|
a, b = torch.randn(2, 8), torch.randn(6, 2)
|
|
new, _ = from_diffusers({"transformer.transformer_blocks.0.ff.net.2.lora_A.weight": a, "transformer.transformer_blocks.0.ff.net.2.lora_B.weight": b})
|
|
old, _ = from_diffusers({"transformer.transformer_blocks.0.ff.net.2.lora_down.weight": a, "transformer.transformer_blocks.0.ff.net.2.lora_up.weight": b})
|
|
assert sorted(old) == sorted(new) == ["blocks.0.mlp.fc2.lora_A.weight", "blocks.0.mlp.fc2.lora_B.weight"]
|
|
assert all(torch.equal(old[k], new[k]) for k in new)
|
|
|
|
|
|
def test_modules_with_no_place_in_the_model_are_counted_and_alerted(patcher, monkeypatch):
|
|
from modules.loaders.lora import nodes
|
|
from modules.loaders.lora.keys import match
|
|
lora, _want = _file("transformer_blocks.1", torch.Generator().manual_seed(0))
|
|
assert match(patcher, None, lora)[3] == 0 # a complete file: nothing is missing
|
|
gen = torch.Generator().manual_seed(5)
|
|
for name, (o, i) in {"norm1.linear": (384, 64), "attn.to_add_out": (64, 64)}.items(): # modules H3 has no name for
|
|
a, b = _pair(o, i, gen)
|
|
lora[f"transformer.transformer_blocks.1.{name}.lora_A.weight"] = a
|
|
lora[f"transformer.transformer_blocks.1.{name}.lora_B.weight"] = b
|
|
patches, _how, dropped, unplaced = match(patcher, None, lora)
|
|
assert (len(patches), dropped, unplaced) == (6, 0, 2)
|
|
said = []
|
|
monkeypatch.setattr(nodes.log, "alert", lambda title, text: said.append(text))
|
|
import comfy.utils
|
|
import folder_paths
|
|
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: "/fake/x")
|
|
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda p, **kw: dict(lora))
|
|
nodes.FunUILoraLoader.execute(model=patcher, lora_name="x.safetensors", strength_model=1.0)
|
|
assert any("2 of its 8 modules have no place" in t for t in said), said
|
|
|
|
|
|
# Modules with no place in H3 are counted whatever training wrapper sits in front of the Diffusers key names.
|
|
import pytest as _pytest
|
|
_RANK = 4
|
|
|
|
@_pytest.fixture(scope="module")
|
|
def wrapper_patcher():
|
|
import comfy.model_patcher as mp
|
|
import comfy.supported_models as sm
|
|
config = sm.MiniMaxH3(dict(
|
|
image_model="minimax_h3", hidden_size=64, num_layers=2, token_refiner_num_layers=1, num_attention_heads=2,
|
|
attention_head_dim=32, ffn_hidden_size=64, text_dim=48, timestep_input_dim=32, time_embed_hidden_size=64,
|
|
time_embed_dim=32, rope_inv_freq_len=4))
|
|
config.set_inference_dtype(torch.float32, None)
|
|
base = config.get_model({}, device=torch.device("cpu"))
|
|
return mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
|
|
def _pair(o, i, g):
|
|
return torch.randn(_RANK, i, generator=g), torch.randn(o, _RANK, generator=g)
|
|
|
|
@_pytest.mark.parametrize("wrapper", ["transformer.", "", "diffusion_model.", "base_model.model.", "dit."])
|
|
def test_unplaced_counted_for_wrapper(wrapper_patcher, wrapper):
|
|
from modules.loaders.lora.keys import match
|
|
g = torch.Generator().manual_seed(0)
|
|
lora = {}
|
|
for name, (o, i) in {"attn.to_q": (64, 64), "attn.to_k": (64, 64), "attn.to_v": (64, 64), "attn.to_out.0": (64, 64),
|
|
"ff.net.0.proj": (128, 64), "ff.net.2": (64, 64), "norm1.linear": (384, 64), "attn.to_add_out": (64, 64)}.items():
|
|
a, b = _pair(o, i, g)
|
|
lora[f"{wrapper}transformer_blocks.1.{name}.lora_A.weight"] = a
|
|
lora[f"{wrapper}transformer_blocks.1.{name}.lora_B.weight"] = b
|
|
patches, how, dropped, unplaced = match(wrapper_patcher, None, lora)
|
|
assert unplaced == 2, (wrapper, unplaced)
|
|
|