Skip to content

Commit e5829e0

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

26 files changed

Lines changed: 3115 additions & 90 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: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
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: cpu
56+
backend_name: UCX
57+
```
58+
59+
`backend` can be either:
60+
61+
- a registered backend name, such as `nixl`
62+
- a class path, such as `my_pkg.refit:MyCheckpointEngine`
63+
64+
`engine_kwargs` must be keyed by the exact `backend` value. For a class-path
65+
plugin:
66+
67+
```yaml
68+
policy:
69+
generation:
70+
checkpoint_engine:
71+
enabled: true
72+
backend: "my_pkg.refit:MyCheckpointEngine"
73+
update_weights_bucket_megabytes: 1024
74+
engine_kwargs:
75+
"my_pkg.refit:MyCheckpointEngine":
76+
transport: my_transport
77+
```
78+
79+
The factory passes `bucket_size` in bytes plus the selected backend kwargs to
80+
the backend constructor. It also provides a backend-neutral default `device`
81+
unless the config already specifies one.
82+
83+
## Backend Interface
84+
85+
Backends subclass
86+
{py:class}`CheckpointEngine <nemo_rl.utils.checkpoint_engines.base.CheckpointEngine>`.
87+
88+
```python
89+
from typing import Any, AsyncGenerator, Generator
90+
91+
import torch
92+
93+
from nemo_rl.utils.checkpoint_engines import (
94+
CheckpointEngine,
95+
CheckpointEngineRegistry,
96+
)
97+
98+
99+
@CheckpointEngineRegistry.register("my_backend")
100+
class MyCheckpointEngine(CheckpointEngine):
101+
cleanup_after_load = True
102+
103+
def __init__(self, bucket_size: int, device: str | torch.device = "cuda"):
104+
self.bucket_size = bucket_size
105+
self.device = torch.device(device)
106+
107+
def prepare(self) -> Any:
108+
"""Allocate or register buffers and return Ray-serializable metadata."""
109+
...
110+
111+
def init_policy_process_group(
112+
self,
113+
*,
114+
worker_rank: int,
115+
train_world_size: int,
116+
rollout_world_size: int,
117+
metadata: list[Any],
118+
) -> None:
119+
"""Connect a policy worker to the backend topology."""
120+
...
121+
122+
def init_rollout_process_group(
123+
self,
124+
*,
125+
rollout_rank: int,
126+
train_world_size: int,
127+
rollout_world_size: int,
128+
metadata: list[Any],
129+
) -> None:
130+
"""Connect a generation worker to the backend topology."""
131+
...
132+
133+
def finalize(self) -> None:
134+
"""Release per-refit topology state."""
135+
...
136+
137+
async def send_weights(
138+
self,
139+
weights: Generator[tuple[str, torch.Tensor], None, None],
140+
) -> None:
141+
"""Send `(name, tensor)` weights from the policy side."""
142+
...
143+
144+
async def receive_weight_batches(
145+
self,
146+
) -> AsyncGenerator[list[tuple[str, torch.Tensor]], None]:
147+
"""Yield `(name, tensor)` batches on the generation side."""
148+
...
149+
```
150+
151+
The `weights` generator is consumed once. Do not assume it can be replayed.
152+
153+
`receive_weight_batches()` should yield tensors with the original parameter
154+
names and values. The generation backend loads each yielded batch immediately,
155+
so yielding at transfer-bucket boundaries allows transfer and loading to overlap.
156+
157+
`cleanup_after_load` is read by the vLLM generation worker after the receive
158+
loop. Set it to `False` when the backend can keep stable buffers and avoiding
159+
extra cache cleanup is safe for steady-state training.
160+
161+
## Registry and Plugins
162+
163+
Built-in backends are lazy-imported by name through
164+
{py:class}`CheckpointEngineRegistry <nemo_rl.utils.checkpoint_engines.base.CheckpointEngineRegistry>`.
165+
External backends have two options:
166+
167+
1. Register a short name with `@CheckpointEngineRegistry.register("name")`.
168+
2. Use a class path directly in config.
169+
170+
Class-path plugins do not need an import side effect. The registry imports the
171+
module, looks up the class, validates that it subclasses `CheckpointEngine`, and
172+
caches the result.
173+
174+
Supported class-path formats are:
175+
176+
```text
177+
my_pkg.refit:MyCheckpointEngine
178+
my_pkg.refit.MyCheckpointEngine
179+
```
180+
181+
## Worker Integration
182+
183+
Policy workers use `BasePolicyWorker` helpers to instantiate the engine, prepare
184+
metadata, join the backend topology, and send weights.
185+
186+
vLLM generation workers forward checkpoint-engine calls into vLLM internal
187+
workers. The internal worker extension receives weight batches and calls the
188+
normal vLLM load path for each batch. It also prints refit timing:
189+
190+
```text
191+
[vLLM refit] Loaded ... via checkpoint engine; bytes=... total=... receive=... load=...
192+
```
193+
194+
Async vLLM uses the same backend interface through async worker wrappers.
195+
196+
## NIXL Backend
197+
198+
The built-in NIXL backend is registered as `nixl`. It uses:
199+
200+
- NIXL agents for memory registration and transfer
201+
- ZMQ messages for bucket metadata and transfer notifications
202+
- two reusable transfer buffers per worker for pipelined bucket movement
203+
- `split_weight_chunks()` and `merge_weight_chunk_batches()` for tensors larger
204+
than one bucket
205+
206+
The NIXL backend chooses one of two topologies:
207+
208+
- If `train_world_size >= rollout_world_size`, each rollout rank is paired with
209+
a policy rank. Extra policy ranks drain their local weight generators and do
210+
not send.
211+
- If `rollout_world_size > train_world_size`, policy rank 0 sends into a chain
212+
of rollout ranks that forward buckets.
213+
214+
`finalize()` removes remote peer connections after each refit, but keeps memory
215+
registrations and transfer buffers alive for the lifetime of the worker. This
216+
avoids repeated multi-GB memory registration and avoids UCX teardown issues in
217+
long-lived Ray/vLLM actors.
218+
219+
### Fault-Tolerance Boundary
220+
221+
The NIXL backend is restart-safe, not actor-healing. It is designed so a failed
222+
transfer becomes a failed refit attempt that the driver can observe:
223+
224+
- `ReadOperation.begin_read()` raises if NIXL immediately returns `ERR`.
225+
- `ReadOperation.wait_for_complete()` polls `check_xfer_state()` and raises if
226+
the transfer enters `ERR`.
227+
- vLLM catches checkpoint-engine update failures and returns `False` to the
228+
GRPO refit orchestration.
229+
- GRPO raises a refit error when any generation worker reports failure.
230+
- GRPO calls `finalize()` in a `finally` block to remove per-refit peer
231+
connections.
232+
233+
The backend does not currently rebuild the NIXL topology, recreate Ray actors,
234+
or reload vLLM inside the same training step after a peer disappears. That
235+
responsibility belongs to the scheduler or a fault-tolerant launcher that
236+
restarts the training process from a durable NeMo RL checkpoint.
237+
238+
For production runs, configure UCX so peer failures are reported to NIXL:
239+
240+
```yaml
241+
policy:
242+
generation:
243+
checkpoint_engine:
244+
enabled: true
245+
backend: nixl
246+
engine_kwargs:
247+
nixl:
248+
backend_name: UCX
249+
backend_init_params:
250+
ucx_error_handling_mode: peer
251+
```
252+
253+
And use bounded UCX retry/keepalive settings:
254+
255+
```sh
256+
export UCX_RC_TIMEOUT=30s
257+
export UCX_RC_RETRY_COUNT=7
258+
export UCX_KEEPALIVE_INTERVAL=1s
259+
export UCX_KEEPALIVE_NUM_EPS=10
260+
```
261+
262+
`ucx_error_handling_mode: none` should be reserved for performance experiments
263+
on stable clusters. With peer error handling disabled, a dead endpoint may not
264+
surface as a NIXL `ERR` state promptly enough for job-level restart logic.
265+
266+
## vLLM NIXL Preinit
267+
268+
vLLM starts internal worker processes during engine setup. For NIXL/UCX, the
269+
backend needs to be initialized inside those internal workers before the normal
270+
vLLM worker setup path finishes.
271+
272+
NeMo RL patches the vLLM internal worker constructor and injects a config-driven
273+
preinit call when:
274+
275+
- `policy.generation.checkpoint_engine.enabled=true`
276+
- `policy.generation.checkpoint_engine.backend=nixl`
277+
278+
The preinit call uses the configured NIXL `backend_name` and
279+
`backend_init_params`; it does not require NeMo RL feature environment
280+
variables. A healthy vLLM run prints:
281+
282+
```text
283+
NIXL vLLM worker preinit completed: backend=UCX
284+
```
285+
286+
Backends other than NIXL should initialize themselves through the normal
287+
`CheckpointEngine` constructor unless they also need code to run in nested vLLM
288+
worker processes before engine setup.
289+
290+
## Bucket Helpers
291+
292+
`split_weight_chunks()` converts the policy weight stream into byte chunks no
293+
larger than the configured bucket size. It records `TensorMeta` for each chunk:
294+
295+
- original tensor name
296+
- shape
297+
- dtype
298+
- chunk offset
299+
- chunk size
300+
- byte offset inside the transfer bucket
301+
302+
`merge_weight_chunk_batches()` reconstructs tensors that were split across
303+
multiple chunks while preserving bucket boundaries for normal tensors. Backend
304+
implementations can use these helpers when their transport operates on flat
305+
byte buffers.
306+
307+
## Adding a New Backend
308+
309+
1. Implement a `CheckpointEngine` subclass.
310+
2. Decide whether to register a short name or use a class path in config.
311+
3. Make `prepare()` allocate/register buffers and return metadata that Ray can
312+
serialize.
313+
4. Use `init_policy_process_group()` and `init_rollout_process_group()` to
314+
connect peers from the combined metadata list.
315+
5. Implement `send_weights()` as a streaming send of `(name, tensor)` pairs.
316+
6. Implement `receive_weight_batches()` as a streaming receive that yields
317+
loadable `(name, tensor)` batches.
318+
7. Make `finalize()` release per-refit peer state without destroying reusable
319+
buffers unless the backend cannot safely reuse them.
320+
8. Define the backend's failure behavior. Transfer errors should become explicit
321+
exceptions or `False` update results rather than silent partial updates.
322+
9. Add unit tests for registry loading, metadata setup, topology, failure
323+
propagation, and a small tensor roundtrip.
324+
10. Run a non-colocated GRPO job and verify the `[vLLM refit]` timing line.
325+
326+
Good starting tests are:
327+
328+
```sh
329+
uv run pytest tests/unit/utils/test_checkpoint_engine.py
330+
uv run pytest tests/unit/algorithms/test_grpo.py -k checkpoint_engine
331+
```
332+
333+
## Compatibility Notes
334+
335+
- Checkpoint-engine refit currently targets non-colocated policy-to-vLLM refit.
336+
- SGLang non-colocated checkpoint-engine refit is not implemented.
337+
- The backend must be installed in every Ray worker environment that imports it.
338+
- The backend must preserve parameter names exactly, because generation workers
339+
use those names to load weights into the target model.
340+
- The backend should avoid driver-side model materialization. The driver should
341+
orchestrate futures and metadata only.

0 commit comments

Comments
 (0)