Skip to content

Add Riemannian-preconditioned LoRA optimizer - #3382

Merged
BenjaminBossan merged 5 commits into
huggingface:mainfrom
smellslikeml:feat/riemannian-preconditioned-lora
Aug 3, 2026
Merged

Add Riemannian-preconditioned LoRA optimizer#3382
BenjaminBossan merged 5 commits into
huggingface:mainfrom
smellslikeml:feat/riemannian-preconditioned-lora

Conversation

@smellslikeml

@smellslikeml smellslikeml commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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.

Coordination

Filed as coordination issue #3380 before pushing. Original PR author @fangzhaozhang explicitly endorsed a revival (comment):

"Thanks for interests! I'm caught up by my school work after the initial implementation, so feel free to open a fresh PR if you'd like."

What this PR does

Revives the design accepted in principle in #1807, folding in round-1 review feedback preemptively:

  • PEP 8 class naming, docstrings replace implementation-detail strings
  • No assert in code — proper TypeError / ValueError / RuntimeError
  • issubclass check on optimizer_cls — the wrapper works with any torch.optim.Optimizer subclass (AdamW, SGD, …)
  • torch.linalg.pinv with damping for numerical stability on near-rank-deficient factors
  • bf16-stable via float32 preconditioner computation, cast back to grad dtype
  • DoRA-compatible — name-based lora_A / lora_B pair discovery skips the magnitude vector

Deviates from #1807 in one design choice: rather than copying and modifying AdamW.step(), this is a subclass-wrapper approach — a dynamic subclass of optimizer_cls that applies the preconditioner to .grad tensors before calling super().step(closure). Thin diff on top of the base optimizer's step, and works with any optimizer.

reg calibration (07-07 update)

The reg damping default is 1e-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):

reg GSM8K test acc.
1e-6 (initial ship) 0.45
1e-2 (this PR) 0.475
default LoRA r=32 baseline 0.49

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.py changes — 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.
  • The accidentally-committed events.out.tfevents.* file — dropped.

Files

  • src/peft/optimizers/riemannian.py — factory + preconditioner
  • src/peft/optimizers/__init__.py — one-line export
  • tests/test_riemannian_lora.py — 9 tests
  • method_comparison/MetaMathQA/utils.py — dispatch entry + TrainConfig validation for optimizer_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, ValueError on no-LoRA model, TypeError on non-Optimizer class, exact paper-formula verification, bf16 gradient finiteness)
  • make style — clean
  • MetaMath benchmark on Colab A100 with the corrected reg=1e-2: 5000 steps completed, GSM8K test accuracy 0.475 (see reg calibration table above)

AI assistance

AI-assisted patch. Every changed line reviewed; local pytest and make style runs exercised the code paths. Following CLAUDE.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>

@BenjaminBossan BenjaminBossan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

if optimizer_type == "lora+":
optimizer = create_loraplus_optimizer(model, optimizer_cls=torch.optim.AdamW, **optimizer_kwargs)
elif optimizer_type == "lora-fa":
optimizer = create_lorafa_optimizer(model, **optimizer_kwargs)
else:
cls = getattr(torch.optim, optimizer_type)
optimizer = cls(model.parameters(), **optimizer_kwargs)
After adding the Riemannian optimizer there, you would also have to add an experiment that uses it.

@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.

Comment thread src/peft/optimizers/riemannian.py Outdated
Comment thread src/peft/optimizers/riemannian.py Outdated
Comment thread src/peft/optimizers/riemannian.py Outdated

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DoRA would only be one example. Let's reword this to make it clear it's not about DoRA specifically.

Comment thread src/peft/optimizers/riemannian.py Outdated
params = dict(model.named_parameters())
pairs = []
for name, param_a in params.items():
if "lora_A" not in name:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for ".lora_A."and ".lora_B." would be more precise, right?

Comment thread src/peft/optimizers/riemannian.py
Comment thread tests/test_riemannian_lora.py Outdated
loss(output, label).backward()


# ── factory: happy path ──────────────────────────────────────────────────────

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type of comment can be removed. Instead, if the tests are not self-explanatory, add a comment at the start of the test.

Comment thread tests/test_riemannian_lora.py Outdated

# ── factory: happy path ──────────────────────────────────────────────────────


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put all tests inside a single test class that bundles them.

Comment thread tests/test_riemannian_lora.py Outdated
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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a reasonable tolerance for allclose here and below.

Comment thread tests/test_riemannian_lora.py Outdated
# ── preconditioner math ──────────────────────────────────────────────────────


def test_preconditioner_matches_paper_formula():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/test_riemannian_lora.py Outdated
assert torch.allclose(lora_b.grad, expected_g_b.to(lora_b.dtype), atol=1e-5)


def test_bf16_gradients_preconditioned_stably():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to parametrize the test over bf16 + fp16?

@smellslikeml
smellslikeml force-pushed the feat/riemannian-preconditioned-lora branch from 97485bd to 8515c48 Compare July 3, 2026 15:44
@smellslikeml

Copy link
Copy Markdown
Contributor Author

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:

if optimizer_type == "lora+":
optimizer = create_loraplus_optimizer(model, optimizer_cls=torch.optim.AdamW, **optimizer_kwargs)
elif optimizer_type == "lora-fa":
optimizer = create_lorafa_optimizer(model, **optimizer_kwargs)
else:
cls = getattr(torch.optim, optimizer_type)
optimizer = cls(model.parameters(), **optimizer_kwargs)

After adding the Riemannian optimizer there, you would also have to add an experiment that uses it.

@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.

Agreed, thank you for your suggestions!
I've updated with changes and attribution

@smellslikeml
smellslikeml marked this pull request as ready for review July 3, 2026 15:51

@BenjaminBossan BenjaminBossan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/peft/optimizers/riemannian.py
smellslikeml and others added 3 commits July 27, 2026 07:36
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>
@smellslikeml
smellslikeml force-pushed the feat/riemannian-preconditioned-lora branch from 0134244 to e18e45e Compare July 27, 2026 14:37
@smellslikeml

Copy link
Copy Markdown
Contributor Author

@BenjaminBossan Just rebased onto main to clear the commit lag. Ready for re-review when you have a moment.

@BenjaminBossan BenjaminBossan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
@smellslikeml

Copy link
Copy Markdown
Contributor Author

For something like LBFGS, which uses the closure, would the Riemanniannian preconditioner still make sense?

No, LBFGS builds curvature from y = flat_grad - prev_flat_grad and gates on the secant condition; if P_k is recomputed each step, y stops being a gradient difference of any fixed function.

While looking at this I noticed the ordering had a separate issue: preconditioner.step() then super().step(closure) silently drops preconditioning for any closure-using optimizer, since torch optimizers evaluate the closure at the top of their own step (typical body is zero_grad(); loss.backward(), which overwrites .grad). Affects AdamW/SGD-with-closure too.

Pushed both fixes in 58648c6:

  • Wrap the closure so preconditioning runs inside it, matching LoraFAOptimizer.step's convention.
  • Guard closure-required optimizers at construction so we don't hardcode LBFGS:
    closure_param = inspect.signature(optimizer_cls.step).parameters.get("closure")
    if closure_param is not None and closure_param.default is inspect.Parameter.empty:
        raise ValueError(...)
    Matches the sibling TypeError on non-Optimizer classes and the AdaLoRA-style raise on unsupported combos.

Regression tests added to tests/test_riemannian_lora.py: closure-vs-no-closure identity check (verified to fail on the previous ordering) and an LBFGS construction-time rejection test.

@BenjaminBossan

Copy link
Copy Markdown
Member

Thanks for investigating the closure case.

While looking at this I noticed the ordering had a separate issue: preconditioner.step() then super().step(closure) silently drops preconditioning for any closure-using optimizer, since torch optimizers evaluate the closure at the top of their own step (typical body is zero_grad(); loss.backward(), which overwrites .grad). Affects AdamW/SGD-with-closure too.

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
@smellslikeml

Copy link
Copy Markdown
Contributor Author
def closure():
    optimizer.zero_grad()
    loss = model(x).sum()
    loss.backward()
    return loss

optimizer.step(closure)

Old step(closure) did:

  1. preconditioner.step() writes P_k g_k into .grad
  2. super().step(closure) calls e.g. AdamW.step(closure), which invokes closure() at the top, and zero_grad(); backward() overwrites .grad with raw g_k
  3. AdamW reads raw g_k, preconditioning silently dropped

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 LBFGS (fail-fast on construction) alongside the step-time check.

@BenjaminBossan BenjaminBossan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the last push. Since we don't know what happens within the closure, I agree it's better to fully reject it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants