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