Skip to content

Commit bd5271d

Browse files
committed
Optimize performance
Signed-off-by: Hollow Man <hollowman@opensuse.org>
1 parent 2180af2 commit bd5271d

11 files changed

Lines changed: 2293 additions & 240 deletions

File tree

Submodule Megatron-Bridge updated 80 files

docs/design-docs/checkpoint-engines.md

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ policy:
5454
nixl:
5555
device: cuda
5656
topology: auto
57+
transfer_mode: staged
58+
buffer_count: 2
59+
background_progress: false
60+
load_batch_bucket_count: 1
61+
direct_min_bytes: 2147483648
62+
direct_stripe_count: 1
63+
metadata_batch_size: 1
5764
backend_name: UCX
5865
backend_init_params:
5966
ucx_error_handling_mode: peer
@@ -226,16 +233,20 @@ The built-in NIXL backend is registered as `nixl`. It uses:
226233

227234
- NIXL agents for memory registration and transfer
228235
- ZMQ messages for bucket metadata and transfer notifications
229-
- two reusable transfer buffers per worker for pipelined bucket movement
236+
- reusable transfer buffers per worker for pipelined bucket movement
237+
- optional direct source descriptors for contiguous policy tensor chunks
230238
- `split_weight_chunks()` and `merge_weight_chunk_batches()` for tensors larger
231239
than one bucket
232240

233-
The NIXL backend chooses one of two topologies:
241+
The NIXL backend chooses one of three topologies:
234242

235243
- If `train_world_size >= rollout_world_size`, each rollout rank is paired with
236244
a policy rank. Extra policy ranks are idle and do not materialize weights.
237-
- If `rollout_world_size > train_world_size`, policy rank 0 sends into a chain
238-
of rollout ranks that forward buckets.
245+
- If `rollout_world_size > train_world_size`, `auto` uses a rollout-side
246+
binary tree rooted at policy rank 0. Tree forwarding avoids the fully
247+
serialized rollout chain.
248+
- `leader_chain` keeps the older single-policy-sender chain for debugging and
249+
benchmark comparison.
239250

240251
Set `engine_kwargs.nixl.topology=leader_chain` to force the single-policy-sender
241252
chain even when policy workers can cover rollout workers. This reduces
@@ -248,6 +259,71 @@ read handles alive for the lifetime of the worker. Reusing these objects avoids
248259
repeated multi-GB memory registration and transfer-handle initialization in
249260
long-lived Ray/vLLM actors.
250261

262+
`transfer_mode=staged` is the conservative default: policy tensors are copied
263+
into reusable registered buckets, and small tensors are packed to reduce
264+
metadata overhead. `transfer_mode=auto` uses direct NIXL descriptors only for
265+
large contiguous policy tensors at least as large as the staged bucket size.
266+
Sub-bucket tensors stay staged because direct descriptors lose the coalescing
267+
benefit of staged buckets and can make source-side registration dominate. Use
268+
`transfer_mode=direct` to force direct descriptors for all contiguous tensors
269+
when benchmarking the raw source-direct path. Increase `buffer_count` when
270+
sender-side wait time shows that two staged buffers are not enough to keep
271+
reads in flight. The receiver also uses extra buffers to prefetch future reads
272+
before yielding the current transfer bucket to vLLM, while preserving buffer
273+
lifetime until vLLM has consumed the yielded views.
274+
275+
`direct_stripe_count` splits each direct-transfer bucket into smaller direct
276+
read stripes. This is an opt-in direct/auto-mode tuning knob for clusters where
277+
one large NIXL read does not keep the available RDMA lanes busy. Pair it with
278+
enough receiver `buffer_count` headroom and set `metadata_batch_size` above `1`
279+
so the additional stripe descriptors do not turn into one ZMQ publish per
280+
stripe. Staged mode keeps using the normal bucket size and is unaffected by
281+
direct striping.
282+
283+
Bucket metadata is still carried on the Python control path, but the NIXL
284+
backend can publish several metadata records in one control message. Receivers
285+
unwrap the batch before starting reads, so the data path and ordering contract
286+
remain one logical bucket at a time.
287+
288+
When `background_progress=true`, each receiver read has a lightweight progress
289+
thread that polls NIXL state while the main thread is inside the synchronous
290+
vLLM load path. This reduces exposed wait time for reads that were started
291+
before the current load batch was yielded. It is disabled by default and should
292+
be enabled only when cluster benchmarks show stable tail latency.
293+
294+
`load_batch_bucket_count` can coalesce multiple received transfer buckets into
295+
one vLLM load call. NIXL uses explicit bucket leases for this path: chunks that
296+
were copied into a reconstructed tensor release their transfer bucket
297+
immediately, while chunks that are exposed as tensor views keep their bucket
298+
leased until the outer vLLM load batch returns. This preserves view lifetime
299+
while reducing Python load-call overhead. Benchmark values above `1` with
300+
enough `buffer_count` headroom, because coalescing too many buckets can reduce
301+
receive prefetch overlap.
302+
303+
For CUDA-backed batches, NIXL returns a list subclass that implements
304+
`record_cuda_load_complete()`. vLLM calls this after `load_weights()` queues its
305+
copies. NIXL records CUDA events on the batch devices and waits on those events
306+
before recycling transfer buffers, which avoids a hard
307+
`torch.cuda.current_stream().synchronize()` after every load batch.
308+
309+
Buffer recycling uses an async release queue. If the receiver already has
310+
another read in flight, the batch lease is released by a background task after
311+
the load-complete CUDA event is ready. If releasing the current bucket is needed
312+
to prefetch the next bucket, the release stays inline so the receive generator
313+
cannot run out of work and terminate early.
314+
315+
On the sender, staged buckets use CUDA events instead of a device-wide sync
316+
before every metadata publish. The sender enqueues the staged copy, records
317+
events for the involved CUDA devices, and keeps filling later buffers. Metadata
318+
publication still happens in original bucket order; a pending bucket is exposed
319+
only after its copy event is complete, and buffer reuse waits for the previous
320+
read notification for that buffer.
321+
322+
When the runtime NIXL Python agent exposes a native `progress()` method, the
323+
wrapper invokes it under the agent lock before polling transfer state or
324+
notifications. Runtimes without that method keep the existing
325+
`check_xfer_state()`/notification polling behavior.
326+
251327
### Fault-Tolerance Boundary
252328

253329
The NIXL backend is restart-safe, not actor-healing. It is designed so a failed

docs/guides/checkpoint-engine-refit.md

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ policy:
3636
device: cuda
3737
cleanup_after_load: false
3838
topology: auto
39+
transfer_mode: staged
40+
buffer_count: 2
41+
background_progress: false
42+
load_batch_bucket_count: 1
43+
direct_min_bytes: 2147483648
44+
direct_stripe_count: 1
45+
metadata_batch_size: 1
3946
backend_name: UCX
4047
backend_init_params:
4148
ucx_error_handling_mode: none
@@ -57,7 +64,14 @@ participating worker. `2048` MiB is a good starting point for large models.
5764
|---|---|
5865
| `device` | Transfer-buffer device. Use `cuda` for the fast GPU-RDMA path when the NIXL/UCX runtime supports CUDA memory registration. Use `cpu` only as a host-pinned fallback when CUDA buffers are unavailable. |
5966
| `cleanup_after_load` | Whether vLLM should run garbage collection and `torch.cuda.empty_cache()` after loading each refit. Disabling this avoids extra steady-state overhead when memory is stable. |
60-
| `topology` | Transfer topology. `auto` keeps the paired topology when policy workers cover rollout workers and falls back to `leader_chain` otherwise. Use `leader_chain` to force one policy sender and rollout-side NIXL forwarding. |
67+
| `topology` | Transfer topology. `auto` keeps the paired topology when policy workers cover rollout workers and falls back to `leader_tree` otherwise. Use `leader_chain` to force the older single chain for comparison. |
68+
| `transfer_mode` | Source-side transfer mode. `staged` copies policy tensors into reusable NIXL buckets. `direct` exposes contiguous policy tensor chunks directly through NIXL and stages only tensors that cannot be directly exposed. `auto` stages sub-bucket tensors but uses direct descriptors for contiguous tensors at or above `max(direct_min_bytes, update_weights_bucket_megabytes)`. |
69+
| `buffer_count` | Number of reusable staged transfer buffers. `2` is the original ping-pong path. `3` or higher allows more outstanding staged reads before a sender reuses a buffer. |
70+
| `background_progress` | Whether the receiver starts a lightweight background progress thread for in-flight NIXL reads. This lets NIXL progress continue while vLLM synchronously loads the previous batch. Keep this `false` unless the target cluster shows stable tail latency with it enabled. |
71+
| `load_batch_bucket_count` | Number of received transfer buckets to coalesce into one vLLM load batch. Keep this at `1` unless enough buffers are available to coalesce without starving receive prefetch; benchmark `2` with `buffer_count >= 4`. |
72+
| `direct_min_bytes` | Minimum tensor size for `transfer_mode: auto` direct transfer. Auto mode also floors this at the staged bucket size, because direct descriptors for sub-bucket tensors lose the coalescing benefit of staged buckets. Start at the bucket size, for example `2147483648` with 2048 MiB buckets, and tune against the staged baseline. |
73+
| `direct_stripe_count` | Number of stripes to split each direct-transfer bucket into. This only affects `transfer_mode: auto` or `direct`; `1` preserves the existing one-read-per-bucket behavior. Try `2` or `4` only with enough `buffer_count` headroom for concurrent receiver reads. |
74+
| `metadata_batch_size` | Number of bucket metadata records to publish together on the control path. `1` preserves one ZMQ object per bucket. Values above `1` mainly help direct-striped runs where many direct stripe descriptors are emitted together. |
6175
| `backend_name` | NIXL backend plugin name, usually `UCX`. |
6276
| `backend_init_params` | Optional NIXL backend initialization parameters. Values are converted to strings before NIXL receives them. |
6377

@@ -84,6 +98,9 @@ policy:
8498
device: cuda
8599
cleanup_after_load: false
86100
topology: auto
101+
transfer_mode: staged
102+
buffer_count: 2
103+
direct_min_bytes: 2147483648
87104
backend_name: UCX
88105
backend_init_params:
89106
ucx_error_handling_mode: peer
@@ -141,6 +158,8 @@ uv run --extra mcore --extra vllm examples/run_grpo.py \
141158
++policy.generation.checkpoint_engine.engine_kwargs.nixl.device=cpu \
142159
++policy.generation.checkpoint_engine.engine_kwargs.nixl.cleanup_after_load=false \
143160
++policy.generation.checkpoint_engine.engine_kwargs.nixl.topology=auto \
161+
++policy.generation.checkpoint_engine.engine_kwargs.nixl.transfer_mode=staged \
162+
++policy.generation.checkpoint_engine.engine_kwargs.nixl.buffer_count=2 \
144163
++policy.generation.checkpoint_engine.engine_kwargs.nixl.backend_name=UCX \
145164
++policy.generation.checkpoint_engine.engine_kwargs.nixl.backend_init_params.ucx_error_handling_mode=none
146165
```
@@ -206,12 +225,19 @@ policy:
206225
checkpoint_engine:
207226
enabled: true
208227
backend: nixl
209-
update_weights_bucket_megabytes: 2048
228+
update_weights_bucket_megabytes: 1024
210229
engine_kwargs:
211230
nixl:
212231
device: cuda
213232
cleanup_after_load: false
214233
topology: auto
234+
transfer_mode: staged
235+
buffer_count: 3
236+
background_progress: false
237+
load_batch_bucket_count: 1
238+
direct_min_bytes: 2147483648
239+
direct_stripe_count: 1
240+
metadata_batch_size: 1
215241
backend_name: UCX
216242
backend_init_params:
217243
ucx_error_handling_mode: none
@@ -247,18 +273,37 @@ Use measured timings to tune from there:
247273
- Prefer RDMA transports; logs should show `rc_mlx5`, not TCP-only transport.
248274
- Sweep bucket size around `1024` to `2048` MiB instead of assuming larger is
249275
always faster. On the latest tested 30B MoE CUDA-buffer refit, eight rails
250-
with `2048` MiB buckets was best; for CPU fallback, `1536` MiB buckets were
251-
best.
276+
with `1024` MiB buckets and three staged buffers reduced steady vLLM receive
277+
time from about `0.90s` to about `0.82s`; for CPU fallback, `1536` MiB
278+
buckets were best.
252279
- Try multiple UCX rails when the fabric supports it. On the same 30B MoE
253-
setup, eight CUDA rails reduced steady transfer/update to about `1.46s`,
254-
compared with about `3.7s` for four CUDA rails and about `3.1s` for NCCL
255-
broadcast. CPU fallback regressed with eight rails; keep it on four rails
256-
unless a local sweep proves otherwise.
280+
setup, eight CUDA rails plus three staged buffers reduced steady
281+
transfer/update to about `1.36s` to `1.38s`, compared with about `3.7s` for
282+
four CUDA rails and about `3.1s` for NCCL broadcast. CPU fallback regressed
283+
with eight rails; keep it on four rails unless a local sweep proves otherwise.
257284
- Pass the RDMA NICs through NIXL UCX `backend_init_params.device_list` as well
258285
as process-level `UCX_NET_DEVICES` when using CUDA transfer buffers. On the
259286
tested 30B MoE setup, adding the eight-device list plus
260-
`engine_config: MAX_RMA_RAILS=8` reduced steady transfer/update from about
261-
`3.7s` to about `1.46s`.
287+
`engine_config: MAX_RMA_RAILS=8`, then using `1024` MiB buckets with three
288+
buffers, reduced steady transfer/update from about `3.7s` to about `1.36s`
289+
to `1.38s`.
290+
- Benchmark `transfer_mode: auto` against the staged baseline. Auto mode keeps
291+
small tensors packed in staged buckets, but exposes large contiguous policy
292+
tensor chunks directly to NIXL to avoid the policy-side bucket copy. If direct
293+
descriptor setup dominates on a model, keep `transfer_mode: staged`.
294+
- For direct or auto transfer, benchmark `direct_stripe_count: 2` or `4`
295+
together with `metadata_batch_size` set to the same value. This can expose
296+
multiple reads for a large tensor chunk while batching the Python control
297+
messages. It is not expected to help `transfer_mode: staged`.
298+
- Benchmark `buffer_count: 3` against `2`. More staged buffers let the sender
299+
keep additional reads outstanding before reusing a buffer, and the receiver
300+
can prefetch future reads before yielding the current bucket to vLLM. Each
301+
extra buffer reserves another `update_weights_bucket_megabytes` on every
302+
participating worker.
303+
- Use `topology: leader_tree` when rollout workers outnumber policy workers.
304+
`auto` selects this topology for that shape. Keep `leader_chain` only as a
305+
comparison point or for debugging because the chain serializes rollout-side
306+
forwarding.
262307
- Do not rely only on `UCX_MAX_RNDV_RAILS` for NIXL refit. NIXL checkpoint-engine
263308
transfers are UCX RMA READ operations, and the tested runtime still printed a
264309
single `rc_mlx5` device in the `rma(...)` log line even when the device-list

examples/configs/grpo_math_1B.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,13 @@ policy:
339339
device: cuda
340340
cleanup_after_load: true
341341
topology: auto
342+
transfer_mode: staged
343+
buffer_count: 2
344+
background_progress: false
345+
load_batch_bucket_count: 1
346+
direct_min_bytes: 2147483648
347+
direct_stripe_count: 1
348+
metadata_batch_size: 1
342349
backend_name: UCX
343350
colocated:
344351
# true: generation shares training GPUs

examples/configs/grpo_math_8B.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ policy:
6969
device: cuda
7070
cleanup_after_load: true
7171
topology: auto
72+
transfer_mode: staged
73+
buffer_count: 2
74+
background_progress: false
75+
load_batch_bucket_count: 1
76+
direct_min_bytes: 2147483648
77+
direct_stripe_count: 1
78+
metadata_batch_size: 1
7279
backend_name: UCX
7380

7481
cluster:

nemo_rl/models/generation/vllm/vllm_backend.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,15 @@ async def _update_weights_from_checkpoint_engine_async(self) -> bool:
422422
load_time += time.time() - load_start
423423

424424
sync_start = time.time()
425-
torch.cuda.current_stream().synchronize()
425+
record_cuda_load_complete = getattr(
426+
weight_batch,
427+
"record_cuda_load_complete",
428+
None,
429+
)
430+
if callable(record_cuda_load_complete):
431+
record_cuda_load_complete()
432+
else:
433+
torch.cuda.current_stream().synchronize()
426434
sync_time += time.time() - sync_start
427435
del weight_batch
428436

nemo_rl/models/generation/vllm/vllm_worker_async.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
# limitations under the License.
1414

1515
import asyncio
16+
import concurrent.futures
1617
import copy
1718
import gc
19+
import inspect
1820
import threading
1921
import time
2022
import uuid
@@ -44,6 +46,27 @@
4446
from nemo_rl.models.generation.vllm.vllm_worker import BaseVllmGenerationWorker
4547

4648

49+
async def _resolve_collective_rpc_result(result: Any) -> Any:
50+
while inspect.isawaitable(result):
51+
result = await result
52+
53+
if isinstance(result, concurrent.futures.Future):
54+
return await _resolve_collective_rpc_result(await asyncio.wrap_future(result))
55+
56+
if isinstance(result, ray.ObjectRef):
57+
return await asyncio.to_thread(ray.get, result)
58+
59+
if isinstance(result, list):
60+
if all(isinstance(item, ray.ObjectRef) for item in result):
61+
return await asyncio.to_thread(ray.get, result)
62+
return [await _resolve_collective_rpc_result(item) for item in result]
63+
64+
if isinstance(result, tuple):
65+
return tuple([await _resolve_collective_rpc_result(item) for item in result])
66+
67+
return result
68+
69+
4770
def _replace_prefix_tokens(
4871
tokenizer,
4972
model_prefix_token_ids: list[int],
@@ -832,9 +855,7 @@ async def init_collective_async(
832855

833856
async def _checkpoint_engine_rpc_async(self, method_name: str, *args: Any) -> Any:
834857
result = await self.llm.collective_rpc(method_name, args=args)
835-
if asyncio.iscoroutine(result):
836-
return await result
837-
return result
858+
return await _resolve_collective_rpc_result(result)
838859

839860
async def init_checkpoint_engine_async(
840861
self,

nemo_rl/utils/checkpoint_engines/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ async def send_weights(
9898
async def receive_weight_batches(
9999
self,
100100
) -> AsyncGenerator[list[tuple[str, torch.Tensor]], None]:
101-
"""Receive model weights in batches that share a transfer buffer."""
101+
"""Receive model weights in batches that share a transfer buffer.
102+
103+
Backends that yield CUDA tensors backed by reusable transfer buffers may
104+
return a list subclass with ``record_cuda_load_complete()``. vLLM calls
105+
that hook after queuing its load operation so the backend can fence
106+
buffer reuse with a CUDA event instead of forcing a device-wide sync.
107+
"""
102108
raise NotImplementedError
103109

104110

0 commit comments

Comments
 (0)