-
Notifications
You must be signed in to change notification settings - Fork 319
feat: multi lora async #1638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mathewjhan
wants to merge
70
commits into
radixark:main
Choose a base branch
from
mathewjhan:feat/multi-lora-async
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat: multi lora async #1638
Changes from 9 commits
Commits
Show all changes
70 commits
Select commit
Hold shift + click to select a range
def3841
[feat] multi lora fully async
mathewjhan 2f4b851
[feat] multi lora fully async
mathewjhan c1134f8
[misc] revert minor changes
mathewjhan 930a1d2
[fix] deterministic ordering
mathewjhan cf37c3f
[chore] precommit
mathewjhan 1bee66b
Merge branch 'main' into feat/multi-lora-async
mathewjhan 629dc85
[fix] import paths for tests
mathewjhan 9ca4536
[test] move tests
mathewjhan e371b2e
[fix] tests + keep original lora behavior for non multi-lora
mathewjhan 8bdd7d4
[fix] recompute-logprobs uses per-sample adapter lora_path in multi-LoRA
yushengsu-thu 1e93ea4
[fix] harden retired-adapter teardown against orphaned rollout requests
yushengsu-thu 50f6ff1
[test] mark recycle-aborted as xfail until re-queuing lands
yushengsu-thu a75a5b4
[fix] reject multi-LoRA with --sglang-tokenizer-worker-num > 1 at launch
yushengsu-thu c5cc40a
[feat] optimizer changes initial commit
mathewjhan 5c4a4e8
[fix] typo lol
mathewjhan f115610
[fix] handle empty
mathewjhan 52b0410
[feat] use num_step instead of num_row
mathewjhan d81bf52
[fix] missing import
mathewjhan 5f1e83f
[fix] smoke
mathewjhan 7245240
[test] move tests to test dir
mathewjhan 9aea756
[chore] improve metrics
mathewjhan c4bed1d
[test] clean up some tests
mathewjhan 2100479
[feat] improve metrics
mathewjhan d972436
[fix] metrics for queue don't need step
mathewjhan ce6c2dd
[fix] metrics glob expansion
mathewjhan 54e13f8
[fix] steps
mathewjhan 07d9e9b
[chore] minor naming
mathewjhan 031c086
Revert "[fix] steps"
mathewjhan e22ca8d
Merge remote-tracking branch 'radixark/main' into feat/multi-lora-async
yushengsu-thu 8fa298a
[fix] metric naming
mathewjhan 75d7538
[feat] improve adapter metrics + adapter metric use real adapter steps
mathewjhan 793715a
[test]
mathewjhan 3c1e9d8
[misc] deprecate num_row in favor of num_step
mathewjhan 3ba0d99
[fix] get_batch CP regressions: nonexistent Tensor.batch() and double…
yushengsu-thu 2b22920
[test] pin get_batch CP=2 behavior: multi-LoRA token counts and singl…
yushengsu-thu ef2ba07
[fix] multi-LoRA validation crashes when --global-batch-size is unset
yushengsu-thu 95ab736
[fix] re-registered adapter name must not inherit the previous tenant…
yushengsu-thu 4455037
[fix] reject bad adapter registrations instead of killing the shared …
yushengsu-thu f0940c1
[fix] reset the retired slot's group-level FusedAdam clock, not just …
yushengsu-thu 0d4cfb2
[fix] slice HF PEFT checkpoint exports to the adapter's real rank
yushengsu-thu 45ef970
[fix] gate adapter checkpoint writers on the dp x cp group, not dp alone
yushengsu-thu 9e51024
[fix] honor --accumulate-allreduce-grads-in-fp32 on the bridge DDP path
yushengsu-thu 918b6db
[fix] engine-recovery full push must not bump unchanged adapters' ver…
yushengsu-thu c02f79a
[fix] honor --num-rollout in the multi-LoRA driver loop
yushengsu-thu 1b6f69d
[fix] register --multi-lora-max-empty-wait-s as a CLI argument
yushengsu-thu 0461e7c
[fix] reject non-Adam optimizers for multi-LoRA at launch
yushengsu-thu 1a4ef27
[fix] reject multi-LoRA with MILES_EXPERIMENTAL_FT_TRAINER at launch
yushengsu-thu c42d9e5
[misc] warn when multi-LoRA runs with a decaying LR schedule
yushengsu-thu 3804f78
add comment
yushengsu-thu f9381b7
change controller name to multilora controller
yushengsu-thu f8f8779
Revert "[fix] honor --num-rollout in the multi-LoRA driver loop"
yushengsu-thu 7cc00a6
[fix] multi-LoRA does not require --num-rollout
yushengsu-thu d5305bc
Revert "[fix] multi-LoRA does not require --num-rollout"
yushengsu-thu ebdba18
[fix] per-adapter LR schedule for multi-LoRA
yushengsu-thu d5b81c1
[refactor] move multi-LoRA rollout infra from examples into miles/rol…
yushengsu-thu 34bf4bc
[refactor] split miles/utils/multi_lora.py into the miles/ray/multi_l…
yushengsu-thu 85a23c7
[refactor] move the multi-LoRA driver to the repo root, default its p…
yushengsu-thu 0bd4291
[refactor] extract validate_multi_lora_args out of miles_validate_args
yushengsu-thu e16b310
[refactor] extract the multi-LoRA optimizer-step arm out of train_one…
yushengsu-thu 06ea30d
[refactor] extract the actor's multi-LoRA blocks into multi_lora_util…
yushengsu-thu a83b90a
Merge remote-tracking branch 'radixark/main' into feat/multi-lora-async
yushengsu-thu a3c8daa
[refactor] split _update_multi_lora_weight_implementation out of the …
yushengsu-thu 7839bd6
[refactor] drop the dead upsert plumbing, tidy _send_one_multi_lora_a…
yushengsu-thu e068a49
[lint] apply isort and black to the multi-LoRA files
yushengsu-thu 39fcb29
[style] make both LoRA-instance branches lazy-import symmetrically
yushengsu-thu a1ce9e4
[refactor] get_batch fetches adapter_slots itself
yushengsu-thu c368a30
Merge remote-tracking branch 'radixark/main' into feat/multi-lora-async
yushengsu-thu 323a991
[example] replace the multi-LoRA shell scripts with a typer launcher
yushengsu-thu 38a576c
[docs] compress the multi-LoRA comments and docstrings to one or two …
yushengsu-thu 47c3ec0
Merge remote-tracking branch 'radixark/main' into feat/multi-lora-async
yushengsu-thu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # Multi-LoRA Training Example (fully-async) | ||
|
|
||
| Train multiple LoRA adapters concurrently against a shared base model, using a | ||
| fully-async rollout (continuous producer) + a slot-keyed LoRA page table on the | ||
| SGLang engines (in-place upsert, no unload, no drain). | ||
|
|
||
| This example trains two adapters on Qwen3-4B: | ||
|
|
||
| - **gsm8k** — grade-school math, `rm_type: math` | ||
| - **dapo_math** — competition math (DAPO-Math-17k), `rm_type: deepscaler` | ||
|
|
||
| ## Layout | ||
|
|
||
| ``` | ||
| provision.sh # one-time: download model + datasets | ||
| run_job.sh # entrypoint: bounded run, exits when done | ||
| run_service.sh # service mode: idles for registrations (port 8068) | ||
| service_smoke.py # register/deregister smoke test against the API | ||
| train_multi_lora_async.py # trainer (entry point) | ||
| multi_lora_async_rollout.py # fully-async rollout function | ||
| multi_lora_data_source_async.py # data source (reads controller, deregisters at num_row) | ||
| adapters/ | ||
| gsm8k.yaml | ||
| dapo_math.yaml | ||
| ``` | ||
|
|
||
| Controller code lives in the library: `miles/utils/multi_lora.py` (registry + | ||
| backend + HTTP API, torch-free) and `miles/ray/multi_lora_controller.py` (named | ||
| Ray actor, pinned to the head node). | ||
|
|
||
| ## Design (no drain, no state machine) | ||
|
|
||
| - **Controller** (Ray actor + control-plane HTTP API) is the source of truth: | ||
| `POST/GET/DELETE /adapter_runs` plus `GET /adapter_runs/state`. The data source | ||
| reads it; the trainer reads it. Generation traffic goes straight to the router; | ||
| on deregister the controller aborts the adapter's in-flight requests | ||
| engine-side by rid prefix (`rid = {adapter}::{uuid}`, set in `generate`). | ||
| - **No drain / no rollout-id / no train_steps / no PENDING-DRAINING-DRAINED states.** | ||
| The data source deregisters an adapter at `num_row`; the trainer's | ||
| `reconcile_adapters` (before each generate) cleans up gone adapters (save ckpt + | ||
| clear Megatron slot) and loads new ones. `update_weights` upserts active adapters' | ||
| weights in place (SGLang page table, `upsert=True`). | ||
| - **Batch ⊆ loaded property:** `reconcile_adapters` runs before `generate`, so the | ||
| batch is fetched with loaded = active; active only shrinks during generate, so every | ||
| adapter in the batch is live on the trainer. | ||
|
|
||
| ## Provision (once) | ||
|
|
||
| ```bash | ||
| bash examples/multi_lora/provision.sh | ||
| ``` | ||
|
|
||
| Downloads `Qwen/Qwen3-4B`, `zhuzilin/dapo-math-17k`, and `zhuzilin/gsm8k`. | ||
|
|
||
| ## Run | ||
|
|
||
| ```bash | ||
| bash examples/multi_lora/run_job.sh | ||
| ``` | ||
|
|
||
| Registers the two adapters from CLI flags and trains until each hits its `num_row` | ||
| (or `--num-rollout`), then exits. | ||
|
|
||
| ## Multi-LoRA CLI flags | ||
|
|
||
| | Flag | Purpose | | ||
| | --- | --- | | ||
| | `--multi-lora-n-adapters N` | Max concurrent adapter slots. `0` disables (default); `> 0` enables. | | ||
| | `--multi-lora-adapter NAME PATH` | Register an adapter at startup. Repeatable. `PATH` → an `adapter.yaml`. | | ||
|
|
||
| Per-adapter `rank` in `adapter.yaml` must be `<= --lora-rank`. | ||
|
|
||
| ## adapter.yaml | ||
|
|
||
| ```yaml | ||
| rank: 16 | ||
| alpha: 16 | ||
| data: /root/gsm8k/train.parquet | ||
| input_key: messages | ||
| label_key: label | ||
| rm_type: math | ||
| num_row: 400 # stop adapter after N rows | ||
| # optional: save, num_epoch, custom_rm_path, ... | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| rank: 32 | ||
| alpha: 32 | ||
| data: /root/dapo-math-17k/dapo-math-17k.jsonl | ||
| input_key: prompt | ||
| label_key: label | ||
| rm_type: deepscaler | ||
| num_row: 500 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| rank: 16 | ||
| alpha: 16 | ||
| data: /root/gsm8k/train.parquet | ||
| input_key: messages | ||
| label_key: label | ||
| rm_type: math | ||
| num_row: 400 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| """Fully-async multi-LoRA rollout: continuous background producer + collect-a-batch.""" | ||
|
|
||
| import asyncio | ||
| import itertools | ||
| import logging | ||
| import queue | ||
| import threading | ||
| import time | ||
| from collections.abc import Callable | ||
| from typing import Any | ||
|
|
||
| from miles.ray.multi_lora_controller import AdaptersCache, get_multi_lora_controller | ||
| from miles.rollout.base_types import RolloutFnTrainOutput | ||
| from miles.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter | ||
| from miles.rollout.generate_utils.prefill_logprobs import recompute_samples_rollout_logprobs_via_prefill | ||
| from miles.rollout.sglang_rollout import GenerateState, generate_and_rm_group, get_model_url | ||
| from miles.utils.async_utils import run | ||
| from miles.utils.misc import load_function | ||
| from miles.utils.types import Sample | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| GenerateFn = Callable[..., Any] | ||
|
|
||
| # Generate fns may return several samples per rollout; the manager flattens later. | ||
| Group = list[Sample | list[Sample]] | ||
|
|
||
|
|
||
| def iter_group_samples(group: Group): | ||
| return itertools.chain.from_iterable(item if isinstance(item, list) else (item,) for item in group) | ||
|
|
||
|
|
||
| def first_sample(group: Group) -> Sample: | ||
| return group[0][0] if isinstance(group[0], list) else group[0] | ||
|
|
||
|
|
||
| async def process_group( | ||
| args, group: list[Sample], sampling_params: dict, generate_fn: GenerateFn, data_source | ||
| ) -> Group | None: | ||
| """Generate a group; returns None for aborted groups. The slot version is | ||
| stamped at submission time (what the staleness filter compares against).""" | ||
| adapter_name = group[0].adapter.name if group and group[0].adapter else None | ||
| submission_version: int | None = None | ||
| if adapter_name is not None: | ||
| adapter = await AdaptersCache().get(adapter_name) | ||
| submission_version = adapter.version if adapter is not None else None | ||
|
|
||
| if submission_version is not None: | ||
| for s in group: | ||
| s.metadata["slot_version"] = submission_version | ||
|
|
||
| result = await generate_fn(args, group, sampling_params) | ||
|
|
||
| if submission_version is not None: | ||
| for s in iter_group_samples(result): | ||
| s.metadata["slot_version"] = submission_version | ||
|
|
||
| if any(s.status == Sample.Status.ABORTED for s in iter_group_samples(result)): | ||
| for s in iter_group_samples(result): | ||
| s.reset_for_retry() | ||
| # Re-queuing is not wired up (the per-adapter source is read-only). | ||
| return None | ||
| return result | ||
|
|
||
|
|
||
| class AsyncMultiLoRAWorker: | ||
| """Background producer: continuously generate groups into a thread-safe queue.""" | ||
|
|
||
| global_worker = None | ||
| worker_lock = threading.Lock() | ||
|
|
||
| def __init__(self, args, data_source, generate_fn: GenerateFn, concurrency: int = None) -> None: | ||
| self.args = args | ||
| self.data_source = data_source | ||
| self.generate_fn = generate_fn | ||
| self.concurrency = concurrency or args.rollout_batch_size | ||
| self.running = True | ||
| self.output_queue: queue.Queue = queue.Queue(maxsize=1000) | ||
| self.worker_thread: threading.Thread | None = None | ||
| self.state = GenerateState(args) | ||
|
|
||
| @classmethod | ||
| def get_or_create(cls, args, data_source, generate_fn: GenerateFn, concurrency: int = None): | ||
| with cls.worker_lock: | ||
| if cls.global_worker is None or not cls.global_worker.worker_thread.is_alive(): | ||
| cls.global_worker = cls(args, data_source, generate_fn, concurrency) | ||
| cls.global_worker.start() | ||
| return cls.global_worker | ||
|
|
||
| def start(self) -> None: | ||
| self.worker_thread = threading.Thread(target=self.thread_main, daemon=True) | ||
| self.worker_thread.start() | ||
|
|
||
| def stop(self) -> None: | ||
| self.running = False | ||
| if self.worker_thread and self.worker_thread.is_alive(): | ||
| self.worker_thread.join(timeout=5) | ||
|
|
||
| def thread_main(self) -> None: | ||
| asyncio.run(self.run_loop()) | ||
|
|
||
| async def run_loop(self) -> None: | ||
| active: set[asyncio.Task] = set() | ||
| max_concurrent = self.concurrency | ||
| try: | ||
| while self.running: | ||
| done = {t for t in active if t.done()} | ||
| for t in done: | ||
| try: | ||
| t.result() | ||
| except Exception as e: | ||
| logger.warning(f"generate task failed: {e}") | ||
| active.discard(t) | ||
|
|
||
| while len(active) < max_concurrent and self.running: | ||
| samples = self.data_source.get_samples(1) | ||
| if not samples: | ||
| break | ||
| group = samples[0] | ||
| active.add(asyncio.create_task(self.process_and_enqueue(group))) | ||
|
|
||
| await asyncio.sleep(0) | ||
| finally: | ||
| if active: | ||
| await asyncio.wait(active) | ||
|
|
||
| async def process_and_enqueue(self, group: list[Sample]) -> None: | ||
| result = await process_group(self.args, group, self.state.sampling_params, self.generate_fn, self.data_source) | ||
| if result is not None: | ||
| self.output_queue.put(result) | ||
|
|
||
| def queue_size(self) -> int: | ||
| return self.output_queue.qsize() | ||
|
|
||
|
|
||
| async def generate_rollout_multi_lora_async( | ||
| args, rollout_id: int, data_source, generate_fn: GenerateFn = generate_and_rm_group | ||
| ) -> tuple[RolloutFnTrainOutput, list[list[Sample]]]: | ||
| """Fully-async multi-LoRA rollout. Collect a batch from the background worker, | ||
| then run the same postprocess as ``generate_rollout_async``.""" | ||
| assert args.rollout_global_dataset | ||
|
|
||
| state = GenerateState(args) | ||
|
|
||
| dynamic_filter = load_function(args.dynamic_sampling_filter_path) if args.dynamic_sampling_filter_path else None | ||
| metric_gatherer = MetricGatherer() | ||
| target_data_size = args.rollout_batch_size | ||
|
|
||
| worker = AsyncMultiLoRAWorker.get_or_create(args, data_source, generate_fn) | ||
|
|
||
| # Groups whose submission-time slot version fell too far behind are dropped. | ||
| max_staleness = getattr(args, "max_weight_staleness", None) | ||
|
|
||
| data: list[Group] = [] | ||
| stale_dropped = 0 | ||
| staleness_values: list[int] = [] | ||
| start_time = time.time() | ||
| last_progress = start_time | ||
| queue_length = worker.queue_size() | ||
| while len(data) < target_data_size: | ||
| made_progress = False | ||
| current_adapters = await AdaptersCache().get_all() | ||
| # Pop one at a time so surplus groups stay queued for the next batch. | ||
| while len(data) < target_data_size: | ||
| try: | ||
| group = worker.output_queue.get_nowait() | ||
| except queue.Empty: | ||
| break | ||
| head = first_sample(group) if group else None | ||
| adapter_name = head.adapter.name if head is not None and head.adapter else None | ||
| if adapter_name not in current_adapters: | ||
| continue # adapter deregistered; drop | ||
| if max_staleness is not None: | ||
| stamped = head.metadata.get("slot_version") | ||
| if stamped is not None: | ||
| staleness = current_adapters[adapter_name].version - stamped | ||
| if staleness > max_staleness: | ||
| for s in iter_group_samples(group): | ||
| s.reset_for_retry() | ||
| stale_dropped += 1 | ||
| staleness_values.append(staleness) | ||
| logger.info( | ||
| f"Dropped stale group (adapter={adapter_name}, " | ||
| f"stamped={stamped}, current={current_adapters[adapter_name].version}, " | ||
| f"staleness={staleness} > max={max_staleness})" | ||
| ) | ||
| continue | ||
| f = call_dynamic_filter(dynamic_filter, args, group) | ||
| if not f.keep: | ||
| metric_gatherer.on_dynamic_filter_drop(reason=f.reason) | ||
| continue | ||
| data.append(group) | ||
| made_progress = True | ||
|
|
||
| if made_progress: | ||
| last_progress = time.time() | ||
| elif time.time() - last_progress > 30: | ||
| logger.warning( | ||
| f"No progress for 30s. queue={worker.queue_size()} collected={len(data)}/{target_data_size}" | ||
| ) | ||
| last_progress = time.time() | ||
|
|
||
| if len(data) < target_data_size: | ||
| await asyncio.sleep(0.01) | ||
|
|
||
| if stale_dropped: | ||
| logger.info( | ||
| f"Staleness stats: dropped={stale_dropped}, " | ||
| f"avg_staleness={sum(staleness_values) / len(staleness_values):.1f}, " | ||
| f"max_staleness={max(staleness_values)}" | ||
| ) | ||
|
|
||
| data = sorted(data, key=lambda g: first_sample(g).index) | ||
|
|
||
| batch_adapters = sorted({first_sample(g).adapter.name for g in data if g and first_sample(g).adapter}) | ||
| if batch_adapters: | ||
| await get_multi_lora_controller().record_batch_adapters.remote(rollout_id, batch_adapters) | ||
|
|
||
| if (x := args.rollout_sample_filter_path) is not None: | ||
| load_function(x)(args, data) | ||
|
|
||
| await recompute_samples_rollout_logprobs_via_prefill( | ||
| args, | ||
| [s for g in data for s in iter_group_samples(g)], | ||
| url=get_model_url(args, "default"), | ||
| sampling_params=state.sampling_params, | ||
| ) | ||
|
|
||
| metrics = { | ||
| **metric_gatherer.collect(), | ||
| "perf/fully_async/queue_length": queue_length, | ||
| "perf/fully_async/batch_wait_time": time.time() - start_time, | ||
| "perf/fully_async/stale_dropped": stale_dropped, | ||
| } | ||
| if staleness_values: | ||
| metrics["perf/fully_async/stale_dropped_avg_staleness"] = sum(staleness_values) / len(staleness_values) | ||
|
|
||
| return RolloutFnTrainOutput(samples=data, metrics=metrics) | ||
|
|
||
|
|
||
| def generate_rollout_multi_lora(args, rollout_id: int, data_source, evaluation: bool = False): | ||
| if evaluation: | ||
| raise ValueError("Evaluation not supported in multi-LoRA async rollout") | ||
| return run(generate_rollout_multi_lora_async(args, rollout_id, data_source)) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.