Skip to content

Commit cb745fa

Browse files
committed
feat: add checkpoint-engine refit interface and integrate NIXL
Signed-off-by: Hollow Man <hollowman@opensuse.org>
1 parent 37526df commit cb745fa

32 files changed

Lines changed: 4590 additions & 182 deletions

docs/about/backends.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,11 @@ NeMo RL supports multiple generation/rollout backends to accommodate different m
1818

1919
For detailed information on backend selection, configuration, and examples, see the [Generation Backends documentation](../design-docs/generation.md).
2020

21+
## Refit Transfer Backends
22+
23+
When generation is non-colocated, NeMo RL can update generation weights through
24+
a checkpoint-engine transfer backend. The built-in checkpoint-engine backend is
25+
`nixl`, which can use UCX/RDMA for policy-to-vLLM refit.
26+
27+
For usage, see the [Checkpoint-Engine Refit guide](../guides/checkpoint-engine-refit.md).
28+
For plugin details, see the [Checkpoint Engine design doc](../design-docs/checkpoint-engines.md).
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
# Checkpoint Engine Design
2+
3+
Checkpoint engines provide a backend-neutral way to transfer policy weights to
4+
non-colocated generation workers during refit. They are used by GRPO when
5+
`policy.generation.checkpoint_engine.enabled=true`.
6+
7+
The user-facing guide is [Checkpoint-Engine Refit](../guides/checkpoint-engine-refit.md).
8+
This document describes the implementation contract and how to add new transfer
9+
backends.
10+
11+
## Goals
12+
13+
Checkpoint engines are designed to:
14+
15+
- decouple refit orchestration from the transport implementation
16+
- let each backend manage its own metadata, buffers, and process topology
17+
- stream weight batches instead of materializing a full model copy in the driver
18+
- support plugin backends without changing GRPO or vLLM code
19+
20+
Checkpoint engines do not replace normal checkpoint save/load. They are a
21+
runtime refit transport used between policy workers and generation workers.
22+
23+
## Control Flow
24+
25+
The GRPO refit flow is:
26+
27+
1. Read `policy.generation.checkpoint_engine`.
28+
2. Instantiate the configured backend on every policy worker and generation
29+
worker.
30+
3. Call `prepare()` on every backend instance and collect Ray-serializable
31+
metadata.
32+
4. Initialize the backend topology with the combined metadata list.
33+
5. Ask policy workers to send model weights.
34+
6. Ask generation workers to receive weight batches and load them into the
35+
generation backend.
36+
7. Call `finalize()` on all backend instances in a `finally` block.
37+
38+
The policy metadata is placed first in the combined metadata list, followed by
39+
generation metadata. Backends receive `train_world_size` and
40+
`rollout_world_size` so they can interpret that list.
41+
42+
## Configuration Contract
43+
44+
Checkpoint-engine config is stored under `policy.generation`:
45+
46+
```yaml
47+
policy:
48+
generation:
49+
checkpoint_engine:
50+
enabled: true
51+
backend: nixl
52+
update_weights_bucket_megabytes: 2048
53+
engine_kwargs:
54+
nixl:
55+
device: cuda
56+
topology: auto
57+
backend_name: UCX
58+
backend_init_params:
59+
ucx_error_handling_mode: peer
60+
```
61+
62+
`backend` can be either:
63+
64+
- a registered backend name, such as `nixl`
65+
- a class path, such as `my_pkg.refit:MyCheckpointEngine`
66+
67+
`engine_kwargs` must be keyed by the exact `backend` value. For a class-path
68+
plugin:
69+
70+
```yaml
71+
policy:
72+
generation:
73+
checkpoint_engine:
74+
enabled: true
75+
backend: "my_pkg.refit:MyCheckpointEngine"
76+
update_weights_bucket_megabytes: 1024
77+
engine_kwargs:
78+
"my_pkg.refit:MyCheckpointEngine":
79+
transport: my_transport
80+
```
81+
82+
The factory passes `bucket_size` in bytes plus the selected backend kwargs to
83+
the backend constructor. Backend-specific settings such as transfer device,
84+
cleanup behavior, and transport plugin name live in config.
85+
86+
For NIXL/UCX CUDA-buffer performance runs, put UCX backend parameters in
87+
`engine_kwargs.nixl.backend_init_params` rather than relying only on process
88+
environment variables. For example, a 30B MoE CUDA-buffer refit improved when
89+
NIXL received both the rail list and RMA rail count directly:
90+
91+
```yaml
92+
policy:
93+
generation:
94+
checkpoint_engine:
95+
engine_kwargs:
96+
nixl:
97+
backend_init_params:
98+
ucx_error_handling_mode: none
99+
device_list: "mlx5_0,mlx5_1,mlx5_2,mlx5_4,mlx5_5,mlx5_6,mlx5_7,mlx5_8"
100+
engine_config: MAX_RMA_RAILS=8
101+
```
102+
103+
Use NIC names that exist on the target nodes. `ucx_error_handling_mode: none`
104+
is a benchmark setting; use `peer` for production runs that need transport
105+
errors to surface promptly. CPU staging should be benchmarked independently;
106+
the CUDA-buffer rail settings are not a safe default for host-pinned transfer
107+
buffers. On the tested 30B MoE CPU fallback, four process-level RDMA rails with
108+
1536 MiB buckets outperformed eight process-level rails.
109+
110+
## Backend Interface
111+
112+
Backends subclass
113+
{py:class}`CheckpointEngine <nemo_rl.utils.checkpoint_engines.base.CheckpointEngine>`.
114+
115+
```python
116+
from typing import Any, AsyncGenerator, Generator
117+
118+
import torch
119+
120+
from nemo_rl.utils.checkpoint_engines import (
121+
CheckpointEngine,
122+
CheckpointEngineRegistry,
123+
)
124+
125+
126+
@CheckpointEngineRegistry.register("my_backend")
127+
class MyCheckpointEngine(CheckpointEngine):
128+
cleanup_after_load = True
129+
130+
def __init__(self, bucket_size: int, device: str | torch.device):
131+
self.bucket_size = bucket_size
132+
self.device = torch.device(device)
133+
134+
def prepare(self) -> Any:
135+
"""Allocate or register buffers and return Ray-serializable metadata."""
136+
...
137+
138+
def init_policy_process_group(
139+
self,
140+
*,
141+
worker_rank: int,
142+
train_world_size: int,
143+
rollout_world_size: int,
144+
metadata: list[Any],
145+
) -> None:
146+
"""Connect a policy worker to the backend topology."""
147+
...
148+
149+
def init_rollout_process_group(
150+
self,
151+
*,
152+
rollout_rank: int,
153+
train_world_size: int,
154+
rollout_world_size: int,
155+
metadata: list[Any],
156+
) -> None:
157+
"""Connect a generation worker to the backend topology."""
158+
...
159+
160+
def finalize(self) -> None:
161+
"""Release per-refit topology state."""
162+
...
163+
164+
async def send_weights(
165+
self,
166+
weights: Generator[tuple[str, torch.Tensor], None, None],
167+
) -> None:
168+
"""Send `(name, tensor)` weights from the policy side."""
169+
...
170+
171+
async def receive_weight_batches(
172+
self,
173+
) -> AsyncGenerator[list[tuple[str, torch.Tensor]], None]:
174+
"""Yield `(name, tensor)` batches on the generation side."""
175+
...
176+
```
177+
178+
The `weights` generator is consumed once. Do not assume it can be replayed.
179+
180+
`receive_weight_batches()` should yield tensors with the original parameter
181+
names and values. The generation backend loads each yielded batch immediately,
182+
so yielding at transfer-bucket boundaries allows transfer and loading to overlap.
183+
184+
`cleanup_after_load` is read by the vLLM generation worker after the receive
185+
loop. Set it to `False` when the backend can keep stable buffers and avoiding
186+
extra cache cleanup is safe for steady-state training.
187+
188+
## Registry and Plugins
189+
190+
Built-in backends are lazy-imported by name through
191+
{py:class}`CheckpointEngineRegistry <nemo_rl.utils.checkpoint_engines.base.CheckpointEngineRegistry>`.
192+
External backends have two options:
193+
194+
1. Register a short name with `@CheckpointEngineRegistry.register("name")`.
195+
2. Use a class path directly in config.
196+
197+
Class-path plugins do not need an import side effect. The registry imports the
198+
module, looks up the class, validates that it subclasses `CheckpointEngine`, and
199+
caches the result.
200+
201+
Supported class-path formats are:
202+
203+
```text
204+
my_pkg.refit:MyCheckpointEngine
205+
my_pkg.refit.MyCheckpointEngine
206+
```
207+
208+
## Worker Integration
209+
210+
Policy workers use `BasePolicyWorker` helpers to instantiate the engine, prepare
211+
metadata, join the backend topology, and send weights.
212+
213+
vLLM generation workers forward checkpoint-engine calls into vLLM internal
214+
workers. The internal worker extension receives weight batches and calls the
215+
normal vLLM load path for each batch. It also prints refit timing:
216+
217+
```text
218+
[vLLM refit] Loaded ... via checkpoint engine; bytes=... total=... receive=... load=...
219+
```
220+
221+
Async vLLM uses the same backend interface through async worker wrappers.
222+
223+
## NIXL Backend
224+
225+
The built-in NIXL backend is registered as `nixl`. It uses:
226+
227+
- NIXL agents for memory registration and transfer
228+
- ZMQ messages for bucket metadata and transfer notifications
229+
- two reusable transfer buffers per worker for pipelined bucket movement
230+
- `split_weight_chunks()` and `merge_weight_chunk_batches()` for tensors larger
231+
than one bucket
232+
233+
The NIXL backend chooses one of two topologies:
234+
235+
- If `train_world_size >= rollout_world_size`, each rollout rank is paired with
236+
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.
239+
240+
Set `engine_kwargs.nixl.topology=leader_chain` to force the single-policy-sender
241+
chain even when policy workers can cover rollout workers. This reduces
242+
policy-side export and send work, but it serializes rollout forwarding through
243+
the chain, so benchmark it against the default paired topology on the target
244+
cluster.
245+
246+
`finalize()` keeps peer connections, memory registrations, transfer buffers, and
247+
read handles alive for the lifetime of the worker. Reusing these objects avoids
248+
repeated multi-GB memory registration and transfer-handle initialization in
249+
long-lived Ray/vLLM actors.
250+
251+
### Fault-Tolerance Boundary
252+
253+
The NIXL backend is restart-safe, not actor-healing. It is designed so a failed
254+
transfer becomes a failed refit attempt that the driver can observe:
255+
256+
- `ReadOperation.begin_read()` raises if NIXL immediately returns `ERR`.
257+
- `ReadOperation.wait_for_complete()` polls `check_xfer_state()` and raises if
258+
the transfer enters `ERR`.
259+
- vLLM catches checkpoint-engine update failures and returns `False` to the
260+
GRPO refit orchestration.
261+
- GRPO raises a refit error when any generation worker reports failure.
262+
- GRPO calls `finalize()` in a `finally` block to remove per-refit peer
263+
connections.
264+
265+
The backend does not currently rebuild the NIXL topology, recreate Ray actors,
266+
or reload vLLM inside the same training step after a peer disappears. That
267+
responsibility belongs to the scheduler or a fault-tolerant launcher that
268+
restarts the training process from a durable NeMo RL checkpoint.
269+
270+
For production runs, configure UCX so peer failures are reported to NIXL:
271+
272+
```yaml
273+
policy:
274+
generation:
275+
checkpoint_engine:
276+
enabled: true
277+
backend: nixl
278+
engine_kwargs:
279+
nixl:
280+
device: cuda
281+
cleanup_after_load: false
282+
backend_name: UCX
283+
backend_init_params:
284+
ucx_error_handling_mode: peer
285+
```
286+
287+
And use bounded UCX retry/keepalive settings:
288+
289+
```sh
290+
export UCX_RC_TIMEOUT=30s
291+
export UCX_RC_RETRY_COUNT=7
292+
export UCX_KEEPALIVE_INTERVAL=1s
293+
export UCX_KEEPALIVE_NUM_EPS=10
294+
```
295+
296+
`ucx_error_handling_mode: none` should be reserved for performance experiments
297+
on stable clusters. With peer error handling disabled, a dead endpoint may not
298+
surface as a NIXL `ERR` state promptly enough for job-level restart logic.
299+
300+
## vLLM NIXL Preinit
301+
302+
vLLM starts internal worker processes during engine setup. For NIXL/UCX, the
303+
backend needs to be initialized inside those internal workers before the normal
304+
vLLM worker setup path finishes.
305+
306+
NeMo RL patches the vLLM internal worker constructor and injects a config-driven
307+
preinit call when:
308+
309+
- `policy.generation.checkpoint_engine.enabled=true`
310+
- `policy.generation.checkpoint_engine.backend=nixl`
311+
312+
The preinit call uses the configured NIXL `backend_name` and
313+
`backend_init_params`; it does not require NeMo RL feature environment
314+
variables. A healthy vLLM run prints:
315+
316+
```text
317+
NIXL vLLM worker preinit completed: backend=UCX
318+
```
319+
320+
Backends other than NIXL should initialize themselves through the normal
321+
`CheckpointEngine` constructor unless they also need code to run in nested vLLM
322+
worker processes before engine setup.
323+
324+
## Bucket Helpers
325+
326+
`split_weight_chunks()` converts the policy weight stream into byte chunks no
327+
larger than the configured bucket size. It records `TensorMeta` for each chunk:
328+
329+
- original tensor name
330+
- shape
331+
- dtype
332+
- chunk offset
333+
- chunk size
334+
- byte offset inside the transfer bucket
335+
336+
`merge_weight_chunk_batches()` reconstructs tensors that were split across
337+
multiple chunks while preserving bucket boundaries for normal tensors. Backend
338+
implementations can use these helpers when their transport operates on flat
339+
byte buffers.
340+
341+
## Adding a New Backend
342+
343+
1. Implement a `CheckpointEngine` subclass.
344+
2. Decide whether to register a short name or use a class path in config.
345+
3. Make `prepare()` allocate/register buffers and return metadata that Ray can
346+
serialize.
347+
4. Use `init_policy_process_group()` and `init_rollout_process_group()` to
348+
connect peers from the combined metadata list.
349+
5. Implement `send_weights()` as a streaming send of `(name, tensor)` pairs.
350+
6. Implement `receive_weight_batches()` as a streaming receive that yields
351+
loadable `(name, tensor)` batches.
352+
7. Make `finalize()` release per-refit peer state without destroying reusable
353+
buffers unless the backend cannot safely reuse them.
354+
8. Define the backend's failure behavior. Transfer errors should become explicit
355+
exceptions or `False` update results rather than silent partial updates.
356+
9. Add unit tests for registry loading, metadata setup, topology, failure
357+
propagation, and a small tensor roundtrip.
358+
10. Run a non-colocated GRPO job and verify the `[vLLM refit]` timing line.
359+
360+
Good starting tests are:
361+
362+
```sh
363+
uv run pytest tests/unit/utils/test_checkpoint_engine.py
364+
uv run pytest tests/unit/algorithms/test_grpo.py -k checkpoint_engine
365+
```
366+
367+
## Compatibility Notes
368+
369+
- Checkpoint-engine refit currently targets non-colocated policy-to-vLLM refit.
370+
- SGLang non-colocated checkpoint-engine refit is not implemented.
371+
- The backend must be installed in every Ray worker environment that imports it.
372+
- The backend must preserve parameter names exactly, because generation workers
373+
use those names to load weights into the target model.
374+
- The backend should avoid driver-side model materialization. The driver should
375+
orchestrate futures and metadata only.

0 commit comments

Comments
 (0)