feat(peft): LoRA support for SFT - #57
Conversation
Wire AutoModel's LoRA through the model wrapper and the SFT launcher. AutoModel owns
the adapter lifecycle -- it injects LoRA inside from_pretrained before FSDP2 shards,
then freezes every non-`lora_` parameter after parallelization -- and the optimizer
already selects on requires_grad, so molt only has to map flags onto PeftConfig.
- `--{prefix}lora.{rank,alpha,dropout,target_modules}`, off unless rank > 0, wired
into train_sft (the prefix keeps the RL launcher's surface open).
- Reject custom-MoE + tp_size > 1 up front: AutoModel's safe MoE-TP path rejects PEFT
only after the weight load, so this fails in milliseconds instead of minutes.
- Derive CheckpointingConfig.is_peft from the model so save, resume and export agree
on the adapter-only format.
- Report the trainable-parameter share, which catches target_modules matching nothing.
Refit still pushes full weights, so the RL launcher deliberately does not expose the
flags yet; merge-on-refit is the follow-up.
Signed-off-by: NancyFyong <88076188+NancyFyong@users.noreply.github.com>
AutoModel's PeftAddon needs the config itself to emit adapter_config.json, so is_peft alone is not enough: the first checkpoint save died with "AttributeError: 'NoneType' object has no attribute 'dim'". Keep the config on the model wrapper (the same thing AutoModel's own recipes do) and read it in the three checkpoint entry points before _unwrap_model discards the wrapper. is_peft now derives from that config instead of scanning parameter names. Signed-off-by: NancyFyong <88076188+NancyFyong@users.noreply.github.com>
vLLM has no adapter parameter and its AutoWeightsLoader raises on an unknown name, so the adapters cannot be pushed as-is. `lora_refit_merges` maps each adapted base weight to its (first, second, scale) operands and refit sends `W + scale * (first @ second)` under the base FQN. The operands are pre-ordered because the layouts disagree: a dense nn.Linear weight is [out, in] while a grouped expert weight is [E, in, out]. Walking modules rather than parsing names is what gets `scale` right — alpha/rank is per module, and AutoModel's moe_rank_scaling gives the experts a different rank than the dense layers. The adapter gathers are collectives, so they run before the non-rank-0 early-out. A missing base weight raises instead of skipping: base weights are frozen under LoRA, so an unmerged refit is silent and only surfaces later as vllm_kl climbing with training. RL rejects a non-zero LoRA dropout — the rollout serves the merged, deterministic weight, so a stochastic training forward would bias the importance-sampling ratio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Muon's orthogonalized update is defined for a full-rank layer, not for a rank-r factor pair whose product is the actual weight delta. Both adapters are plain 2D weights, so _classify_params put them in the Muon group — and under PEFT they are the only trainable tensors, making that the whole update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This PR lands LoRA for SFT first. RL LoRA needs merge-on-refit (the rollout engine is fed full base weights, so unmerged adapters silently serve the base policy) — that ships as a follow-up. Strip the RL merge path and reject --actor.lora.rank>0 up front so the failure is a clear error, not a silent base-policy rollout. --actor.lora.* stays registered only to give that error. Also trims LoRA comments to the repo's concise-why style. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirror the full-parameter sft_qwen3_6_35b recipe with LoRA on top (quick_start + slurm): Qwen3.5-35B-A3B (custom MoE, VLM) and Qwen3-30B-A3B (stock HF Qwen3MoeForCausalLM, text). Only the adapters train; LR bumped to 1e-4 (LoRA tolerates a higher rate than the full-model 1e-6). Adapters default to `*_proj`; adapting the grouped experts needs an explicit `--model.lora.target_modules '*'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
also cc @HuiyingLi |
|
Hi @HuiyingLi, can you take a look. Thank you! |
|
Thanks for the focused LoRA plumbing—the overall SFT-only split makes sense. I tested this against the pinned AutoModel checkout and found a few issues to address before approval:
|
Thank you for review, I'll fix it quickly. |
…ptimizer save_ckpt routes through Checkpointer.save_optimizer, which builds OptimizerState(is_peft=True, has_expert_parallelism=True) under PEFT+EP and writes the *native* AdamW state dict. load_ckpt hand-rolled its own OptimizerState + dcp.load without those flags, requesting the DCP FQN shape against native-keyed data — allow_partial_load=True then silently returned zero moments, and the resumed AdamW ran cold (exp_avg / exp_avg_sq lost). Route load through Checkpointer.load_optimizer so both sides build OptimizerState with the same is_peft / has_expert_parallelism gates, and OptimizerState internally materializes the moment schema before DCP populates it. Also drops allow_partial_load=True (the flag that kept the bug silent). Real-GPU verify on 8xH20 with Qwen3-30B-A3B, LoRA rank 16, EP=8: - save at step 2: exp_avg.norm=1.128e-04, exp_avg_sq.norm=6.385e-09 - resume from step 4: step=4.0 restored, exp_avg.norm=4.056e-04, exp_avg_sq.norm=4.188e-08, consumed_samples=32
…_projs) AutoModel's ModuleMatcher runs an anchored fullmatch against the full dotted *module path*. Custom-MoE grouped experts live at "<...>.experts" (one module per MoE layer holding all experts as GroupedExperts / GroupedExpertsDeepEP), so the pattern is "*.experts". "*_projs" describes a *parameter name* inside that module (gate_and_up_projs, down_projs) and matches no module path -- the prior guidance would have adapted nothing when a user tried to follow it. Fix the docstring in _lora_peft_config, the --model.lora.target_modules help text, the four LoRA SFT recipe headers, and the test_lora_config.py comment that codified the wrong claim. Update test_explicit_target_modules_are_forwarded to exercise the correct pair "*_proj" "*.experts".
|
Hi @HuiyingLi, addressed issues 1 and 3. Issue 2 (recipe dependency on #61) I'd like to leave for a follow-up — see below. Issue 1 — PEFT+EP optimizer resumeRoot cause was deeper than the schema not being materialized: Commit: Real-GPU verification (8×H20, Qwen3-30B-A3B, LoRA rank 16, EP=8,
Non-zero moments, AdamW Regression test: Issue 3 — grouped-expert target guidanceYou're right — Fixed the docstring in Commit: Issue 2 — recipe dependency on #61Confirmed AutoModel#3325 is still open ( |
Resolve conflict in molt/models/base.py: main added freeze_config= kwarg to ModelCls.from_pretrained (delegating vision-freezing to AutoModel) and removed the post-hoc requires_grad loop; our branch added peft_config= for the LoRA integration and had a CP-forcing block that auto-freezes vision under cp_size>1 because CP shards the language stack only. Kept both kwargs on the from_pretrained call and moved the CP-forcing logic to compute `effective_freeze_visual` *before* from_pretrained, so it flows into AutoModel's freeze_config path and metrics stay comparable to the pre-freeze_config recipes. Verified: python -m compileall on molt/examples/tests, python -m pytest on checkpoint_optim_resume + lora_config + checkpoint_client_state + prune + cp_thd_packing + sft_trainer (27 pass), bash -n on all 7 recipe scripts.
First of a small series adding LoRA. This one covers the model wrapper and the SFT
launcher; RL comes next (see the note at the bottom). Draft because the split is worth
agreeing on before I push the rest.
Why it is this small
AutoModel already does all of it:
from_pretrained(..., peft_config=PeftConfig(...))injects the adapters before FSDP2 shards, then freezes every non-
lora_parameterafter parallelization (so params materialized during sharding are caught too). Molt
already builds the optimizer from
requires_gradparams, so nothing else needs to optout of training the base. This PR is mostly flag plumbing.
What is here
add_lora_args(parser, prefix)—lora.{rank,alpha,dropout,target_modules}, offunless
rank > 0, alongside the other shared blocks. Prefixed likeadd_optimizer_argsso the RL launcher can reuse it unchanged._lora_peft_config()inbase.py, next to the existing single-call-site helpers(
_mtp_off_kwargs,_validate_attn_implementation), which keeps the mapping and theguard unit-testable without building a model.
--fsdp.tp_size > 1. AutoModel's safe MoE-TP path replicatesnon-expert modules and rejects PEFT, but only in a post-shard validator — i.e. after
the whole checkpoint is loaded. Failing at flag-parse time turns minutes into
milliseconds. Dense + TP stays allowed (it only costs the Triton LoRA kernel).
CheckpointingConfig.is_peftderived from the model instead of hard-codedFalse.All three
_build_checkpointercall sites already pass the model, so save, resume andconsolidated export agree on the adapter-only format rather than writing base weights
with
lora_keys mixed in.--optim muon: Muon's orthogonalizedupdate assumes a full-rank layer, not a rank-
rfactor pair.target_modulesmatchednothing (share stays ~100%).
Default
target_modulesis*_proj, matching AutoModel's own implicit default. Patternsare anchored fullmatches, so it covers dense attention/MLP linears but not custom-MoE
grouped experts, which are named
*_projs— those need an explicit pattern such as*.The docs/help text say so.
Not here, on purpose
RL. Refit pushes
model.state_dict()by HF name, and vLLM'sAutoWeightsLoaderraiseson
lora_A/lora_B; skipping them instead ships frozen base weights, so the rolloutsilently stays on the initial policy. That needs merge-on-refit, which is its own PR with
its own tests. The
--actor.lora.*flags are still registered so the RL launcher gives aclear error, but it fails fast on
rank > 0rather than silently serving the basepolicy.
Also deliberately absent: no
use_doraflag (nothing can trigger it, andmaterialize_effective_weightdoes not support DoRA), and no TE-experts guard —BackendConfig.expertsdefaults totorch_mm, so that path needs an explicitMOLT_MOE_EXPERTS=te, and AutoModel's own error already names TE.Testing
tests/unit/test_lora_config.py(7 tests): defaults keep LoRA off, flags map ontoPeftConfig(rank→dim), the default/explicittarget_modules, and the MoE+TP guardincluding the two combinations that must stay allowed (MoE+EP-only, dense+TP).
tests/unit/test_muon_param_classify.pycovers the adapters→AdamW routing.End-to-end SFT validation
Two MoE models, LoRA r16/α32, default
*_projtarget (attention/dense linears; expertsstay frozen), expert-parallel
EP=2 / TP=1,--model.gradient_checkpoint full,attn=te, 2 epochs. Both drive the loss down with 0CheckpointErrorand write anadapter-only checkpoint:
sft_lossstart → endCheckpointErrorQwen3MoeForCausalLM→ AutoModel custom MoE)Note: MoE + EP +
ac=fullneeds--data.pad_to_max_len(shipped separately in #61) tokeep the checkpoint recompute deterministic; LoRA itself is independent of that flag.
pytest -qclean, pre-commit clean,--helpverified.