Repository navigation
[Perf] Free up temporary memory allocated for LoRA tensors for in-memory weight sync - #2492
Conversation
f9ea8b1 to
0c30582
Compare
0c30582 to
5a73341
Compare
There was a problem hiding this comment.
Code Review
This pull request implements memory offloading for in-memory LoRA adapters, moving activated adapters and staged tensors from GPU to pinned host memory once they are loaded into vLLM's LoRA slots to reduce GPU memory footprint. The review feedback suggests using torch.empty_strided instead of torch.empty to preserve the stride and layout of non-contiguous tensors, safely retrieving add_adapter using .get() to avoid potential KeyErrors during upstream refactoring, and adding test assertions to verify that offloaded tensor strides match their original GPU counterparts.
| key = (tensor.data_ptr(), tensor.dtype, tuple(tensor.shape), tensor.stride()) | ||
| host = moved.get(key) | ||
| if host is None: | ||
| host = torch.empty(tensor.shape, dtype=tensor.dtype, device="cpu", pin_memory=True) |
There was a problem hiding this comment.
Using torch.empty to allocate the host tensor creates a contiguous tensor, which discards the original stride/layout of non-contiguous tensors (e.g., transposed or sliced views). To preserve the exact stride and layout of the original GPU tensor, use torch.empty_strided instead.
| host = torch.empty(tensor.shape, dtype=tensor.dtype, device="cpu", pin_memory=True) | |
| host = torch.empty_strided(tensor.shape, tensor.stride(), dtype=tensor.dtype, device="cpu", pin_memory=True) |
| for cls in (WorkerLoRAManager, LRUCacheWorkerLoRAManager): | ||
| _ORIGINAL_ADD_ADAPTER[cls] = cls.__dict__["add_adapter"] | ||
| cls.add_adapter = _make_patched_add_adapter(_ORIGINAL_ADD_ADAPTER[cls]) |
There was a problem hiding this comment.
Accessing cls.__dict__["add_adapter"] directly will raise a KeyError if a future vLLM version refactors either WorkerLoRAManager or LRUCacheWorkerLoRAManager to inherit add_adapter rather than defining it directly. Using .get("add_adapter") and checking for None makes the patching logic more robust against upstream changes.
for cls in (WorkerLoRAManager, LRUCacheWorkerLoRAManager):
original = cls.__dict__.get("add_adapter")
if original is not None:
_ORIGINAL_ADD_ADAPTER[cls] = original
cls.add_adapter = _make_patched_add_adapter(original)| assert torch.equal(host, square.cpu()) | ||
| assert torch.equal(host_t, square.t().cpu()) |
There was a problem hiding this comment.
With the change to preserve strides using torch.empty_strided, we should assert that the strides of the offloaded host tensors match the original GPU tensors exactly.
| assert torch.equal(host, square.cpu()) | |
| assert torch.equal(host_t, square.t().cpu()) | |
| assert torch.equal(host, square.cpu()) | |
| assert host.stride() == square.stride() | |
| assert torch.equal(host_t, square.t().cpu()) | |
| assert host_t.stride() == square.t().stride() |
[High impact] Changes how in-memory LoRA adapters manage GPU and host memory. The PR appears safe to merge, with a non-blocking cleanup issue that delays returning some GPU memory. Findings
|
With `trainer.policy.model.lora.sync_mode=memory`, vLLM builds the received adapter on the GPU (`from_lora_tensors(device=GPU)`), packs MoE expert factors into stacked tensors (`PackedLoRALayerWeights.pack_moe`) and copies this worker's slice into its pre-allocated LoRA slots. The registered `LoRAModel` and SkyRL's staged tensors then stayed on the GPU, allocated outside vLLM's sleep-mode pool, through generation and training, although generation reads only the slots. vLLM's directory path keeps the same `LoRAModel` on pinned host memory. The in-memory LoRA patch now also wraps `add_adapter` of `WorkerLoRAManager` and `LRUCacheWorkerLoRAManager`. After a GPU-built adapter is activated, `offload_in_memory_adapter` moves the `LoRAModel`'s `lora_a` / `lora_b` and the staged tensors to pinned host memory (tensors that shared GPU storage share one host copy), synchronizes and calls `torch.cuda.empty_cache()`. Only ids built since the last activation are offloaded, so the per-step `add_adapter` call stays a set lookup. The offload runs under vLLM's `gpu_sync_allowed()`. vLLM re-activates an evicted adapter from the host tensors with `set_lora`, and a rebuild from the offloaded stage builds on the host, as the directory path does. Tests: CPU wiring tests in test_lora_in_memory_load.py, and GPU tests of the copy in gpu_ci/patches/vllm/test_lora_in_memory_offload.py. Signed-off-by: SumanthRH <sumanthrh99@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5a73341 to
ebed80f
Compare
What does this PR do?
Moves an in-memory LoRA adapter (
trainer.policy.model.lora.sync_mode=memory) off the GPU once vLLM has copied it into its LoRA slots, so the built adapter and the received tensors stop occupying GPU memory through generation and training.TLDR: after
add_adapteractivates a GPU-built in-memory adapter, the registeredLoRAModeland SkyRL's staged tensors move to pinned host memory, which is where vLLM keeps theLoRAModelfor an adapter loaded from a directory. The build itself stays on the GPU. No new config.Why it happens
sync_mode=memory, the receiver stages the adapter tensors on the GPU, andpatch_lora_in_memorybuilds theLoRAModelthere withfrom_lora_tensors(..., device=manager.device). Building on the GPU keeps deduplicated aliases sharing storage and keeps vLLM's MoE packing fast.add_adapter, vLLM packs every expert's factors into stacked tensors (PackedLoRALayerWeights.pack_moe,torch.stack). For MoE models this packed copy holds every local expert's factors, including one copy per expert of factors that were sent once and aliased. vLLM then copies this worker's slice into the LoRA slots it allocated at startup (activate_adapter->set_lora).LoRAModelas the source for re-activating the adapter after an eviction, and SkyRL keeps the staged tensors to rebuild it after an LRU eviction. Both stayed on the GPU. They are allocated outside vLLM's sleep-mode pool, so sleeping the engine does not free them, and they stay allocated through generation and through training in colocated setups. Generation only reads the slots.The directory path (
sync_mode=disk) does not have this cost:WorkerLoRAManager._load_adapterbuilds theLoRAModelon pinned CPU memory (from_local_checkpoint(..., device="cpu")) and only the slots are on the GPU.Size of the packed copy per vLLM worker: per MoE layer and local expert,
3 * r * (hidden + moe_intermediate)elements in the LoRA dtype (gate and up:A [r, hidden],B [moe_intermediate, r]; down:A [r, moe_intermediate],B [hidden, r]). With vLLM tensor parallelism and no expert parallelism every expert is local and theLoRAModelis not TP-sliced. For Qwen3-30B-A3B (48 layers x 128 experts, hidden 2048, moe_intermediate 768) at rank 64 that is 6.2 GiB per GPU in bf16, plus the staged tensors. In a colocated run on 8 GPUs, this PR lowers the peak GPU memory of every phase by about 6.4 GiB (see Validation). The cost grows with the rank, the number of experts and the number of MoE layers.More details on large-scale tests to follow
Solution
patch_lora_in_memory.py:apply_lora_in_memory_patchalso wrapsadd_adapteronWorkerLoRAManagerand onLRUCacheWorkerLoRAManager, which overrides it without callingsuper()._load_in_memory_adapterrecords thelora_int_idof every adapter it builds on the GPU. After the originaladd_adapterreturns (the adapter is packed, registered and activated), the wrapper callsoffload_in_memory_adapterfor those ids only. vLLM callsadd_adapteron every scheduler step, so for an adapter that is already active the wrapper does one set lookup. The offload runs under vLLM'sgpu_sync_allowed(), as the rest ofadd_adapter's device work does.offload_in_memory_adaptercopies eachlora_a/lora_bof the registeredLoRAModel(single tensors and per-slice lists) and the staged tensors to pinned host memory withnon_blocking=True. Copies are keyed by(data_ptr, dtype, shape, stride), so tensors that shared GPU storage (a dense module and its staged tensor, staged aliases,pack_moe's reuse of w1 for w3 in non-gated MoE) share one host copy, and two different views of the same storage get separate copies. It then synchronizes the stream and callstorch.cuda.empty_cache()so a colocated trainer can use the freed memory._load_in_memory_adapterbuilds on the host when the staged tensors are on the host. That is the case for a rebuild after an LRU eviction, which then works like a load from a directory:from_lora_tensors(device="cpu")returns the already pinned tensors as they are, so aliases stay shared, and no GPU memory is allocated for the rebuild while generation is running.After an eviction from the slots, vLLM re-activates the adapter with
set_lorafrom the host tensors, the same as for an adapter read from a directory..agents/docs/weight_sync.mdand theSkyRLLoraConfig.sync_modedocstring describe the new placement and the size formula.Alternatives considered
LoRAModelthere, as the directory path does. Lowest GPU memory during the sync, butpack_moe(torch.stackof every expert's factors) then runs on the host. For MoE adapters that is much slower than building on the GPU and moving the result.lora_a, w2lora_b) are replicated across TP ranks in vLLM's slot layout, so slicing would save little. It would also need a path through every LoRA layer'sset_lorathat accepts pre-sliced tensors, since vLLM slices unconditionally.fully_sharded_loras. Already supported. It shrinks the LoRA slots, not theLoRAModelcopy, so it is independent of this change and can be combined with it.Constraints
LoRAModelin pinned host memory per registered adapter, the same amount the directory path holds, plus the deduplicated staged tensors. Freed pinned blocks return to PyTorch's pinned-host cache, not to the OS.max_cpu_loras): the rebuild now runs on the host, includingpack_moe, inside a scheduler step. That is slower than the previous GPU rebuild for large MoE adapters, and matches what the directory path does in the same case. Single-tenant runs withmax_cpu_loras >= 1never rebuild.Test plan
CPU,
tests/backends/skyrl_train/weight_sync/test_lora_in_memory_load.py(vllmmarker, vLLM's realLoRAModel). Ameta-device stage stands in for GPU tensors:test_device_built_adapter_is_offloaded_once: a device-built adapter is offloaded once by the patchedadd_adapter, and not again on a repeatadd_adaptercall for the active adapter.test_disk_adapter_is_not_offloaded: a directory-backed request goes to vLLM's original loader and is never offloaded.test_patch_wraps_add_adapter_of_both_worker_managers: both managers'add_adapterare wrapped (LRUCacheWorkerLoRAManageroverrides it without callingsuper()).GPU,
tests/backends/skyrl_train/gpu/gpu_ci/patches/vllm/test_lora_in_memory_offload.py(new; runs in the FSDP GPU CI job). It drives vLLM'sLoRAModel.from_lora_tensorsandPackedLoRALayerWeights.pack_moeon CUDA, with a stand-in for the adapter manager:test_offload_moves_built_adapter_and_stage_to_pinned_host: everylora_a/lora_band staged tensor ends up pinned on the host,torch.cuda.memory_allocated()returns to its value before the sync, packed values are unchanged, and aliased staged tensors share one host copy.test_rebuild_from_offloaded_stage_stays_on_host: a rebuild from the offloaded stage (vLLM's LRU path) builds on the host and keeps aliases shared (no re-pinning copy).test_views_with_different_strides_get_their_own_host_copy: a tensor and its transpose get separate, correct host copies.The existing GPU e2e rows
no_colocate_nccl_megatron_adapter_memoryandcolocate_nccl_megatron_adapter_memoryintests/backends/skyrl_train/gpu/gpu_ci/test_lora.pycover sync and generation with the real vLLM managers after the offload.Validation
CPU, on a CPU-only run (
CUDA_VISIBLE_DEVICES=,RAY_ADDRESS=local):All 9 failures are
ModuleNotFoundError: No module named 'megatron'(test_moe_release_dispatcher_probs.py,test_sharded_rdt_ownership.py,test_sharded_rdt_source.py) and fail the same way onmainwithout this change.GPU (B200, rebased on
mainwith vLLM 0.31.0; the memory run below was on vLLM 0.30):End to end on Qwen3-30B-A3B: colocated GRPO on GSM8K, 1 node with 8 B200s, Megatron TP4 EP8, vLLM TP8, LoRA rank 64 with
merge_lora=falseandsync_mode=memory, 4 steps (examples/train/megatron/run_megatron_lora_qwen3-30b-a3b.shwith a smaller batch). Peak GPU memory per phase fromnvidia-smi(max over GPUs, steps 2-4),mainvs this PR:mainThe weight sync takes about 1 s longer (8.2-8.7 s vs 9.3-9.6 s, the device-to-host copy); step time is unchanged (63-66 s vs 64-65 s). The rollout-vs-trainer log-prob difference is unchanged (mean absolute difference 0.019-0.020 in both runs).
🤖 Generated with Claude Code
Note
Medium Risk
Changes vLLM worker LoRA lifecycle and GPU memory during sync/generation; LRU rebuilds now run
pack_moeon the host, but behavior is covered by new GPU/CPU tests and existing e2e LoRA memory rows.Overview
In-memory LoRA weight sync (
sync_mode=memory) no longer keeps the builtLoRAModeland staged tensors on the GPU after activation.After vLLM's patched
add_adaptercopies the adapter into LoRA slots,offload_in_memory_adaptermoves the registered model weights and SkyRL's staged tensors to pinned host memory (deduplicating shared GPU storage), synchronizes copies, and callstorch.cuda.empty_cache()so colocated trainers can reclaim GPU memory. Offload runs once per adapter via_AWAITING_OFFLOAD, including onLRUCacheWorkerLoRAManager(which overridesadd_adapterwithoutsuper()). Rebuilds after LRU eviction detect CPU-staged tensors and build on the host, matching the disk path.Docs (
weight_sync.md,SkyRLLoraConfig.sync_mode) describe the new lifecycle. Tests cover one-time offload, disk adapters unchanged, both manager classes patched, and GPU pinned-host behavior.Reviewed by Cursor Bugbot for commit ebed80f. Bugbot is set up for automated code reviews on this repo. Configure here.