|
| 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