8000 Add native LoRA and NF4 QLoRA layers by surya-mp · Pull Request #466 · gomlx/gomlx · GitHub
[go: up one dir, main page]

Skip to content

Add native LoRA and NF4 QLoRA layers - #466

Merged
janpfeifer merged 2 commits into
gomlx:mainfrom
surya-mp:feat/native-peft-lora-qlora
Oct 9, 2026
Merged

janpfeifer merged 2 commits into
gomlx:mainfrom
surya-mp:feat/native-peft-lora-qlora

Conversation

@surya-mp
@surya-mp surya-mp commented Sep 18, 2026 •
Copy link
Copy Markdown
Contributor

Summary

Adds native parameter-efficient fine-tuning support to GoMLX through a new ml/layers/peft package.

The implementation provides:

  • Native LoRA layers for existing GoMLX dense projections.
  • NF4 QLoRA layers with optional double quantization of block scales.
  • Target-module injection through small architecture-facing interfaces.
  • Frozen base projections with adapter-only trainable variable selection.
  • Multiple named adapters sharing one base projection.
  • Runtime adapter selection, composition, and disabling.
  • Configurable LoRA rank, alpha, dropout, target modules, bias behavior, and dense-weight layouts.
  • Validation and rollback handling when host module replacement fails.
  • Detailed usage documentation in docs/peft.md.
  • README entries under News and Highlights.

Design

The package keeps GoMLX-specific PEFT behavior close to the graph and variable APIs:

  • Linear provides LoRA over standard GoMLX dense weights.
  • NF4Linear provides QLoRA over packed NF4 base weights.
  • Inject and InjectNF4 let model architectures expose named modules and replace only configured
    targets.
  • Each returned adapter exposes its own optimizer variable set, so training can update only adapter A/B
    matrices and configured biases.

Validation

go test ./ml/layers/peft

The focused test suite covers LoRA and QLoRA forward paths, frozen base weights, adapter-only gradients, named adapter selection, target injection, validation, and rollback behavior.

@janpfeifer
Copy link
Copy Markdown
Contributor

Hi @surya-mp

Thank you for the contribution! I've been meaning to do this for a while and never had the time. This is very welcome!

Unfortunately, I'm in a couple of weeks trip without a laptop ... But I'll review/merge it as soon as I'm back, 1st week of October. Apologies.

Cheers

PS: for a later time: this is a useful use case, it could be interesting to create a page in gomlx.github.io, if it's of interest to you.

I'd also love to add/improve fused ops to optimize the execution of Lora models with the Go backend.

@janpfeifer janpfeifer left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First, thanks again for the contribution, PEFT is something that has been lacking 😃

Even though I understand the math around LoRA/QLoRA, and after reading the peft.md, I'm still not following how should I use ml/layers/peft :) Probably, it's me being slow ... But in any case, we would like to make it simple to normal users.

A few questions: I assume this works by having the user rewrite the model replacing calls to layers.fnn.New (or ml/nn/Dense) with calls to peft.NewLinear or peft.NewNF4Linear ? I don't follow when one would call peft.Inject -- if within the model building function or outside of it. Also whether it applies to all layers, or only to one layer.

In GoMLX usually all the state of a model, when possible, is store in the model.Store. Rarely there is an object that is used both, outside the model building function and inside the building function. Not saying it can't exist, but it feels to me the LoRA adapter could be completely exist inside the model building function -- and largely configured by hyperparameters stored in model.Store (accessed through the scope). You can always add extra configuration methods, but it would be nice if the defaults are read from the scope. See the package ml/layers/fnn for an example.

A few meta comments:

  • During model building in GoMLX we "throw" exceptions (errors would make it unreadable, see discussion in docs), I suspect some of the methods/functions are used during model building and should not return an error, but panic the error (or use exceptions.Panicf.
  • The conventions is that errors must always have a stack-trace: this greatly facilitate pin-pointing building errors. Everywhere you return the pre-defined errors, use instead a return errors.Wrap(ErrMyErrorType) instead (or panic(errors.Wrap(ErrMyErrorType)) if inside a model building function. They can still be checked later for the different types.
  • If using AI (I use it often for straight-forward implementations), consider including the .agents/AGENT.md of the GoMLX repo, as well as the included skill in .agents/skills/golang-gomlx/: it already explains to the AI about the errors, and other conventions.
  • The rng parameter: the convention is always to use the random methods from the model.Store (you can get it with scope.Store()). You can have an optional .WithRNG() method if someone wants something different.

Apologies, if that is me not understanding your abstractions. If this doesn't make sense, maybe we could schedule a quick VC to chat ? (sometimes it is easier)

@surya-mp
surya-mp commented Oct 4, 2026
Copy link
Copy Markdown
Contributor Author

Thank you. You were right that the original injection/registry API did not feel native to GoMLX. I refactored the package so that LoRA and QLoRA are graph-building layers configured from model.Scope parameters. The normal path is now peft.New(scope, input, weight, bias).Done() (or NewNF4), directly where the projection is built.

I removed the architecture-level injection and named-adapter machinery, moved validation failures in graph construction to GoMLX exceptions with stack traces, and used the model store RNG for adapter initialization. The documentation and tests now reflect this model.

@janpfeifer janpfeifer left a comment •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @surya-mp, this is looking much better 👍🏻

There are still a few inconsistencies on how GoMLX is organized, I'll describe below. But I don't want to impose too much on you, if you don't want to do the change, let me know, and I will merge your changes as is (with the core of LoRA/QLoRA) and then change the API to match GoMLX, your choice 😃

My conceptual understanding is that (Q)LoRA should be a "meta" layer, that changes the way a dense layer (or other layers like attention) work. Ideally, it would have the exact same API, just add LoRA adapters to it, if configured (and if not configured, simply fall back and work exactly like the original).

With that in mind, I have the following comments:

  • The peft.Linear object you created is something in between a nn.Dense API and a ml/layers/fnn API.
    • The nn package doesn't handle variables (or model.Store or model.Scope), it handles the routing to fused-ops when available, and otherwise implements the fallback computation math. Usually, end users building models will not call these directly, and instead will use the various ml/layers (like the fnn).
    • The layers (like ml/layers/fnn) packages are the one most folks will use, and handle the creation the variables and such.

And then I have the following is a more radical suggestion/proposal:

  • Add a ParamAdapter = "peft_adapter" parameter, that defines the name of the (LoRA) adapter to use. If empty, no adapter is used (and it falls back to the usual fnn). The nice thing is that the same model can support various adapters, by simply changing this hyper-parameter -- meaning one can have a checkpoint with multiple different lora adapters -- or multiple checkpoints one per LoRA adapter, either way works.
  • We change the fnn package to recognize the peft.ParamAdapter ("peft_adapter") parameter (maybe create a function peft.HasAdapter(scope) bool. If it returns true, the fnn package would freeze the normal weights (weights.SetTrainable(false)) calls a peft.AddLinear(layerScope, x) to add the LoRA projection (or similar). This way everyone gets LoRA adapter for free by simply configuring the "peft_adapter" hyperparameter, without having to do anything.
  • When creating the LoRA variables (A and B) create it under the <FNNs scope> / <adapterName> / {A|B}. This keeps things cleanly organized.
  • One can eventually (future PR) update the variable checking CLI tool in cmd/gomlx_checkpoints with flags to trim or copy (merge) LoRA adapter weights across checkpoints if one wants. It can also support the functionality to merge some LoRA weights into the main weights (or we can create another tool for that).
  • Another future suggested changes (for future PRs): add LoRA adapters to the attention layer (and other layers) using the same format.
  • Yet another suggestion: I can work on updating go-huggingface to be able to load models with LoRA adapters such that it would simply work for inference (including handling multi-tenancy, with multiple different LoRA adapters).

This way, the user doesn't need to do anything in their code. They simple set the hyperparameter "peft_adapter" to the name of the LoRA adapter they want to train or use (inference) and things just work.

What do you think ?

ps.: The fnn package doesn't handle yet PF4 quantized. I suggest we postpone QLoRA for a next PR, and I can better formalize the PF4 use case -- it also could benefit from some more optimized fused ops in the "go" backend, which I'm happy to do next.

Comment thread docs/peft.md
non-trainable.

```go
modelScope := store.RootScope().In("model")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can set the parameters in the store object directly (if no scope is given, it will set on the root scope), so here simply do:

store.SetParams(map[string]any{
	peft.ParamRank:    16,
	peft.ParamAlpha:   float32(32),
	peft.ParamDropout: float32(0.05),
	peft.ParamBias:    peft.BiasNone,
})

@janpfeifer
Copy link
Copy Markdown
Contributor

hi @surya-mp ,

I was thinking: I want to implement the strands-decider-2b model, which uses LoRA.

If you are ok, I could merge your changes as is, and then make the changes in the fnn and attention package to call peft here, as I suggested. But I don't want to overstep if you would like to do it yourself. Let me know.

@surya-mp
surya-mp commented Oct 9, 2026 •
Copy link
Copy Markdown
Contributor Author

Hi @janpfeifer, you can go ahead and merge the PR. Regarding the go-huggingface package you mentioned for loading models with adapters just for inference, I've built something similar for my own use case. I plan to update that later once this is done, but let me know if you've already started working on it or if I can help.

@janpfeifer
janpfeifer merged commit 306e239 into gomlx:main Oct 9, 2026
4 checks passed
@janpfeifer
Copy link
Copy Markdown
Contributor

Thanks! Cool that you are already using it btw!!

I'll update the other layers to automatically use peft if the hyperparameter is given, and port some go-huggingface models with LoRA to use it. Let's see how it goes. I'll post here.

@janpfeifer
janpfeifer commented Oct 9, 2026 •
Copy link
Copy Markdown
Contributor

See follow up in #469, where I make it such one just need to import github.com/gomlx/gomlx/ml/layers/peft and it is automatically available by just configuring the peft_adapter. It updated the docs/peft.md with the simplified API.

The #469 was also merged, and the documentation also updated in https://gomlx.github.io/docs/layers/#parameter-efficient-fine-tuning-peft--lora

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

0