From 88fa75f441598fd25a4cd75c7357ebfda3848ea5 Mon Sep 17 00:00:00 2001 From: Aloys Jehwin Date: Tue, 4 Aug 2026 23:00:03 +0530 Subject: [PATCH 1/5] fix(lora): only drop adapter from _merged_adapters when unfused from all components Signed-off-by: Aloys Jehwin --- src/diffusers/loaders/lora_base.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/diffusers/loaders/lora_base.py b/src/diffusers/loaders/lora_base.py index d4c88d35924f..36ebd7900ef1 100644 --- a/src/diffusers/loaders/lora_base.py +++ b/src/diffusers/loaders/lora_base.py @@ -669,11 +669,21 @@ def unfuse_lora(self, components: list[str] | None = None, **kwargs): if issubclass(model.__class__, (ModelMixin, PreTrainedModel)): for module in model.modules(): if isinstance(module, BaseTunerLayer): - for adapter in set(module.merged_adapters): - if adapter and adapter in self._merged_adapters: - self._merged_adapters = self._merged_adapters - {adapter} module.unmerge() + # Only remove an adapter from _merged_adapters once it is no longer + # physically merged in any remaining loadable component. Removing it + # on the first unfused component would desync the set when the adapter + # is still fused into other components. + remaining_merged: set[str] = set() + for component_name in self._lora_loadable_modules: + component_model = getattr(self, component_name, None) + if component_model is not None and issubclass(component_model.__class__, (ModelMixin, PreTrainedModel)): + for module in component_model.modules(): + if isinstance(module, BaseTunerLayer): + remaining_merged.update(module.merged_adapters) + self._merged_adapters = self._merged_adapters & remaining_merged + def set_adapters( self, adapter_names: list[str] | str, From 8e1ca0139404e9262b255fc76a5d3308e8bae991 Mon Sep 17 00:00:00 2001 From: Aloys Jehwin Date: Thu, 6 Aug 2026 09:34:53 +0530 Subject: [PATCH 2/5] test(lora): add regression test for unfuse_lora partial component sync Signed-off-by: Aloys Jehwin --- tests/lora/test_lora_loader_utils.py | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/lora/test_lora_loader_utils.py b/tests/lora/test_lora_loader_utils.py index b35ffea80768..810e7b25e24a 100644 --- a/tests/lora/test_lora_loader_utils.py +++ b/tests/lora/test_lora_loader_utils.py @@ -100,3 +100,64 @@ def test_local_directory_with_multiple_files_warns_and_uses_first(tmp_path, monk assert weight_name == first_path.name assert "contains more than one weights file" in caplog.text + + +def test_unfuse_lora_partial_components_keeps_merged_adapters_in_sync(): + """Regression test for #14214. + + When unfuse_lora is called with a subset of components, _merged_adapters + should still reflect adapters that are physically fused in the remaining + components. Before the fix, it removed the adapter on the first unfuse + even if other components still had it baked in. + """ + import torch.nn as nn + from peft import LoraConfig + from peft.tuners.tuners_utils import BaseTunerLayer + from diffusers.loaders.lora_base import LoraBaseMixin + from diffusers.loaders.peft import PeftAdapterMixin + from diffusers.models.modeling_utils import ModelMixin + from diffusers.configuration_utils import ConfigMixin + + class TinyModel(ModelMixin, ConfigMixin, PeftAdapterMixin): + config_name = "config.json" + def __init__(self): + super().__init__() + self.linear = nn.Linear(8, 8) + + class FakePipeline(LoraBaseMixin): + _lora_loadable_modules = ["unet", "text_encoder"] + def __init__(self, unet, text_encoder): + self._merged_adapters = set() + self.unet, self.text_encoder = unet, text_encoder + + unet = TinyModel() + text_encoder = TinyModel() + config = LoraConfig(r=4, lora_alpha=4, target_modules=["linear"], init_lora_weights=False) + unet.add_adapter(config, adapter_name="adapter") + text_encoder.add_adapter(config, adapter_name="adapter") + + pipe = FakePipeline(unet, text_encoder) + pipe.fuse_lora(components=["unet", "text_encoder"], adapter_names=["adapter"]) + assert pipe.num_fused_loras == 1 + + # Unfuse only text_encoder — unet is still physically fused + pipe.unfuse_lora(components=["text_encoder"]) + + # _merged_adapters must still track the adapter (unet is still fused) + assert "adapter" in pipe.fused_loras, ( + "adapter should remain in fused_loras while unet is still fused" + ) + assert pipe.num_fused_loras == 1, ( + f"Expected 1 fused lora, got {pipe.num_fused_loras}" + ) + + # Confirm unet is physically still merged at the PEFT level + unet_still_merged = any( + isinstance(m, BaseTunerLayer) and len(m.merged_adapters) > 0 + for m in unet.modules() + ) + assert unet_still_merged, "unet should be physically merged at the PEFT level" + + # Now unfuse unet too — both components are done + pipe.unfuse_lora(components=["unet"]) + assert pipe.num_fused_loras == 0, "All components unfused, fused_loras should be empty" From 9145c5ff0cb52ee18ef17b2892165b4a8ebb65ce Mon Sep 17 00:00:00 2001 From: Aloys Jehwin Date: Thu, 6 Aug 2026 10:15:51 +0530 Subject: [PATCH 3/5] test(lora): keep regression test in test_lora_loader_utils.py (standalone unit) Signed-off-by: Aloys Jehwin --- tests/lora/test_lora_loader_utils.py | 25 +++++++------------------ tests/lora/utils.py | 1 + 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/tests/lora/test_lora_loader_utils.py b/tests/lora/test_lora_loader_utils.py index 810e7b25e24a..96b3fad178c7 100644 --- a/tests/lora/test_lora_loader_utils.py +++ b/tests/lora/test_lora_loader_utils.py @@ -103,12 +103,10 @@ def test_local_directory_with_multiple_files_warns_and_uses_first(tmp_path, monk def test_unfuse_lora_partial_components_keeps_merged_adapters_in_sync(): - """Regression test for #14214. + """Regression test for gh-14214. - When unfuse_lora is called with a subset of components, _merged_adapters - should still reflect adapters that are physically fused in the remaining - components. Before the fix, it removed the adapter on the first unfuse - even if other components still had it baked in. + Unfusing only a subset of components must keep _merged_adapters in sync + with the adapters still physically fused in the remaining components. """ import torch.nn as nn from peft import LoraConfig @@ -140,24 +138,15 @@ def __init__(self, unet, text_encoder): pipe.fuse_lora(components=["unet", "text_encoder"], adapter_names=["adapter"]) assert pipe.num_fused_loras == 1 - # Unfuse only text_encoder — unet is still physically fused pipe.unfuse_lora(components=["text_encoder"]) + assert "adapter" in pipe.fused_loras, "adapter should remain tracked while unet is still fused" + assert pipe.num_fused_loras == 1 - # _merged_adapters must still track the adapter (unet is still fused) - assert "adapter" in pipe.fused_loras, ( - "adapter should remain in fused_loras while unet is still fused" - ) - assert pipe.num_fused_loras == 1, ( - f"Expected 1 fused lora, got {pipe.num_fused_loras}" - ) - - # Confirm unet is physically still merged at the PEFT level unet_still_merged = any( isinstance(m, BaseTunerLayer) and len(m.merged_adapters) > 0 for m in unet.modules() ) - assert unet_still_merged, "unet should be physically merged at the PEFT level" + assert unet_still_merged, "unet should still be physically merged at the PEFT level" - # Now unfuse unet too — both components are done pipe.unfuse_lora(components=["unet"]) - assert pipe.num_fused_loras == 0, "All components unfused, fused_loras should be empty" + assert pipe.num_fused_loras == 0 diff --git a/tests/lora/utils.py b/tests/lora/utils.py index 38aec8ce4807..f2666d25179b 100644 --- a/tests/lora/utils.py +++ b/tests/lora/utils.py @@ -2548,3 +2548,4 @@ def test_lora_group_offloading_delete_adapters(self): # Clean up the hooks to prevent state leak if hasattr(denoiser, "_diffusers_hook"): denoiser._diffusers_hook.remove_hook(_GROUP_OFFLOADING, recurse=True) + From 5ec11b4d3b718a41bff094266f602c8a1710bf80 Mon Sep 17 00:00:00 2001 From: Aloys Jehwin Date: Fri, 7 Aug 2026 02:36:36 +0530 Subject: [PATCH 4/5] =?UTF-8?q?fix(lora):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20use=20isinstance,=20shorten=20comment,=20fix=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Aloys Jehwin --- src/diffusers/loaders/lora_base.py | 6 ++---- tests/lora/test_lora_loader_utils.py | 14 +++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/diffusers/loaders/lora_base.py b/src/diffusers/loaders/lora_base.py index 36ebd7900ef1..e39e96a6159d 100644 --- a/src/diffusers/loaders/lora_base.py +++ b/src/diffusers/loaders/lora_base.py @@ -672,13 +672,11 @@ def unfuse_lora(self, components: list[str] | None = None, **kwargs): module.unmerge() # Only remove an adapter from _merged_adapters once it is no longer - # physically merged in any remaining loadable component. Removing it - # on the first unfused component would desync the set when the adapter - # is still fused into other components. + # physically merged in any remaining loadable component. remaining_merged: set[str] = set() for component_name in self._lora_loadable_modules: component_model = getattr(self, component_name, None) - if component_model is not None and issubclass(component_model.__class__, (ModelMixin, PreTrainedModel)): + if isinstance(component_model, nn.Module): for module in component_model.modules(): if isinstance(module, BaseTunerLayer): remaining_merged.update(module.merged_adapters) diff --git a/tests/lora/test_lora_loader_utils.py b/tests/lora/test_lora_loader_utils.py index 96b3fad178c7..bc1f59ca92b3 100644 --- a/tests/lora/test_lora_loader_utils.py +++ b/tests/lora/test_lora_loader_utils.py @@ -17,9 +17,16 @@ import pytest import torch +import torch.nn as nn +from peft import LoraConfig +from peft.tuners.tuners_utils import BaseTunerLayer from safetensors.torch import save_file +from diffusers.configuration_utils import ConfigMixin from diffusers.loaders import StableDiffusionLoraLoaderMixin, lora_base +from diffusers.loaders.lora_base import LoraBaseMixin +from diffusers.loaders.peft import PeftAdapterMixin +from diffusers.models.modeling_utils import ModelMixin LORA_KEY = "unet.test.lora_A.weight" @@ -108,13 +115,6 @@ def test_unfuse_lora_partial_components_keeps_merged_adapters_in_sync(): Unfusing only a subset of components must keep _merged_adapters in sync with the adapters still physically fused in the remaining components. """ - import torch.nn as nn - from peft import LoraConfig - from peft.tuners.tuners_utils import BaseTunerLayer - from diffusers.loaders.lora_base import LoraBaseMixin - from diffusers.loaders.peft import PeftAdapterMixin - from diffusers.models.modeling_utils import ModelMixin - from diffusers.configuration_utils import ConfigMixin class TinyModel(ModelMixin, ConfigMixin, PeftAdapterMixin): config_name = "config.json" From 80fcad462da0e5ece8c4b700142a42dec8902871 Mon Sep 17 00:00:00 2001 From: Aloys Jehwin Date: Fri, 7 Aug 2026 02:44:59 +0530 Subject: [PATCH 5/5] fix: restore tests/lora/utils.py to main (remove stray trailing newline) Signed-off-by: Aloys Jehwin --- tests/lora/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/lora/utils.py b/tests/lora/utils.py index f2666d25179b..38aec8ce4807 100644 --- a/tests/lora/utils.py +++ b/tests/lora/utils.py @@ -2548,4 +2548,3 @@ def test_lora_group_offloading_delete_adapters(self): # Clean up the hooks to prevent state leak if hasattr(denoiser, "_diffusers_hook"): denoiser._diffusers_hook.remove_hook(_GROUP_OFFLOADING, recurse=True) -