Add Riemannian-preconditioned LoRA optimizer - #3382
Conversation
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks @smellslikeml for reviving the Riemannian-preconditioned optimizer PR. I did a first review and I agree that this more generic approach is better. I have a couple of comments but generally, this PR is already quite mature.
One thing to add to the PR would be to extend the MetaMath benchmark to allow using this optimizer there. Check these lines:
peft/method_comparison/MetaMathQA/utils.py
Lines 280 to 286 in e4fe61b
@fangzhaoz If you have the opportunity, it would be great if you could review the PR too. If @smellslikeml agrees, I would also suggest to add you as a co-author on the final commit.
|
|
||
| Pairs are matched by name substitution: for every parameter whose name contains ``lora_A`` the sibling ``lora_B`` | ||
| parameter is looked up by substituting the substring. Only 2D weight matrices that both require gradients are | ||
| returned, which is exactly what the ``r x r`` preconditioner is defined for. DoRA's ``lora_magnitude_vector`` is |
There was a problem hiding this comment.
DoRA would only be one example. Let's reword this to make it clear it's not about DoRA specifically.
| params = dict(model.named_parameters()) | ||
| pairs = [] | ||
| for name, param_a in params.items(): | ||
| if "lora_A" not in name: |
There was a problem hiding this comment.
Checking for ".lora_A."and ".lora_B." would be more precise, right?
| loss(output, label).backward() | ||
|
|
||
|
|
||
| # ── factory: happy path ────────────────────────────────────────────────────── |
There was a problem hiding this comment.
This type of comment can be removed. Instead, if the tests are not self-explanatory, add a comment at the start of the test.
|
|
||
| # ── factory: happy path ────────────────────────────────────────────────────── | ||
|
|
||
|
|
There was a problem hiding this comment.
Let's put all tests inside a single test class that bundles them.
| after = [p.detach().clone() for pair in lora_pairs for p in pair] | ||
| # Every LoRA weight should have moved after two preconditioned steps. | ||
| for pre, post in zip(before, after): | ||
| assert not torch.allclose(pre, post), "LoRA weight did not update after two steps" |
There was a problem hiding this comment.
Let's use a reasonable tolerance for allclose here and below.
| # ── preconditioner math ────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| def test_preconditioner_matches_paper_formula(): |
There was a problem hiding this comment.
IMO, this test is not very useful, as it more or less mirrors the code used above to implement the optimizer. Maybe there are some edge cases that can be sanity checked without reimplementing the computation in the test, but as is, I think we can remove the test.
| assert torch.allclose(lora_b.grad, expected_g_b.to(lora_b.dtype), atol=1e-5) | ||
|
|
||
|
|
||
| def test_bf16_gradients_preconditioned_stably(): |
There was a problem hiding this comment.
Would it make sense to parametrize the test over bf16 + fp16?
97485bd to
8515c48
Compare
Agreed, thank you for your suggestions! |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks, this looks pretty good from my side, just a few comments.
@fangzhaoz Please let me know if you have time to review the PR or not.
Adds `create_riemannian_optimizer` for the r×r Riemannian preconditioning
recipe from "Riemannian Preconditioned LoRA for Fine-Tuning Foundation
Models" (arXiv:2402.02347v3). Before every base-optimizer step, the LoRA
factor gradients are rescaled as:
g_A ← (BᵀB + reg·I_r)⁻¹ @ g_A
g_B ← g_B @ (AAᵀ + reg·I_r)⁻¹
Non-LoRA parameters are updated unchanged. The r×r preconditioner keeps
storage and runtime overhead small in the LoRA rank.
Implementation notes:
- Factory returns a dynamic subclass of `optimizer_cls`, so the recipe
works with AdamW, SGD, or any `torch.optim.Optimizer` subclass.
- Preconditioner computed in ≥float32 for bf16 stability, cast back to
the grad dtype.
- Pair discovery is name-based (lora_A ↔ lora_B), so DoRA's
lora_magnitude_vector is left to the base optimizer.
- `torch.linalg.pinv` with `reg` damping keeps small-r inverses stable.
Tests cover the factory, subclass permissiveness (AdamW + SGD),
DoRA compatibility, ValueError on no-LoRA models, TypeError on non-
Optimizer classes, exact paper formula, and bf16 gradient finiteness.
Follow-up to huggingface#1807 (author-abandoned). Coordination check pending
before opening a PR against upstream.
Co-Authored-By: remyx-ai[bot] <289541483+remyx-ai[bot]@users.noreply.github.com>
…h experiment) Co-Authored-By: fangzhaozhang <zfzhao.olivia@gmail.com>
…tation - utils.py: TrainConfig.__post_init__ now accepts "riemannian" alongside the other explicitly-handled optimizer_type values. - riemannian.py: docstring notes that LoRA on embedding layers (lora_embedding_A/B, backed by nn.Parameter rather than nn.Linear weights) is left unpreconditioned by this implementation. - riemannian.py + MetaMath experiment: `reg` default 1e-6 -> 1e-2, matching the reference example from huggingface#1807 and the paper author's public repo. On llama-3.2-3B rank 32 (5000 steps, otherwise-default MetaMath settings) this moves GSM8K test accuracy from 0.45 to 0.475, closing 62% of the gap to the default-LoRA r=32 baseline reported in review. Co-authored-by: fangzhaozhang <73155110+fangzhaozhang@users.noreply.github.com> Co-authored-by: remyx-ai[bot] <289541483+remyx-ai[bot]@users.noreply.github.com> Signed-off-by: smellslikeml <9044907+smellslikeml@users.noreply.github.com>
0134244 to
e18e45e
Compare
|
@BenjaminBossan Just rebased onto main to clear the commit lag. Ready for re-review when you have a moment. |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the updates, the PR generally looks good and is ready to be merged. Before doing that, I just have one question left.
|
|
||
| class RiemannianPreconditionedOptimizer(optimizer_cls): | ||
| @torch.no_grad() | ||
| def step(self, closure: Optional[Callable] = None): |
There was a problem hiding this comment.
I'm just wondering: For something like LBFGS, which uses the closure, would the Riemanniannian preconditioner still make sense?
The prior step() ran preconditioner.step() then super().step(closure). Torch optimizers evaluate the closure at the top of their own step — typical body is zero_grad(); loss.backward(), which overwrites .grad — so preconditioning was silently dropped whenever a closure was passed. Applied to AdamW/SGD-with-closure as well, not just LBFGS. Fix: wrap the closure so preconditioning runs inside it, after the base optimizer evaluates it. Matches LoraFAOptimizer.step's convention of reading .grad after the closure runs. Guard LBFGS-shaped optimizers at construction. Even with correct ordering, LBFGS builds curvature pairs from y = flat_grad - prev_flat_grad and gates on the secant condition; if P_k is recomputed each iteration, y is no longer a gradient difference of any fixed function. Detect by signature (step has a required 'closure' parameter with no default) so we don't hardcode the class name. Adds two regression tests to tests/test_riemannian_lora.py: - test_closure_path_preconditions_lora_gradients (closure vs no-closure identity; catches the ordering bug — verified to fail on old code) - test_raises_when_optimizer_requires_closure (LBFGS guard) AI assistance was used to prepare this change; tests were run and pass: pytest tests/test_riemannian_lora.py (11 passed)
No, LBFGS builds curvature from While looking at this I noticed the ordering had a separate issue: Pushed both fixes in 58648c6:
Regression tests added to |
|
Thanks for investigating the closure case.
I did not understand this point. Could you please give a concrete example (not a full reproducer, just a snippet would be enough)? And are there real world cases of "AdamW/SGD-with-closure"? If this is a very hypothetical scenario, I'd also be fine with simple checking if a closure was passed and raising. |
Simpler than the closure-wrapper approach per Bossan's review: raise ValueError when a closure is passed at step time, since no shipping PEFT consumer uses closure-based training and silent-drop is worse than fail-loud. Non-breaking widen if a real consumer shows up. Keeps the construction-time signature guard for LBFGS as fail-fast (a caller of LBFGS-with-optimizer would fail at first step() anyway, but failing at construction gives a clearer error message). Swaps the closure-wrapper regression test for a step-time raise test. The LBFGS construction-guard test is unchanged. pytest tests/test_riemannian_lora.py: 11 passed
def closure():
optimizer.zero_grad()
loss = model(x).sum()
loss.backward()
return loss
optimizer.step(closure)Old
Fair, this is hypothetical for PEFT. LBFGS is the only shipping torch optimizer that requires a closure. Simplified per your suggestion in f90cc44: replaced the wrapper with a step-time raise on any closure, dropped the wrapper's regression test, kept the construction-time signature guard for |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the last push. Since we don't know what happens within the closure, I agree it's better to fully reject it.
Adds
create_riemannian_optimizerfor the r×r Riemannian preconditioning recipe from Riemannian Preconditioned LoRA for Fine-Tuning Foundation Models (arXiv:2402.02347v3). Before every base-optimizer step, the LoRA factor gradients are rescaled as:Non-LoRA parameters are updated unchanged. The r×r preconditioner keeps storage and runtime overhead small in the LoRA rank.
Coordination
Filed as coordination issue #3380 before pushing. Original PR author @fangzhaozhang explicitly endorsed a revival (comment):
What this PR does
Revives the design accepted in principle in #1807, folding in round-1 review feedback preemptively:
assertin code — properTypeError/ValueError/RuntimeErrorissubclasscheck onoptimizer_cls— the wrapper works with anytorch.optim.Optimizersubclass (AdamW, SGD, …)torch.linalg.pinvwith damping for numerical stability on near-rank-deficient factorslora_A/lora_Bpair discovery skips the magnitude vectorDeviates from #1807 in one design choice: rather than copying and modifying
AdamW.step(), this is a subclass-wrapper approach — a dynamic subclass ofoptimizer_clsthat applies the preconditioner to.gradtensors before callingsuper().step(closure). Thin diff on top of the base optimizer's step, and works with any optimizer.regcalibration (07-07 update)The
regdamping default is1e-2, matching @fangzhaozhang's reference example in #1807 (create_riemannian_optimizer(..., reg=1e-2)) and the paper author's public repo. On the MetaMath benchmark (llama-3.2-3B, rank 32, 5000 steps, otherwise-default settings):reg1e-6(initial ship)1e-2(this PR)The paper's reported gains are modest and rank-dependent — Table 1: +0.7 BLEU on GPT-2 medium at r=4; Table 2: +0.7pp GLUE avg on 4-bit Mistral 7B at r=16. MetaMath r=32 isn't a paper-benchmarked setting; the paper's stronger claim is LR-range robustness (Figures 1, 3) rather than fixed-LR gains at fixed rank.
Scope explicitly deferred from #1807's tree
src/peft/tuners/lycoris_utils.py/poly/router.pychanges — unclear necessity, dropped. Happy to add back if you confirm they're needed.examples/riemannian_lora/— happy to add here or as a follow-up.events.out.tfevents.*file — dropped.Files
src/peft/optimizers/riemannian.py— factory + preconditionersrc/peft/optimizers/__init__.py— one-line exporttests/test_riemannian_lora.py— 9 testsmethod_comparison/MetaMathQA/utils.py— dispatch entry +TrainConfigvalidation foroptimizer_type="riemannian"method_comparison/MetaMathQA/experiments/lora/llama-3.2-3B-rank32-riemannian/— benchmark experiment (adapter + training params)Test plan
pytest tests/test_riemannian_lora.py— 9 passed (factory happy-path, subclass permissiveness on AdamW + SGD, DoRA compatibility,ValueErroron no-LoRA model,TypeErroron non-Optimizer class, exact paper-formula verification, bf16 gradient finiteness)make style— cleanreg=1e-2: 5000 steps completed, GSM8K test accuracy 0.475 (seeregcalibration table above)AI assistance
AI-assisted patch. Every changed line reviewed; local
pytestandmake styleruns exercised the code paths. FollowingCLAUDE.md's AI-assisted contribution disclosure guidelines.Credit for the original design + engagement with round-1 review goes to @fangzhaozhang (#1807), who is co-authored on the fix commit at their round-1 co-authorship suggestion.
Co-Authored-By: remyx-ai[bot] <289541483+remyx-ai[bot]@users.noreply.github.com>