Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Whole-parameter LoRA payload: key classification, application, and reporting.
|
|
|
|
Nothing here loads a model. Most tests exercise key algebra and arithmetic on the CPU;
|
|
one regression test verifies cross-device adapter loading when CUDA is available.
|
|
"""
|
|
import logging
|
|
|
|
import pytest
|
|
import torch
|
|
from safetensors.torch import save_file
|
|
|
|
from fastvideo.configs.models.dits.wanvideo import WanVideoConfig
|
|
from fastvideo.models.loader import fsdp_load
|
|
from fastvideo.models.loader.lora_patch import DenseLoRAPatch, normalize_lora_key
|
|
from fastvideo.models.loader.utils import get_param_names_mapping
|
|
|
|
|
|
def write_adapter(tmp_path, tensors, name="adapter_model.safetensors"):
|
|
path = tmp_path / name
|
|
save_file(tensors, str(path))
|
|
return str(path)
|
|
|
|
|
|
# --------------------------------------------------------------------------- keys
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw, expected",
|
|
[
|
|
# PEFT, the spelling FastVideo's own extractor emits.
|
|
("blocks.0.attn.to_q.lora_A.weight", "blocks.0.attn.to_q.lora_A.weight"),
|
|
# PEFT with a named adapter: the name sits between the factor and `.weight`.
|
|
("blocks.0.attn.to_q.lora_A.default.weight", "blocks.0.attn.to_q.lora_A.weight"),
|
|
("blocks.0.attn.to_q.lora_B.turbo_v2.weight", "blocks.0.attn.to_q.lora_B.weight"),
|
|
# kohya / ComfyUI up-down spelling, with and without `.weight`.
|
|
("blocks.0.attn.to_q.lora_down.weight", "blocks.0.attn.to_q.lora_A.weight"),
|
|
("blocks.0.attn.to_q.lora_up.weight", "blocks.0.attn.to_q.lora_B.weight"),
|
|
("blocks.0.attn.to_q.lora_down", "blocks.0.attn.to_q.lora_A"),
|
|
# kohya alpha.
|
|
("blocks.0.attn.to_q.alpha", "blocks.0.attn.to_q.lora_alpha"),
|
|
# The `diffusion_model.` prefix ComfyUI-format files carry.
|
|
("diffusion_model.blocks.0.mlp.fc1.lora_up.weight", "blocks.0.mlp.fc1.lora_B.weight"),
|
|
],
|
|
)
|
|
def test_normalize_lora_key_accepts_every_published_spelling(raw, expected):
|
|
assert normalize_lora_key(raw) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [
|
|
"blocks.0.norm1.diff",
|
|
"blocks.0.attn.to_q.diff_b",
|
|
"blocks.0.attn.to_gate_compress.set_weight",
|
|
"blocks.0.scale_shift_table.diff_param",
|
|
"blocks.0.extra_table.set_param",
|
|
"blocks.0.attn.to_q.dora_scale",
|
|
])
|
|
def test_normalize_lora_key_disclaims_non_low_rank_keys(raw):
|
|
"""These belong to the loader-side patch, not the module merge path."""
|
|
assert normalize_lora_key(raw) is None
|
|
|
|
|
|
def test_set_weight_is_not_mangled_into_set():
|
|
"""`.replace('.weight', '')` downstream would turn `.set_weight` into `.set`."""
|
|
assert normalize_lora_key("blocks.0.attn.to_gate_compress.set_weight") is None
|
|
|
|
|
|
# --------------------------------------------------------------------- classification
|
|
|
|
|
|
def test_from_adapter_returns_none_for_a_purely_low_rank_adapter(tmp_path):
|
|
path = write_adapter(
|
|
tmp_path, {
|
|
"blocks.0.attn.to_q.lora_A.weight": torch.zeros(4, 8),
|
|
"blocks.0.attn.to_q.lora_B.weight": torch.zeros(8, 4),
|
|
})
|
|
assert DenseLoRAPatch.from_adapter(path) is None
|
|
|
|
|
|
def test_from_adapter_returns_none_without_a_path():
|
|
assert DenseLoRAPatch.from_adapter(None) is None
|
|
|
|
|
|
def test_from_adapter_splits_additive_from_replacement(tmp_path):
|
|
path = write_adapter(
|
|
tmp_path, {
|
|
"blocks.0.attn.to_q.lora_A.weight": torch.zeros(4, 8),
|
|
"blocks.0.norm1.diff": torch.zeros(8),
|
|
"blocks.0.attn.to_q.diff_b": torch.zeros(8),
|
|
"blocks.0.scale_shift_table.diff_param": torch.zeros(8),
|
|
"blocks.0.attn.to_gate_compress.set_weight": torch.zeros(8, 8),
|
|
"blocks.0.extra_table.set_param": torch.zeros(8),
|
|
})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
assert patch is not None
|
|
# Weight/bias suffixes restore their parameter suffix; generic parameter
|
|
# payloads preserve the complete name.
|
|
assert set(patch._additive) == {
|
|
"blocks.0.norm1.weight",
|
|
"blocks.0.attn.to_q.bias",
|
|
"blocks.0.scale_shift_table",
|
|
}
|
|
assert set(patch._replacement) == {
|
|
"blocks.0.attn.to_gate_compress.weight",
|
|
"blocks.0.extra_table",
|
|
}
|
|
|
|
|
|
def test_dense_suffix_is_not_hidden_by_an_alpha_like_parameter_name(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.alpha.diff_param": torch.zeros(4)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
assert set(patch._additive) == {"blocks.0.alpha"}
|
|
|
|
|
|
def test_param_names_mapping_is_applied_to_dense_keys(tmp_path):
|
|
"""An adapter written against the published layout needs no separate rename table."""
|
|
path = write_adapter(tmp_path, {"blocks.0.ff.net.0.proj.diff": torch.zeros(4)})
|
|
|
|
def mapping(name):
|
|
return name.replace("ff.net.0.proj", "ff.fc_in"), None, None
|
|
|
|
patch = DenseLoRAPatch.from_adapter(path, mapping)
|
|
assert set(patch._additive) == {"blocks.0.ff.fc_in.weight"}
|
|
|
|
|
|
def test_official_wan_mixed_adapter_maps_dense_key_like_factors(tmp_path):
|
|
path = write_adapter(
|
|
tmp_path, {
|
|
"blocks.0.self_attn.q.lora_A.weight": torch.zeros(2, 4),
|
|
"blocks.0.self_attn.q.lora_B.weight": torch.zeros(4, 2),
|
|
"blocks.0.self_attn.k.diff": torch.zeros(4),
|
|
})
|
|
config = WanVideoConfig()
|
|
patch = DenseLoRAPatch.from_adapter(
|
|
path,
|
|
get_param_names_mapping(config.param_names_mapping),
|
|
lora_param_names_mapping=get_param_names_mapping(config.lora_param_names_mapping),
|
|
)
|
|
assert set(patch._additive) == {"blocks.0.to_k.weight"}
|
|
|
|
|
|
def _stub_fsdp_loading(monkeypatch):
|
|
monkeypatch.setattr(fsdp_load, "set_mixed_precision_policy", lambda **_: None)
|
|
monkeypatch.setattr(fsdp_load, "safetensors_weights_iterator", lambda *_args, **_kwargs: iter(()))
|
|
monkeypatch.setattr(fsdp_load, "load_model_from_full_model_state_dict", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(fsdp_load, "_maybe_quantize_model", lambda _model, **_kwargs: None)
|
|
|
|
|
|
def _load_tiny_model(model_cls, *, lora_path=None, lora_strength=1.0):
|
|
return fsdp_load.maybe_load_fsdp_model(
|
|
model_cls=model_cls,
|
|
init_params={},
|
|
weight_dir_list=[],
|
|
device=torch.device("cpu"),
|
|
hsdp_replicate_dim=1,
|
|
hsdp_shard_dim=1,
|
|
default_dtype=torch.float32,
|
|
param_dtype=torch.float32,
|
|
reduce_dtype=torch.float32,
|
|
training_mode=False,
|
|
pin_cpu_memory=False,
|
|
lora_path=lora_path,
|
|
lora_strength=lora_strength,
|
|
)
|
|
|
|
|
|
def test_fsdp_loader_forwards_lora_specific_mapping(monkeypatch):
|
|
captured = {}
|
|
|
|
class TinyModel(torch.nn.Module):
|
|
param_names_mapping = {r"^hf\.(.*)$": r"custom.\1"}
|
|
lora_param_names_mapping = {r"^official\.(.*)$": r"hf.\1"}
|
|
|
|
def capture_patch(cls,
|
|
lora_path,
|
|
param_names_mapping,
|
|
*,
|
|
lora_param_names_mapping=None,
|
|
strength=1.0):
|
|
captured["path"] = lora_path
|
|
captured["regular"] = param_names_mapping("hf.weight")[0]
|
|
captured["lora"] = lora_param_names_mapping("official.weight")[0]
|
|
captured["strength"] = strength
|
|
return None
|
|
|
|
monkeypatch.setattr(DenseLoRAPatch, "from_adapter", classmethod(capture_patch))
|
|
_stub_fsdp_loading(monkeypatch)
|
|
|
|
_load_tiny_model(TinyModel, lora_path="adapter.safetensors", lora_strength=0.75)
|
|
|
|
assert captured == {
|
|
"path": "adapter.safetensors",
|
|
"regular": "custom.weight",
|
|
"lora": "hf.weight",
|
|
"strength": 0.75,
|
|
}
|
|
|
|
|
|
def test_fsdp_loader_does_not_consult_lora_mapping_without_adapter(monkeypatch):
|
|
class TinyModel(torch.nn.Module):
|
|
param_names_mapping = {r"^hf\.(.*)$": r"custom.\1"}
|
|
|
|
def unexpected_patch(*_args, **_kwargs):
|
|
pytest.fail("the no-LoRA load path must not inspect an adapter")
|
|
|
|
monkeypatch.setattr(DenseLoRAPatch, "from_adapter", classmethod(unexpected_patch))
|
|
_stub_fsdp_loading(monkeypatch)
|
|
|
|
model = _load_tiny_model(TinyModel)
|
|
|
|
assert isinstance(model, TinyModel)
|
|
assert not hasattr(model, "lora_param_names_mapping")
|
|
|
|
|
|
def test_fsdp_loader_allows_adapter_without_lora_specific_mapping(monkeypatch):
|
|
captured = {}
|
|
|
|
class TinyModel(torch.nn.Module):
|
|
param_names_mapping = {r"^hf\.(.*)$": r"custom.\1"}
|
|
|
|
def capture_patch(cls,
|
|
lora_path,
|
|
param_names_mapping,
|
|
*,
|
|
lora_param_names_mapping=None,
|
|
strength=1.0):
|
|
captured["path"] = lora_path
|
|
captured["regular"] = param_names_mapping("hf.weight")[0]
|
|
captured["lora"] = lora_param_names_mapping
|
|
captured["strength"] = strength
|
|
return None
|
|
|
|
monkeypatch.setattr(DenseLoRAPatch, "from_adapter", classmethod(capture_patch))
|
|
_stub_fsdp_loading(monkeypatch)
|
|
|
|
_load_tiny_model(TinyModel, lora_path="adapter.safetensors")
|
|
|
|
assert captured == {
|
|
"path": "adapter.safetensors",
|
|
"regular": "custom.weight",
|
|
"lora": None,
|
|
"strength": 1.0,
|
|
}
|
|
|
|
|
|
def test_fused_target_is_refused_rather_than_guessed(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.attn.to_q.diff": torch.zeros(4)})
|
|
|
|
def fusing_mapping(name):
|
|
return "blocks.0.attn.to_qkv.weight", 0, 3
|
|
|
|
with pytest.raises(NotImplementedError, match="fused parameter"):
|
|
DenseLoRAPatch.from_adapter(path, fusing_mapping)
|
|
|
|
|
|
def test_two_keys_onto_one_parameter_is_an_error(tmp_path):
|
|
path = write_adapter(tmp_path, {
|
|
"blocks.0.norm1.diff": torch.zeros(4),
|
|
"blocks.0.norm1.set_weight": torch.zeros(4),
|
|
})
|
|
# `.diff` and `.set_weight` land in different tables, so they do not collide; two
|
|
# keys of the *same* kind must.
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
assert set(patch._additive) == {"blocks.0.norm1.weight"}
|
|
assert set(patch._replacement) == {"blocks.0.norm1.weight"}
|
|
|
|
|
|
# ---------------------------------------------------------------------- application
|
|
|
|
|
|
def test_apply_to_adds_the_delta(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.full((4, ), 0.25)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
out = patch.apply_to("blocks.0.norm1.weight", torch.ones(4))
|
|
assert torch.allclose(out, torch.full((4, ), 1.25))
|
|
|
|
|
|
def test_apply_to_supports_standalone_parameters(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.scale_shift_table.diff_param": torch.full((4, ), 0.25)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
out = patch.apply_to("blocks.0.scale_shift_table", torch.ones(4))
|
|
assert torch.allclose(out, torch.full((4, ), 1.25))
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for cross-device adapter loading")
|
|
def test_apply_to_moves_cpu_delta_to_base_tensor_device(tmp_path):
|
|
"""A CPU safetensors delta follows a directly loaded CUDA checkpoint tensor."""
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.full((4, ), 0.25)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
|
|
out = patch.apply_to("blocks.0.norm1.weight", torch.ones(4, device="cuda"))
|
|
|
|
assert out.device.type == "cuda"
|
|
assert torch.allclose(out.cpu(), torch.full((4, ), 1.25))
|
|
|
|
|
|
def test_apply_to_leaves_unrelated_parameters_untouched(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.full((4, ), 0.25)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
base = torch.ones(4)
|
|
assert patch.apply_to("blocks.9.norm1.weight", base) is base
|
|
|
|
|
|
def test_apply_to_sums_in_float32_so_a_small_delta_survives(tmp_path):
|
|
"""A bfloat16 add would quantize away a delta far below the base weight.
|
|
|
|
The VSA gates and the norm deltas both sit three to four orders of magnitude under
|
|
the weights they modify, which is exactly where bfloat16's 8-bit significand drops
|
|
the change entirely.
|
|
"""
|
|
delta = 1e-4
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.full((4, ), delta, dtype=torch.bfloat16)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
base = torch.ones(4, dtype=torch.bfloat16)
|
|
|
|
out = patch.apply_to("blocks.0.norm1.weight", base)
|
|
assert out.dtype == torch.float32
|
|
assert (out - 1.0).abs().min() > 0, "delta was rounded away"
|
|
|
|
naive = (base + torch.full((4, ), delta, dtype=torch.bfloat16))
|
|
assert torch.equal(naive, base), "precondition: the bfloat16 add is what loses it"
|
|
|
|
|
|
def test_apply_to_rejects_a_shape_mismatch(tmp_path):
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.zeros(8)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
with pytest.raises(ValueError, match="shape"):
|
|
patch.apply_to("blocks.0.norm1.weight", torch.zeros(4))
|
|
|
|
|
|
def test_replacement_for_supports_standalone_parameters(tmp_path):
|
|
table = torch.randn(6, 4)
|
|
path = write_adapter(tmp_path, {"blocks.0.extra_table.set_param": table})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
assert torch.equal(patch.replacement_for("blocks.0.extra_table"), table)
|
|
|
|
|
|
def test_replacement_for_returns_the_whole_tensor(tmp_path):
|
|
gate = torch.randn(6, 4)
|
|
path = write_adapter(tmp_path, {"blocks.0.attn.to_gate_compress.set_weight": gate})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
assert torch.equal(patch.replacement_for("blocks.0.attn.to_gate_compress.weight"), gate)
|
|
assert patch.replacement_for("blocks.0.attn.to_q.weight") is None
|
|
|
|
|
|
# ------------------------------------------------------------------------ reporting
|
|
|
|
|
|
def test_unapplied_keys_are_named_in_the_log(tmp_path, caplog):
|
|
path = write_adapter(tmp_path, {
|
|
"blocks.0.norm1.diff": torch.zeros(4),
|
|
"blocks.77.norm1.diff": torch.zeros(4),
|
|
})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
patch.apply_to("blocks.0.norm1.weight", torch.zeros(4))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
patch.report_unapplied()
|
|
|
|
assert "blocks.77.norm1.diff" in caplog.text
|
|
assert "blocks.0.norm1.diff" not in caplog.text
|
|
|
|
|
|
def test_a_fully_applied_payload_warns_about_nothing(tmp_path, caplog):
|
|
path = write_adapter(tmp_path, {"blocks.0.norm1.diff": torch.zeros(4)})
|
|
patch = DenseLoRAPatch.from_adapter(path)
|
|
patch.apply_to("blocks.0.norm1.weight", torch.zeros(4))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
patch.report_unapplied()
|
|
|
|
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
|