Fix _merged_adapters bookkeeping desync on partial unfuse_lora(components=...)#14215
Fix _merged_adapters bookkeeping desync on partial unfuse_lora(components=...)#14215ErenAta16 wants to merge 5 commits into
Conversation
…ents=...) _merged_adapters is a single set shared across the whole pipeline, while actual merge state is tracked per component by PEFT. When the same adapter is fused into multiple components (the default when fuse_lora() is called with no adapter_names) and later only some of those components are unfused via unfuse_lora(components=[...]), the old code removed the adapter name from _merged_adapters unconditionally, even when it was still physically merged into the base weights of the untouched component(s). num_fused_loras/fused_loras would then report the pipeline as having nothing (or less) fused than it actually does. Track unmerge candidates during the per-component unmerge loop, then only drop an adapter from the pipeline-wide set once confirmed unmerged in every _lora_loadable_modules component via the new _is_adapter_merged_in_any_component helper. Also adds a regression test (test_fuse_unfuse_partial_components_keeps_merged_adapter_bookkeeping in tests/lora/utils.py) covering the partial-components case, since the existing fuse/unfuse tests only ever pass every loadable component at once. Fixes huggingface#14214.
| if "text_encoder" not in self.pipeline_class._lora_loadable_modules: | ||
| return |
There was a problem hiding this comment.
We should use skip rather than returning so that the test properly gets skipped.
There was a problem hiding this comment.
Good catch, fixed in cacdeee. The old return was reporting a pass for pipelines that don't support text encoder LoRAs at all, instead of skipping. Switched to self.supports_text_encoder_loras + pytest.skip(...), matching the convention already used elsewhere in this file.
There was a problem hiding this comment.
🤗 Serge says:
The core fix in lora_base.py is correct and well-reasoned: tracking unmerge_candidates and only dropping an adapter from the pipeline-wide _merged_adapters once _is_adapter_merged_in_any_component confirms it's gone from every loadable component correctly resolves the partial-unfuse_lora bookkeeping desync described in #14214. The None-guard from the old code (if adapter and ...) is safely subsumed by the if adapter not in self._merged_adapters: continue filter, so no regression there.
Tests
- The new test uses a bare
if "text_encoder" not in ... : return, which makes the test report as passed (green) on pipelines without a text encoder rather than skipped. The established convention in this file is thesupports_text_encoder_lorasflag pluspytest.skip(...)(see lines 385, 521). Using that keeps the skip visible and consistent, and correctly reflects pipelines that carry atext_encodermodule but don't support text-encoder LoRAs.
Style (minor)
_is_adapter_merged_in_any_componenthas a single caller. Per the repo's coding-style guidance (inline single-use private helpers), this could be inlined intounfuse_lora, though the helper is self-contained and the current form is readable — not blocking.
No correctness or security concerns; the description's claims match the diff.
serge v0.1.0 · model: claude-opus-4-8 · 12 LLM turns · 11 tool calls · 59.1s · 180773 in / 3307 out tokens
| component and then unfusing only *some* of them should not report the adapter as fully unfused, | ||
| it's still merged into the untouched component(s). | ||
| """ | ||
| if "text_encoder" not in self.pipeline_class._lora_loadable_modules: |
There was a problem hiding this comment.
Prefer the existing skip convention over a bare return. As written, this test reports as passed for pipelines that don't have a text_encoder loadable module, which hides the fact that it never exercised the scenario. The rest of this file gates on the supports_text_encoder_loras flag with pytest.skip(...) (e.g. lines 385, 521), which is also more accurate — a pipeline can have a text_encoder module but not support text-encoder LoRAs.
| if "text_encoder" not in self.pipeline_class._lora_loadable_modules: | |
| if not self.supports_text_encoder_loras: | |
| pytest.skip("Skipping test as text encoder LoRAs are not currently supported.") |
There was a problem hiding this comment.
Thanks, I looked at this again against AGENTS.md's single-caller inlining guidance. I went back and forth, but decided to leave _is_adapter_merged_in_any_component as a separate method: inlining it means a two-level nested loop with a boolean flag sitting inside the unfuse_lora unmerge-candidate loop, which reads worse than a named, docstring'd predicate, even with one caller. Happy to inline it if you'd still prefer that on a second look, but wanted to explain the call rather than silently leave it.
|
Process note, read after opening this: I found Self-review against
One thing I did not fix and want to flag rather than silently leave: |
Matches the established skip pattern used elsewhere in this file instead of a silent early return, per review feedback.
BenjaminBossan
left a comment
There was a problem hiding this comment.
Generally looks good and makes sense. Just some small comments.
unfuse_lora's existing docstring doesn't mention that _merged_adapters/num_fused_loras is pipeline-wide rather than per-component
Yes, that's worth documenting.
| per-component PEFT merge state after a partial `unfuse_lora(components=...)` call.""" | ||
| for component in self._lora_loadable_modules: | ||
| model = getattr(self, component, None) | ||
| if model is None or not issubclass(model.__class__, (ModelMixin, PreTrainedModel)): |
There was a problem hiding this comment.
Why issubclass(model.__class__, ...) and not isinstance(model, ...)?
There was a problem hiding this comment.
That's copied from the existing pattern right above it in the same unfuse_lora loop (and used throughout this file, 16 occurrences), not a deliberate choice on my part - happy to switch to isinstance() here if you'd rather not perpetuate it, but left it matching the surrounding code for now.
There was a problem hiding this comment.
I see. IMO, isinstance makes more sense but maybe I'm missing something. I'll leave that decision up to the Diffusers maintainers.
There was a problem hiding this comment.
Sounds like the consensus is to leave it as-is for now and revisit library-wide later, so no change here.
|
|
||
| # Now unfuse the remaining component; bookkeeping should correctly drop to 0. | ||
| pipe.unfuse_lora(components=[denoiser_component_name]) | ||
| self.assertTrue(pipe.num_fused_loras == 0, f"{pipe.num_fused_loras=}, {pipe.fused_loras=}") |
There was a problem hiding this comment.
For completeness, I would also check "adapter-1" is not in pipe.fused_loras.
There was a problem hiding this comment.
Added in f6c54bc - now also asserts adapter-1 is out of fused_loras once it's unfused from every component.
… test - unfuse_lora docstring now notes num_fused_loras/fused_loras are tracked pipeline-wide, not per component. - Regression test now also asserts the adapter is absent from fused_loras after it's been unfused from every component.
|
Pushed f6c54bc addressing the review: added the pipeline-wide vs per-component note to unfuse_lora's docstring, and replied inline on the two other points. |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the changes, just one nit.
| per-component PEFT merge state after a partial `unfuse_lora(components=...)` call.""" | ||
| for component in self._lora_loadable_modules: | ||
| model = getattr(self, component, None) | ||
| if model is None or not issubclass(model.__class__, (ModelMixin, PreTrainedModel)): |
There was a problem hiding this comment.
I see. IMO, isinstance makes more sense but maybe I'm missing something. I'll leave that decision up to the Diffusers maintainers.
| Whether to unfuse the text encoder LoRA parameters. If the text encoder wasn't monkey-patched with the | ||
| LoRA parameters then it won't have any effect. | ||
|
|
||
| Note that `num_fused_loras`/`fused_loras` (backed by `self._merged_adapters`) are tracked pipeline-wide, |
There was a problem hiding this comment.
Let's not mention implementation details here, as those don't matter to the user. It's sufficient to mention the part about fused_loras, as this is what's exposed to the uesr.
There was a problem hiding this comment.
I think it's fine to keep it as is and it can be replaced library-wide to fix on a common pattern.
There was a problem hiding this comment.
Fair, trimmed in b52aa28 - just the fused_loras behavior now, dropped the _merged_adapters mention.
|
@bot /style |
|
Style bot fixed some files and pushed the changes. |
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
Drop the _merged_adapters implementation detail per review feedback; fused_loras is what's actually exposed to callers.
Fixes #14214.
What does this PR do?
LoraBaseMixin._merged_adapters(backingnum_fused_loras/fused_loras) is a single set shared across the whole pipeline, but merge state itself is tracked per component by PEFT.fuse_lora()/unfuse_lora()both accept acomponentsargument to operate on a subset of_lora_loadable_modules. When the same adapter is fused into multiple components (the default whenfuse_lora()is called with noadapter_names) and later only some of those components are unfused,unfuse_lora()dropped the adapter name from_merged_adaptersunconditionally, even though it's still physically merged into the base weights of the untouched component(s).Fix
Track which adapters got unmerged during the per-component loop, then only drop an adapter from the pipeline-wide
_merged_adaptersset once it's confirmed unmerged in every_lora_loadable_modulescomponent (new_is_adapter_merged_in_any_componenthelper), not just the ones passed to this particular call.Testing
Added
test_fuse_unfuse_partial_components_keeps_merged_adapter_bookkeepingto the sharedPeftLoraLoaderMixinTestsmixin intests/lora/utils.py, so it runs across the pipeline-specific test files that inherit from it (skips pipelines without atext_encoderloadable module, since the scenario needs at least two independent components). Ran it againstStableDiffusionLoRATests:Also ran the full existing fuse/unfuse suite for the same pipeline to check for regressions in the common (full-components) case:
5 passed, 1 skipped (torch_compile variant, unrelated), 1 xpassed (pre-existing marker, unrelated), no regressions.
I verified the underlying bug and the fix directly with the actual
LoraBaseMixin/PeftAdapterMixinclasses and a realpeft.LoraConfigbefore writing the pipeline-level test, in case that's useful: