huggingface / huggingface/peft
hotswap_adapter: silent no-op when adapter is merged; KeyError on modules_to_save checkpoints; stale peft_config breaks save/load after swapping different rank/alpha
- Dominant language
- Python
- Stars
- 21.7k
- Forks
- 2.5k
- Avg merge
- 4d 12h
- Merged PRs (30d)
- 59
Description
Hi PEFT team! :wave: Thanks for the excellent library — and for the recent hot-swap feature, which we think is a big deal for serving many adapters.
As part of a broader **read-only audit of PEFT's subsystems** at commit `5d602fdb` (2026-08-21), we took a close look at `src/peft/utils/hotswap.py` and found three correctness gaps, each reproduced on CPU. Per the repo's [AGENTS.md](https://github.com/huggingface/peft/blob/main/AGENTS.md): **this audit was AI-assisted**, every finding below was verified with runnable reproductions executed by the submitting human, and this report is human-reviewed. We searched the tracker first and believe these are unreported.
**Environment:** peft `0.20.1.dev0` @ `5d602fdb` · transformers `5.15.1` · torch `2.13.0` (CPU) · Python 3.12 · macOS arm64
Shared setup for all snippets:
```python
import tempfile, torch
from peft import LoraConfig, get_peft_model, PeftModel
from peft.utils.hotswap import hotswap_adapter
from transformers import LlamaConfig, LlamaForCausalLM
def tiny_llama():
return LlamaForCausalLM(LlamaConfig(
vocab_size=64, hidden_size=32, intermediate_size=64,
num_hidden_layers=2, num_attention_heads=4, num_key_value_heads=2))
def make_adapter(path, r=4, alpha=8, seed=0):
torch.manual_seed(seed)
m = get_peft_model(tiny_llama(), LoraConfig(
r=r, lora_alpha=alpha, target_modules=["q_proj", "v_proj"], lora_dropout=0.0))
for n, p in m.named_parameters():
if "lora_" in n:
torch.nn.init.normal_(p, std=0.1)
m.save_pretrained(path)
IDS = torch.randint(0, 64, (2, 8))
```
---
### 1) Hot-swapping a merged adapter silently keeps the OLD adapter active; unmerge afterwards leaves wrong base weights
```python
d0, d1 = tempfile.mkdtemp(), tempfile.mkdtemp()
make_adapter(d0, seed=0); make_adapter(d1, seed=1)
model = PeftModel.from_pretrained(tiny_llama(), d0)
before = model(IDS).logits
model.merge_adapter() # fold current adapter into base weights
hotswap_adapter(model, d1, "default") # swap weights in place
after = model(IDS).logits
print((before - after).abs().max()) # ~1e-07 → swap had ZERO effect
model.unmerge_adapter()
# outputs now behave like the OLD adapter again, not like the newly swapped-in adapter
```
**Actual:** output is bit-identical to pre-swap while merged; after `unmerge_adapter()` the model reverts towards old-adapter behavior instead of matching the new adapter (freshly loading `d1` gives clearly different outputs).
**Expected:** the swapped adapter takes effect, or `hotswap_adapter` refuses to operate on a merged model.
**Root cause:** `hotswap.py` never inspects merge state — there is no reference to `merged_adapters`/`merged` anywhere in the module (verified by search). While merged, LoRA forward uses base weights only (`src/peft/tuners/lora/layer.py:1046-1047`), so the freshly copied tensors are invisible; on unmerge, ΔW recomputed from the *new* weights (`layer.py:990-1003`) is subtracted from a base containing the *old* ΔW.
Would you prefer a **raise** here, or an automatic unmerge-before-swap? Happy to implement whichever you prefer.
### 2) Checkpoints containing `modules_to_save` crash hotswap with a bare `KeyError`
```python
cfg = lambda: LoraConfig(r=4, lora_alpha=8, target_modules=["q_proj"],
modules_to_save=["lm_head"], lora_dropout=0.0)
d0, d1 = tempfile.mkdtemp(), tempfile.mkdtemp()
get_peft_model(tiny_llama(), cfg()).save_pretrained(d0)
get_peft_model(tiny_llama(), cfg()).save_pretrained(d1)
loaded = PeftModel.from_pretrained(tiny_llama(), d0)
hotswap_adapter(loaded, d1, "default")
# KeyError: 'base_model.model.lm_head.weight'
```
**Root cause:** the dry run (`hotswap.py:463-480`) only tracks keys carrying the `lora_` + adapter-name prefix (`:460`), but `modules_to_save` weights are stored without any prefix/adapter name (`utils/save_and_load.py:330-331`, `utils/other.py:780-787`). The lookup succeeds via `attrgetter`, then `missing_keys.remove(key)` explodes at `hotswap.py:474`. The reverse direction (pure-LoRA → checkpoint adding LoRA on the mts module) fails with a misleading "unexpected keys" error even though docs allow subset targeting. Even without the crash, mts weights could never be swapped by this path.
**Suggestion:** filter the incoming state dict to `parameter_prefix` keys up front and raise an explicit *"modules_to_save weights cannot be hot-swapped"* error; note the limitation in `docs/source/package_reference/hotswap.md`.
### 3) Swapping adapters with different rank/alpha leaves `peft_config` stale ⇒ `save_pretrained` writes an unloadable artifact
```python
d0, d1, d3 = tempfile.mkdtemp(), tempfile.mkdtemp(), tempfile.mkdtemp()
make_adapter(d0, r=4, alpha=8); make_adapter(d1, r=16, alpha=32)
model = PeftModel.from_pretrained(tiny_llama(), d0)
hotswap_adapter(model, d1, "default")
print(model.peft_config["default"].r) # 4 ← stale; live tensors are rank-16
model.save_pretrained(d3)
PeftModel.from_pretrained(tiny_llama(), d3)
# RuntimeError: size mismatch for ...lora_A.default.weight
```
PEFT itself writes a checkpoint whose `adapter_config.json` no longer matches its own tensors — reloading that artifact fails. Additionally, `unscale_layer(None)` resets scaling from the stale layer-level `r`/`lora_alpha` (observed 2.0 → 1.0 where 2.0 was correct).
**Root cause:** the incoming `config` stays a local variable (`hotswap.py:685-704` never writes back into `model.peft_config[adapter_name]`), and `_update_scaling` (`:501-509`) touches only per-tensor `scaling` dicts — the layer-level `r`/`lora_alpha`/`use_rslora` bookkeeping used by `set_scale`/`unscale_layer` (`tuners/lora/layer.py:754-787`) keeps describing the old adapter.
**Suggestion:** on success, assign `model.peft_config[adapter_name] = config` and refresh per-layer rank/alpha fields (or route scaling updates through an API that maintains them).
---
### Two smaller observations (happy to expand if useful)
- On a `torch.compile`d model, a rank-*growing* swap silently triggers a full recompilation instead of raising toward `prepare_model_for_compiled_hotswap` (`hotswap.py:553-566` — the incompatible-shape branch raises informatively, but the storage-swap fallback path doesn't).
- A swap is not transactional: shape/dtype checks happen lazily inside the mutating loop while the dry run validates key existence only (`hotswap.py:463-474`), so a mid-copy failure can leave the model half-swapped.
All three main findings reproduce deterministically on CPU with the snippets above. We'd be glad to prepare fixes with regression tests (test-first, per your contribution guide) for whichever of these you'd like addressed — just point us at your preferred semantics. Thanks for considering! 🙏
Contributor guide
Assessment
This issue has not been assessed yet.