-
Notifications
You must be signed in to change notification settings - Fork 316
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
33
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
33 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 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.