Skip to content

feat: multi lora async#1638

Open
mathewjhan wants to merge 49 commits into
radixark:mainfrom
mathewjhan:feat/multi-lora-async
Open

feat: multi lora async#1638
mathewjhan wants to merge 49 commits into
radixark:mainfrom
mathewjhan:feat/multi-lora-async

Conversation

@mathewjhan

@mathewjhan mathewjhan commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Allow disagg fully async training on multiple loras (all-linear, excluding expert) per step in Miles using megatron-bridge.

Two LoRAs trained together (DAPO + GSM8K)

image image

Feature

  • Train multiple LoRAs in a single training step
  • Use as a long running service, with in_place pause generation and LoRA upsert updates
  • Use as normal training job, stopping when no more LoRAs are left to train
  • FastAPI control plane to register and deregister adapter runs

Dependent PRs

SGLang

Unified above: sgl-project/sglang#31253 (for testing - do not need merge)

Megatron-Bridge (radixark)

Dev setup

Tested image: radixark/miles:dev-202607090055

You need to install the 3 forks:

SGLang

This fork has two small changes to SGLang (has both PRs):

  • Upsert semantics for loading LoRAs with the same lora name, to avoid having to unload; currently, unloading requires there to be zero requests for that LoRA to be loaded. When running pause generation before unload, we cannot complete more requests, when there are still running requests on unload, it is impossible to unload the adapter and miles will hang
  • Abort by rid prefix; this is useful to target specific requests under a "namespace", such as when a LoRA run has been deregistered from miles and we want to abort all of the requests for that LoRA adapter. The alternative is to track all requests for that LoRA adapter individually and send in individual abort requests, since SGLang currently doesn't support batch abort
git clone https://github.com/mathewjhan/sglang
cd sglang
git checkout sglang-miles-mathew-dev-2
uv pip install --system --break-system-packages --no-deps -e python

Megatron-Bridge

This fork adds a multi-lora support to Megatron-Bridge along with some helper methods/user api

git clone https://github.com/mathewjhan/Megatron-Bridge
cd Megatron-Bridge
git checkout radixark/bridge/multilora-2
uv pip install --system --break-system-packages --no-deps -e .

Miles

git clone https://github.com/mathewjhan/miles
cd miles
git checkout feat/multi-lora-async
uv pip install --system --break-system-packages --no-deps -e .

Running

see: examples/multi_lora

For normal training (not as a service):

  1. run examples/multi_lora/provision.sh
  2. configure W&B credentials and settings in examples/multi_lora/run_job.sh
  3. run examples/multi_lora/run_job.sh |& tee run.log

For multi-lora training as a long running service:

  1. run examples/multi_lora/provision.sh
  2. configure W&B credentials and settings in examples/multi_lora/run_service.sh
  3. run examples/multi_lora/run_service.sh |& tee run.log in one shell
  4. wait for the service to be ready ("No adapters; sleeping for 5.0s...")
  5. optionally run the smoke test in another shell
python3 examples/multi_lora/service_smoke.py \
      --api-url http://127.0.0.1:8068 \
      --data /root/gsm8k/train.parquet \
      --input-key messages \
      --label-key label \
      --rm-type math \
      --steps 5

Minor features added to existing code (backwards compatible)

  • Add AdapterRef and RewardSpec to Sample type so individual samples can access their own reward functions and adapter names during rollout

Missing features for future

  • Colocated + update_weight_from_tensor currently unsupported, only update_weight_from_distributed
  • Per adapter batch size currently unsupported, all adapters stepped at once (will be added later when decoupling the optimizer)
  • Currently doesn't checkpoint optimizer + scheduler state yet, but can be added later as future PR
  • Dataset checkpoint loading per adapter
  • MultiLoRA not applied to experts as of now due to more complex bookkeeping required (need to keep track of the [adapter index, routed experts] together for grouped gemm)
  • Doesn't support pure LoRA load from *.bin/*.safetensors yet, can be added later as a future PR, only resume from a megatron checkpoint or train from scratch
  • Support add_samples to recycle stale/aborted samples

@mathewjhan mathewjhan changed the title Feat/multi lora async feat: multi lora async Jul 12, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a fully-async multi-LoRA training framework, enabling concurrent training of multiple LoRA adapters against a shared base model. It adds a background rollout worker, an async round-robin data source, a Ray-based controller with an HTTP control-plane API, and integrates slot-keyed adapter management into the Megatron-LM and SGLang backends. The review feedback identifies a critical distributed deadlock risk in actor.py caused by non-deterministic set iteration during collective operations, which can be fixed by sorting. Additionally, the reviewer recommends replacing several assert statements with ValueError for input validation and closing the httpx.Client in the smoke test to prevent resource leaks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread miles/backends/megatron_utils/actor.py Outdated
Comment thread miles/utils/arguments.py
Comment thread examples/multi_lora/train_multi_lora_async.py Outdated
Comment thread miles/ray/multi_lora_controller.py
Comment thread examples/multi_lora/service_smoke.py
Comment thread examples/multi_lora/service_smoke.py
@yushengsu-thu yushengsu-thu self-assigned this Jul 12, 2026

@yushengsu-thu yushengsu-thu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ran a deep verification pass over this branch together with its two sglang dependency PRs (sgl-project#30912/#30913); every finding below was adversarially re-verified against the code. Two issues to flag on the miles side — one is a hard crash/correctness bug in the logprob-recompute path, the other is a lifecycle race that silently corrupts rollout data across adapter slot reuse. (Side note: the deeper sglang-side findings are filed on the two dependency PRs directly.)

Comment thread examples/multi_lora/multi_lora_async_rollout.py
Comment thread miles/utils/multi_lora.py
_build_prefill_scoring_payload keyed LoRA solely on is_lora_enabled(args)
(always true in multi-LoRA) and sent the single-adapter name miles_lora,
which is never registered on multi-LoRA engines -> every step crashed with
'adapters are not loaded' when --recompute-logprobs-via-prefill is on; the
batched path also applied payloads[0]'s lora_path to a mixed-adapter batch.

- resolve lora_path per sample: adapter samples score under their own
  __miles_slot_{N}, single-adapter LoRA keeps miles_lora, base keeps none
- batched scoring groups by (logprob_start_len, lora_path) and rejects
  mixed-adapter batch payloads
- centralize the engine-side slot adapter name as slot_lora_name(), shared
  by rollout, prefill scoring, and the weight-push mixin

Signed-off-by: Yusheng Su <yushengsu.thu@gmail.com>
The retire-time prefix abort fires exactly once (RETIRING->CLEANUP); requests
can survive it (a multi-turn group POSTs its next turn after the round, or a
request sits in the engine's tokenizer window) and, once the slot is reused
and the next adapter's weights are upserted into the same __miles_slot_{N},
keep decoding under the wrong weights with no error. Batch-time collection
filters already drop such groups from training data; these changes shrink the
window and stop wasting decode on them:

- MultiLoRABackend.free_slot: re-run the prefix abort right before the slot
  is released for reuse (second round, after a full step of settling)
- sglang_rollout.generate: refuse to POST for an adapter that is no longer
  sampleable (deregistered/cleaned up) and abort the sample locally instead
- the tokenizer-window escape itself is fixed engine-side (sgl-project#30912)

Signed-off-by: Yusheng Su <yushengsu.thu@gmail.com>
@yushengsu-thu

yushengsu-thu commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

@mathewjhan examples/multi_lora/tests/test_multi_lora_async_rollout.py::test_process_group_recycles_aborted fails on this branch (pre-existing at e371b2e, unrelated to the M1/M2 fixes): the test expects aborted groups to be re-queued, but the code path documents that re-queuing is not wired up. Which direction do you want — wire up re-queuing for aborted groups, or adjust the test to pin the current drop-and-count behavior? I'm happy to pick it up once the direction is set.

Re-queuing aborted groups (add_samples) is intended but deferred to a
future PR (confirmed by mathewjhan; left unimplemented for lack of
testing time). strict=True so wiring it up forces marker removal.

Signed-off-by: Yusheng Su <yushengsu.thu@gmail.com>
@yushengsu-thu

Copy link
Copy Markdown
Collaborator

Thanks for confirming — marked test_process_group_recycles_aborted as xfail(strict=True) in 50f6ff1 so the suite is green on this PR while still pinning the intended end-state behavior (wiring up re-queuing will XPASS and force the marker off). Two things worth bundling into that future PR: (1) the re-queuing itself (per-adapter source needs a write path), and (2) aborted/stale-dropped rows currently still count toward num_row, so adapters quietly train on fewer samples than configured — probably the more impactful half. Also worth skipping re-queue for RETIRING adapters (their slot is being freed; nothing can serve the regenerated group).

yushengsu-thu and others added 4 commits July 13, 2026 17:10
Each sglang tokenizer worker process holds its own LoRA registry with no
cross-worker sync (sgl-project/sglang#31084), so per-step adapter upserts
resolve on whichever worker the router picks and fail non-deterministically.
sglang rejects the upsert at runtime anyway; failing at launch avoids
burning GPU time until the first weight push.

Signed-off-by: Yusheng Su <yushengsu.thu@gmail.com>
@mathewjhan
mathewjhan force-pushed the feat/multi-lora-async branch from fdefa55 to f115610 Compare July 16, 2026 06:58
@mathewjhan
mathewjhan force-pushed the feat/multi-lora-async branch from 1fbe335 to 75d7538 Compare July 17, 2026 10:31
@mathewjhan
mathewjhan marked this pull request as ready for review July 17, 2026 20:29
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

mathewjhan and others added 16 commits July 17, 2026 13:46
… bshd slice

Two context-parallel regressions on shared (non-multi-LoRA) paths:

- thd + allgather_cp: a chunk->batch rename also hit the torch method
  calls, leaving tokens.batch(...) / loss_masks.batch(...) which do not
  exist on torch.Tensor -- every --allgather-cp run crashed with
  AttributeError on the first micro-batch. Restore Tensor.chunk().

- bshd: tokens were sliced with slice_with_cp at the top of the branch
  and again in the non-allgather arm. chunk_size derives from max_seqlen,
  not the input length, so the second pass silently replaces the back
  half of each CP rank's tokens with pad zeros (shape unchanged, no
  error) and hands the allgather arm pre-sliced input. Drop the top
  slice, matching the structure of the same branch on main.
…e-slice bshd layout

get_batch's CP paths had no coverage, which let both regressions in the
previous commit ship undetected. Pin the normal-CP (zigzag) contract at
cp_size=2 with CUDA stubbed so the tests run on CPU workers:

- per-rank adapter_token_counts equal the post-slice per-sample lengths
  summed per slot, stream padding attributed to the last slot, and sum
  to the rank-local token count;
- counts are identical across CP ranks (zigzag gives each rank the same
  padded share of every sample);
- unsorted adapter_slots micro-batches are rejected;
- bshd tokens match a single zigzag slice (regression test for the
  double slice_with_cp).
--global-batch-size defaults to None (Megatron derives it later), but the
adapter-cap fallback guarded only hasattr, which is always true, so
validation died with 'TypeError: int * None'. Leave the cap unset when the
global batch size is not given; the existing is-not-None branch below
already treats None as no cap. Fixes the shipped
test_accepts_default_single_tokenizer_worker, which fails at HEAD.
…'s buffered state

get_groups only discards a retired adapter's buffered groups and partial
step stats when a later generate call observes the name missing from the
snapshot. When the last adapter retires, the driver stops generating and
idles, so that sweep never runs; re-registering the same name then ships
the old tenant's groups (old prompts, rewards, slot_version) into the new
tenant's first batch and merges the old rewards into its step statistics.
The staleness filter does not help: same-slot reuse leaves a version delta
of 1-2, within the run scripts' --max-weight-staleness 3.

Give every registration a unique identity and fence rollout state on it:

- AdapterRecord/AdapterRun carry a registration_id (uuid per register());
- process_group stamps it on samples next to the slot_version stamp;
- get_groups drops a name's buffer and partial stats when its snapshot
  registration_id changes, and GroupBuffer.drop_foreign() drops stragglers
  of the old registration that land after that sweep (an in-flight
  generation of the retired tenant can complete at any time).

Covered by two new batch-collection tests (re-registration reset, foreign
straggler filtering) plus a registration stamp assertion in the
process_group test.
…producer

Two stacked defects let one bad registration stall every adapter behind a
misleading empty-batch timeout:

- a nonexistent data path was accepted at registration and only blew up
  when the producer's get_samples built the RolloutDataSource — killing
  the shared producer thread for ALL adapters; the driver then looped on
  EmptyBatchTimeoutError while get_or_create resurrected the worker into
  the same crash;
- every multi-LoRA sample carries a RewardSpec, and _resolve_reward_config
  treated any spec as final, so an adapter without its own rm_type never
  fell back to the sample metadata or the process-wide --rm-type /
  --custom-rm-path: every generated group failed reward computation and
  was silently dropped, starving the batch collector the same way.

Fixes, in three layers:

- resolve_adapter_config rejects a registration whose data path does not
  exist or whose reward config resolves to nothing anywhere (adapter
  fields or process args), so the API returns a clear error up front;
- _resolve_reward_config resolves per field: spec values win, unset
  fields fall through to sample metadata then args (the non-multi-LoRA
  spec-less chain is unchanged);
- the producer records an unexpected death in worker.failure and
  collect_batch raises it immediately with the original cause instead of
  waiting out the empty-batch timeout.

Covered by registration-validation tests, a reward-resolution precedence
test file, and a dead-producer surfacing test; existing controller test
fixtures now use a real data file and an explicit rm_type.
…per-param state

Slot cleanup zeroed exp_avg/exp_avg_sq and the per-param state['step'],
but TE/apex FusedAdam keeps the Adam clock per param GROUP: after a
tenant trained N steps, group['step'] stayed N through retirement, so the
slot's next tenant started with the previous adapter's bias-correction
clock (its early updates scaled as if N steps had already happened). The
per-param reset only ever covered torch's AdamW fallback, whose clock
lives in state['step'].

Reset the group clocks of the retired slot's param groups (matched by the
miles_multi_lora_slot tag the per-slot optimizer builder stamps on them);
co-tenant slots keep their clocks since every slot owns its own Adam
child. Covered by slot-cleanup tests for both clock layouts, stubbing the
bridge module so they run against any bridge build.
The model allocates every slot at --lora-rank, so export_adapter_weights
yields max-rank-padded tensors, while adapter_config.json declares the
adapter's real r — PEFT refuses such a checkpoint with a shape mismatch
(the example's gsm8k adapter at rank 16 under --lora-rank 32 hits this on
every save). The weight-sync push path already trims with
slice_lora_to_rank; apply the same trim on the checkpoint export. The
Megatron shard keeps the padded layout for slot resume, unchanged.

Adds unit tests for slice_lora_to_rank (per-dim trims for lora_A/lora_B,
hard failure on trained padding, pass-throughs), which had no coverage.
save_multi_lora_checkpoints elected writers with intra_dp.rank == 0, but
LoRA params are replicated across DP AND CP: with context_parallel_size >
1 every CP rank passed the gate, so cp_size ranks wrote the same
adapter_megatron_tp{t}_pp{p}.pt concurrently and raced each other's
shutil.rmtree/os.replace promotion of the same checkpoint dir. Gate on
the combined intra_dp_cp group instead: exactly one writer per (tp, pp)
shard and one global promoter.
The bridge LoRA model setup built DistributedDataParallelConfig with only
use_distributed_optimizer set, leaving grad_reduce_in_fp32 at its False
default — silently ignoring --accumulate-allreduce-grads-in-fp32 (which
the multi-LoRA run scripts pass). This matters doubly for multi-LoRA:
per-adapter accumulation RETAINS gradients in this DDP buffer across
train batches, so a bf16 buffer compounds rounding error with every
accumulated batch and every idempotent re-reduce. Wire the flag through.
…sions

Version bumps drive the rollout-side staleness filter. A new/recovered
engine triggers a full resync of every loaded adapter, and the bump set
was the push set, so adapters whose weights had not changed aged one
version per recovery: their buffered and in-flight groups drifted toward
--max-weight-staleness and were dropped despite being perfectly fresh.
Keep the full push, bump only the adapters that actually stepped (or
were newly loaded) since the last push.
Argument validation requires --num-rollout (when num_epoch is unset) and
the README says the run stops there, but the driver loop never read it —
the only exits were adapter exhaustion or service-mode idling. Cap the
loop at num_rollout as documented.
collect_batch already read multi_lora_max_empty_wait_s (falling back to
the 30s default), but the argument was never registered, so the timeout
was not actually configurable from the command line.
The per-slot optimizer machinery is Adam-only: _adam_init_state_fn seeds
exp_avg/exp_avg_sq, and slot retirement cleanup resets exactly those
moments plus the Adam step clocks. Muon already had a dedicated
rejection, but any other non-Adam optimizer (e.g. sgd) built fine and
silently leaked its state across slot tenants at retirement. Guard at
argument validation and again in build_multi_lora_optimizer.
The v2 train group has no reconcile_adapters, so the multi-LoRA driver's
first loop iteration dies with AttributeError after engines and models
are already up. Fail at argument validation with a clear message
instead. (Full v2 support also needs train-outcome propagation and
reconcile fan-out to every cell; until then the combination is
unsupported.)
The LR schedule budget is a single global sample count that every
adapter's steps drain together: under a decaying --lr-decay-style, an
adapter registered later in a service-mode run starts at an
already-decayed LR and can sit at --min-lr for its whole life. A
per-adapter schedule position is a larger design change; until then,
surface the sharp edge at launch (the shipped examples use constant LR
and are unaffected).
@yushengsu-thu

yushengsu-thu commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Note on Muon support (currently rejected at launch for multi-LoRA): it would be a small change (~50 LOC). Megatron's get_megatron_muon_optimizer mirrors build_multi_lora_optimizer exactly — same freeze-others param-group trick, same bf16/LayerWise delayed-master-weight dance, same use_distributed_optimizer=False + no-fp16 constraints — so per-slot Muon children slot straight into the existing LayerWise assembly; the rest is a momentum_buffer init/cleanup fn, a model.py branch-order flip, and relaxed guards.
Caveats: LoRA lora_B names don't match the linear_qkv.weight check so muon_split_qkv wouldn't apply, and Muon's benefit on r×d low-rank matrices is unproven. Deferred until there's an experimental need.
....
untill here is the basic version (the following is re-factor and move files

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