mirror of
https://github.com/huggingface/diffusers.git
synced 2026-10-24 20:59:27 +03:00
* [LoRA] add LoKr adapter support (Z-Image, Flux2/Klein) Adds loading of LoKr (LyCORIS Kronecker product) adapters: - `load_lora_adapter` detects `lokr_` keys and injects a peft `LoKrConfig`, inferred from the tensor shapes via `_create_lokr_config` (decompose factor, per-module rank/alpha patterns). - State dict conversions for the formats in the wild: ai-toolkit Z-Image (dotted diffusers paths under `diffusion_model.`), ai-toolkit BFL Flux2 (fused qkv), LyCORIS underscore format, and bare dotted diffusers paths. - BFL fused-QKV LoKr cannot be split exactly into separate Q/K/V Kronecker factors, so `Flux2LoraLoaderMixin.load_lora_weights` fuses the model's QKV projections and maps the adapter 1:1 (exact). - Alpha follows the LyCORIS convention: scaling applies only to rank-decomposed factors and is baked into the weights at conversion. Fixes #13221 * [LoRA] LoKr review follow-ups: adapter-agnostic error message, refuse fused-QKV load over an unfused adapter - "Invalid adapter checkpoint. We currently support LoRA and LoKr." replaces the message that still said "LoRA checkpoint" and described the substring check. - Loading a fused-QKV LoKr checkpoint now refuses when an adapter is already injected on to_q/to_k/to_v or add_{q,k,v}_proj: fuse_qkv_projections() would replace those modules and orphan it. Covered by a test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * [LoRA] rename the LoKr tests to test_lokr_conversion.py The file exercises checkpoint conversion and loading for LoKr adapters, not LoRA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * [LoRA] LoKr review follow-ups: scope to Z-Image/Flux2, model-level fused QKV, LoKrTesterMixin - Only the Z-Image and Flux2 loader mixins accept LoKr checkpoints, the two that convert them; the other mixins are back to their LoRA-only check. - The fused-QKV handling moves to `_maybe_fuse_qkv_projections_for_lokr` in peft_utils, called from `load_lora_adapter`, so the model fuses its projections whichever entry point loads the adapter. The quantized-model error explains why. - `_bake_lokr_alpha` becomes `_bake_lokr_alpha_` as it edits the dict in place. - The BFL Flux2 converter no longer accepts expanded diffusers block names under `diffusion_model.`: no LoKr checkpoint uses that layout; diffusers-named checkpoints have no prefix and take the generic converter. - The LyCORIS converter names the keys it does not recognize. - Tests move from tests/lora to a `LoKrTesterMixin` in tests/models/testing_utils, with the checkpoint formats tested on the Flux2 and Z-Image model test classes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * [LoRA] mark ZImageLoraLoaderMixin.load_lora_weights as copied from Flux2 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Sayak Paul <spsayakpaul@gmail.com>
140 lines
6.1 KiB
Python
140 lines
6.1 KiB
Python
# coding=utf-8
|
|
# Copyright 2026 HuggingFace Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.nn as nn
|
|
|
|
from diffusers.utils.import_utils import is_peft_available
|
|
|
|
from ...testing_utils import assert_tensors_close, is_lora, require_peft_backend, torch_device
|
|
from .common import BaseModelOutputMixin
|
|
|
|
|
|
if is_peft_available():
|
|
from peft.tuners.lokr.layer import LoKrLayer, factorization
|
|
|
|
from diffusers.loaders.peft import PeftAdapterMixin
|
|
|
|
|
|
def make_lokr_factors(out_features, in_features, factor=4, rank=None):
|
|
"""
|
|
Random LoKr factors for an `out_features x in_features` layer, factorized like peft does with `decompose_factor`.
|
|
With `rank`, the right factor is stored rank-decomposed (`lokr_w2_a @ lokr_w2_b`).
|
|
|
|
Returns the factors keyed by their state dict suffix, and the delta weight they encode.
|
|
"""
|
|
out_l, out_k = factorization(out_features, factor)
|
|
in_m, in_n = factorization(in_features, factor)
|
|
w1 = torch.randn(out_l, in_m)
|
|
if rank is None:
|
|
w2 = torch.randn(out_k, in_n)
|
|
return {"lokr_w1": w1, "lokr_w2": w2}, torch.kron(w1, w2)
|
|
w2_a, w2_b = torch.randn(out_k, rank), torch.randn(rank, in_n)
|
|
return {"lokr_w1": w1, "lokr_w2_a": w2_a, "lokr_w2_b": w2_b}, torch.kron(w1, w2_a @ w2_b)
|
|
|
|
|
|
def check_lokr_deltas(model, expected_deltas, adapter_name="default", atol=1e-5):
|
|
"""Check that exactly the expected modules carry a LoKr adapter, each with the expected delta weight."""
|
|
named_modules = dict(model.named_modules())
|
|
adapted = {name for name, module in named_modules.items() if isinstance(module, LoKrLayer)}
|
|
assert adapted == set(expected_deltas)
|
|
for name, expected in expected_deltas.items():
|
|
delta = named_modules[name].get_delta_weight(adapter_name).cpu()
|
|
assert_tensors_close(delta, expected, atol=atol, rtol=0, msg=f"Wrong LoKr delta on {name}")
|
|
|
|
|
|
@is_lora
|
|
@require_peft_backend
|
|
class LoKrTesterMixin(BaseModelOutputMixin):
|
|
"""
|
|
Mixin class for testing loading LoKr (LyCORIS Kronecker product) adapters with `load_lora_adapter`.
|
|
|
|
Expected from config mixin:
|
|
- model_class: The model class to test
|
|
|
|
Expected methods from config mixin:
|
|
- get_init_dict(): Returns dict of arguments to initialize the model
|
|
- get_dummy_inputs(): Returns dict of inputs to pass to the model forward pass
|
|
|
|
Pytest mark: lora
|
|
Use `pytest -m "not lora"` to skip these tests
|
|
"""
|
|
|
|
def setup_method(self):
|
|
if not issubclass(self.model_class, PeftAdapterMixin):
|
|
pytest.skip(f"PEFT is not supported for this model ({self.model_class.__name__}).")
|
|
|
|
def _flatten_output(self, output):
|
|
# Some models (e.g. Z-Image) return a list of per-sample tensors.
|
|
if isinstance(output, (list, tuple)):
|
|
return torch.cat([t.flatten() for t in output])
|
|
return output
|
|
|
|
def _model_output(self, model, inputs_dict):
|
|
return self._flatten_output(model(**inputs_dict, return_dict=False)[0])
|
|
|
|
def get_lokr_state_dict(self, model, rank=None):
|
|
"""
|
|
A peft-format LoKr state dict on every attention `to_q` and `to_v`, with the expected delta of each. With
|
|
`rank`, the `to_v` factors are rank-decomposed, so the config inference has to mix both kinds.
|
|
"""
|
|
state_dict, expected_deltas = {}, {}
|
|
for name, module in model.named_modules():
|
|
projection = name.rsplit(".", 1)[-1]
|
|
if isinstance(module, nn.Linear) and projection in ("to_q", "to_v"):
|
|
factors, delta = make_lokr_factors(
|
|
module.out_features, module.in_features, rank=rank if projection == "to_v" else None
|
|
)
|
|
state_dict.update({f"{name}.{suffix}": weight for suffix, weight in factors.items()})
|
|
expected_deltas[name] = delta
|
|
return state_dict, expected_deltas
|
|
|
|
@pytest.mark.parametrize("rank", [None, 1], ids=["full_factors", "rank_decomposed_factors"])
|
|
@torch.no_grad()
|
|
def test_lokr_adapter_loads_exact_kronecker_deltas(self, base_model_output, rank):
|
|
torch.manual_seed(0)
|
|
model = self.model_class(**self.get_init_dict()).eval().to(torch_device)
|
|
state_dict, expected_deltas = self.get_lokr_state_dict(model, rank=rank)
|
|
|
|
model.load_lora_adapter(state_dict, prefix=None, adapter_name="default")
|
|
|
|
check_lokr_deltas(model, expected_deltas)
|
|
output = self._model_output(model, self.get_dummy_inputs())
|
|
base_output = self._flatten_output(base_model_output)
|
|
assert not torch.allclose(output, base_output, atol=1e-4, rtol=1e-4), "Output should differ with LoKr"
|
|
|
|
@torch.no_grad()
|
|
def test_lokr_unload_restores_base_output(self, base_model_output):
|
|
torch.manual_seed(0)
|
|
model = self.model_class(**self.get_init_dict()).eval().to(torch_device)
|
|
state_dict, _ = self.get_lokr_state_dict(model)
|
|
|
|
model.load_lora_adapter(state_dict, prefix=None, adapter_name="default")
|
|
model.unload_lora()
|
|
|
|
assert not any(isinstance(module, LoKrLayer) for module in model.modules())
|
|
output = self._model_output(model, self.get_dummy_inputs())
|
|
assert_tensors_close(output, self._flatten_output(base_model_output), atol=1e-4, rtol=1e-4)
|
|
|
|
def test_lokr_hotswap_raises(self):
|
|
torch.manual_seed(0)
|
|
model = self.model_class(**self.get_init_dict()).eval().to(torch_device)
|
|
state_dict, _ = self.get_lokr_state_dict(model)
|
|
model.load_lora_adapter(state_dict, prefix=None, adapter_name="default")
|
|
|
|
with pytest.raises(ValueError, match="Hotswapping LoKr adapters is not supported"):
|
|
model.load_lora_adapter(state_dict, prefix=None, adapter_name="default", hotswap=True)
|