fix(shadow): sync lm_head trainability on set_adapter for CAUSAL_LM - #3632
fix(shadow): sync lm_head trainability on set_adapter for CAUSAL_LM#3632Sravanjangam wants to merge 4 commits into
Conversation
Per BenjaminBossan green light on huggingface#3626 — only follow Benjamin, ignore cananoo. Problem: _sync_shadow_module_trainability only walks shadow_backbone/ projection/head, but CAUSAL_LM reuses base lm_head via modules_to_save (not in shadow_head). After set_adapter, lm_head stays frozen — silent correctness bug, grad is None, norm 0. Solution: In _sync_shadow_module_trainability, also sync base lm_head when any CAUSAL_LM adapter requested lm_head via modules_to_save. Trainable iff active adapter is CAUSAL_LM with lm_head and not inference_mode; otherwise frozen. Only touches head if some adapter ever requested it, to avoid touching unrelated models. Tests: Manual 5-run repro — before fix 0/5 OK (BUG, norm 0), after fix 5/5 OK (norm ~4.27), inference_mode and no-lm_head cases correct. Fixes huggingface#3626 AI assistance used — human reviewed every line, tests run.
…_adapter Adds test_shadow_lm_head_trainable_after_set_adapter and test_shadow_lm_head_frozen_in_inference_mode for huggingface#3626. Both pass with HF_HUB_CACHE=/tmp/hf_cache (2 passed). AI assistance used.
|
@SeanLee97 Do you have time to check this? |
|
Hi @BenjaminBossan and @SeanLee97 thanks for taking a look! Happy to hear any suggestions and ready to improve on the change let me know what you'd like to see. |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the fix, it's almost good to go, just two small comments from my side.
| for p in head.parameters(): | ||
| p.requires_grad = should_train_head |
There was a problem hiding this comment.
Wouldn't this work?
| for p in head.parameters(): | |
| p.requires_grad = should_train_head | |
| head.requires_grad_(should_train_head) |
| "lm_head" in (c.modules_to_save or []) and str(c.task_type) == str(TaskType.CAUSAL_LM) | ||
| for c in self.peft_config.values() |
There was a problem hiding this comment.
This pretty much duplicates the logic just 3 lines above. Let's rewrite this block to determine this once.
…_ (per review) Per BenjaminBossan 2026-09-04 on huggingface#3632: use head.requires_grad_(should_train_head) instead of the per-parameter loop, and compute the block once instead of duplicating the modules_to_save/CAUSAL_LM predicate 3 lines apart. Single loop over peft_config items sets manages_head (any adapter routes lm_head via modules_to_save for CAUSAL_LM) and should_train_head (an active, non-inference adapter does). Behavior identical: head untouched when no adapter manages it; frozen in inference mode; trainable otherwise. Verification: - pytest tests/test_shadow.py -k 'lm_head or set_adapter' -> 3 passed - ruff check + format clean; doc-builder style --check_only exit 0 Fixes huggingface#3632 / PR huggingface#3632. AI assistance used -- human reviewed every line.
|
Hi @BenjaminBossan — both addressed in 1. 2. Single-pass block ( Verification on
|
|
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. |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the updates. I did another review and checked the tests you added. It turns out they're already passing on the current main branch. So I'm not sure if the fix is unnecessary or if the tests are too weak. Remember that for bugfix PRs, the tests should first be written so that they fail on main and then ensure that they pass with the fix.
…apter has no copy ModulesToSaveWrapper.delete_adapter early-returned when the deleted adapter never managed the module, dropping the fallback sync. Deleting the active adapter then left the fallback's copy frozen while active (found via ShadowPEFT huggingface#3626: default manages lm_head, other does not, delete other while active -> head frozen). The fix mirrors the normal path below (set_adapter to the fallback), so deleted-with-copy and deleted-without-copy behave identically. Verification: - New test_shadow_lm_head_trainable_after_delete_fallback FAILS on main 9c16ee6, PASSES with fix (requires_grad + grad-norm asserts) - Full tests/test_shadow.py: 40 passed, 1 skipped - Adjacent delete_adapter/modules_to_save selections: 926 passed; 7 FrodConfig failures reproduce identically on clean main (pre-existing) - ruff check + format clean; doc-builder style --check_only exit 0 - Removed the never-firing lm_head block + two main-passing tests from the earlier revision (dead str() == str() comparison, always False) Fixes huggingface#3626. PR huggingface#3632. AI assistance used -- human reviewed every line.
|
Hi @BenjaminBossan — full update on your 09-07 review, with a correction to my earlier note below. Your review was right about the But one path over, the invariant genuinely breaks on main — delete-fallback (FAILS on main, PASSES with fix): Root cause ( Verification: new bug-fix test asserts |
Hi @BenjaminBossan — thanks for ShadowPEFT and for the quick nod on #3626! This is the small trainability-sync fix we discussed, Only following your green light here.
Fixes #3626
Problem
ShadowModel._sync_shadow_module_trainability(src/peft/tuners/shadow/model.py:731-749) only walksshadow_backbone/shadow_projection/shadow_headto fliprequires_grad. Fortask_type="CAUSAL_LM"ShadowPEFT intentionally does not create ashadow_head— it reuses the frozen baselm_headvia_resolve_shadow_head(model.py:787-793) and lets users train it viamodules_to_save=["lm_head"](docstring491-497, test295-308).lm_headlives onself.model, not in anyshadow_*container, so it is never visited by the sync. After the firstset_adapter("other")orset_adapter("default", inference_mode=True), the shadow modules are flipped butlm_headstays in its previousrequires_gradstate — frozen when it should be trainable (or vice versa). Loss still decreases (adapter trains), so the bug is invisible until eval — checkpoint saves an untouched head.Who can trigger: Every
ShadowConfig(CAUSAL_LM, modules_to_save=["lm_head"])workflow that callsset_adapter(multi-adapter training, inference_mode toggling). Single-adapter withoutset_adapteris fine, which is whytest_save_includes_trainable_lm_headpasses.Concrete failure:
set_adapter:lm_head.weight.requires_grad=True,grad.norm()≈4.27set_adapter("other")(bug):requires_grad=False,grad=None,norm 0— head frozen the whole fine-tuneSolution
In
_sync_shadow_module_trainability, also sync the baselm_headwhen it is the resolved shadow head forCAUSAL_LM. The approach is minimal and matches the existing shadow-container logic:should_train_head = not inference_mode and any active CAUSAL_LM adapter requests lm_head via modules_to_savelm_headforCAUSAL_LM(to avoid touching unrelatedSEQ_CLSor non-lm models)head = self.model.get_output_embeddings(); for p in head.parameters(): p.requires_grad = should_train_headNo API change, no new config, only fixes the missing walk. Single-adapter and
SEQ_CLSpaths unchanged.Changes
src/peft/tuners/shadow/model.py:742-761→ 17 lines added in_sync_shadow_module_trainabilityto sync baselm_headforCAUSAL_LM+modules_to_save=["lm_head"]tests/test_shadow.py:310-342→ 32 lines added:test_shadow_lm_head_trainable_after_set_adapter(repro from [Bug] ShadowPEFT LM-head trainability freeze after set_adapter — modules_to_save=["lm_head"] silently stops receiving grads #3626) andtest_shadow_lm_head_frozen_in_inference_modeVerification
Exact gates run fresh from the committed branch
fix/shadow-lm-head-trainability@d531dbeeafter final commit, before PR:peft@9c16ee66):0/5 OK—after_req=False,grad_after=False,norm 0.00(BUG)5/5 OK—after_req=True,grad_after=True,norm ~4.27inference_mode=True→lm_head.requires_grad=False(expected)inference_mode=False→True(expected)modules_to_save→Falsebefore and afterset_adapter(expected, no touch)ruff check src/peft/tuners/shadow/model.py tests/test_shadow.py→All checks passed;ruff format --check→2 files already formattedtest_shadow.pybaseline parity — 3 passed, 8 skipped, 29 failed pre-existing (allPermissionErrorcache onpeft-internal-testing/tiny-random-LlamaForCausalLMwhen run withoutHF_HUB_CACHE=/tmp/hf_cache; same failures onmain, not introduced by this diff). WithHF_HUB_CACHE=/tmp/hf_cache, new tests pass.Environment
fix/shadow-lm-head-trainability@d531dbee(fixbba8d99f, testd531dbee), basemain@9c16ee66, 0 behindupstream/mainat creationHF_HUB_CACHE=/tmp/hf_cache pytest tests/test_shadow.py -k "lm_head"andruff check/formatas above