Skip to content

Routed per-subset state dict gets into NIXL CUDA destinations - #252

Merged
d4l3k merged 2 commits into
mainfrom
d4l3k/gen-direct-gpu
Oct 29, 2026
Merged

d4l3k merged 2 commits into
mainfrom
d4l3k/gen-direct-gpu

Conversation

@d4l3k

@d4l3k d4l3k commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Root issue: torchtitan RL generators prefetch each policy into a full pinned CPU copy of the weights, and this copy OOMs GB300 generator hosts, which have little CPU memory. The companion torchtitan PR reads the StorageVolume snapshot straight into the generator's GPU parameters, one FSDP group at a time. Two pieces were missing in torchstore:

  • Routed per-subset gets: RoutingClient.get_layouts planned only the first state dict it saw, so a later get_state_dict for another subset returned the first subset's keys in fresh tensors and none of the requested ones.
  • Safe NIXL CUDA destinations: FSDP frees and reallocates sharded param storage in place with resize_, so a cached registration ends up over freed memory and reads stale bytes. With expandable segments, UCX also registers only the 20 MiB cuMem chunk holding a buffer's start. A buffer that fits within one chunk but crosses a chunk boundary then fails registration (ibv_reg_dmabuf_mr: Invalid argument on GB200) or, past the registered part, RDMA with a local access violation that hangs the transfer. UCX's registration cache also keeps deregistered memory registered, so CUDA memory torch frees after a get stays pinned and the device cannot reuse it (measured: freed destinations missing from cudaMemGetInfo until the process exits).

Fix:

  • RoutingClient.get_layouts plans every key the first time it is passed, against only that subset's publisher entries, and fetches publisher layouts once per state dict. get_state_dict(strict=False) fetches only the passed subset; keys it skips as unpublished stay unplanned, so a later strict get still rejects them.
  • NIXL CUDA get destinations hold a registration of their whole storage only while the get is in flight. Concurrent buffers of one get share it. CPU destinations stay cached. A storage is never registered twice: two registrations of the same range make peers fail to load the agent's metadata (NIXL_ERR_NOT_ALLOWED).
  • Cancelling a CUDA get waits, up to TORCHSTORE_NIXL_TIMEOUT_S, for the storage volume to answer before deregistering its destinations, since the volume may still be writing into them. After a timeout or a second cancel, the destinations stay registered and referenced until the volume answers, and getting into the same storage meanwhile raises. This uses a new TransportBuffer._call_volume_get hook.
  • The NIXL UCX backend is created with engine_config CUDA_COPY_REG_WHOLE_ALLOC=off,RCACHE_ENABLE=n, scoped to torchstore's agent: every registration covers its whole range, and deregistering releases the memory. Exported UCX_CUDA_COPY_REG_WHOLE_ALLOC/UCX_RCACHE_ENABLE still override these.

BC: no API change. Routed non-strict gets return the passed keys rather than those of the first get. Routed strict gets still require the passed dict to match every key planned so far.

Companion torchtitan PR (lands after this one): pytorch/torchtitan#5652. Duplicate trainer copy follow-up: pytorch/torchtitan#5654

Test plan:

  • pytest tests/routing tests/test_nixl_transport.py tests/test_nixl_transport_cuda.py tests/test_state_dict.py plus tests/test_state_dict_utils.py, rebased on main, on GB200 with nixl 1.5.0: 135 passed, 1 failed. The failure, test_state_dict_transfer_dtype[MonarchRDMA-cuda] (RDMA completion error status=12 in the put path), also fails 5/5 on main. Cancellation tests cover waiting for the volume's answer, release after a timeout or second cancel, and refusing to reuse a storage the volume may still write into. The CUDA tests use real NIXL and cover a destination spanning cuMem chunks with expandable segments (fails registration without CUDA_COPY_REG_WHOLE_ALLOC=off) and a freed destination returning to the device (fails without RCACHE_ENABLE=n).
  • Two-process check (CPU volume, GPU client, IB): NIXL allocates no GPU memory of its own; device usage matches a plain copy_ baseline, does not grow over 20 gets, and freed destinations return to the device. NIXL segfaults at interpreter exit on this host for any process that creates an agent, so judge by the pytest summary.
  • End to end, torchtitan RL fetch_model_state_dict_to_gpu (Qwen3.5, GB200): rc=0, logprob_diff 0.010-0.014, matching the staged path.

Limitations:

  • CUDA destinations register and deregister on every get, including callers whose storage never moves. Without the UCX registration cache, GPU register+deregister measured 0.9 ms (64 MiB), 2.8 ms (1 GiB), 9.1 ms (4 GiB) on GB200, about 1.5 s per sync for 1.5k params plus ~3 ms/GiB; not measured at scale. Caching by (data_ptr, nbytes) cannot detect FSDP reallocation at the same address.
  • tests/test_nixl_transport_cuda.py skips in CI (nixl is not installed); it was run manually.
  • RCACHE_ENABLE=n applies to all memory on both agents, so uncached registrations (per-get CPU destinations, staging buffers, non-contiguous copies) pay a full register/deregister each time. Long-lived registrations are cached by torchstore.
  • Each CUDA get changes the client's NIXL metadata, so the volume reloads it per get, as it already did for new CPU destinations. Reloading removes the remote agent, so concurrent gets from one client to one volume can hang an in-flight transfer (pre-existing; now hit by any concurrent CUDA gets). torchtitan issues one blocking get at a time.
  • A get abandoned after a timeout keeps its destinations registered until the volume answers. Holding the tensors does not stop FSDP resize_ from freeing that memory, and gets refuse the storage only if it comes back at the same address and size.
  • A first routed get whose subset has no published keys still fails (Mapping is missing); later gets now recover.

Lines changed: impl +218/-14, tests +460/-2.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 28, 2026
@d4l3k
d4l3k force-pushed the d4l3k/gen-direct-gpu branch from 102f92e to b34e58c Compare October 28, 2026 22:07
@d4l3k d4l3k changed the title Direct RDMA weight sync into GPU destinations over NIXL Routed per-subset state dict gets into NIXL CUDA destinations Oct 28, 2026
@d4l3k
d4l3k force-pushed the d4l3k/gen-direct-gpu branch from b34e58c to 8e7b41b Compare October 28, 2026 23:51
@d4l3k
d4l3k marked this pull request as ready for review October 29, 2026 00:15
Comment thread torchstore/transport/nixl.py Resolved
d4l3k added 2 commits October 29, 2026 15:10
Routed get_state_dict planned only the first call's keys and fetched every planned key, so a reader loading one parameter group at a time got no routes for later groups and allocated the others. Plan new keys on each call and make non-strict routed gets fetch only the passed keys.

NIXL cached CUDA get destinations by storage, but FSDP frees and reallocates parameter storage in place, leaving the cached registration over freed memory. Register those destinations per get and deregister in drop().
…olume writes; dedupe registrations

A cancelled get now waits (up to TORCHSTORE_NIXL_TIMEOUT_S) for the volume's answer before drop() deregisters its CUDA destinations; on a timeout or a second cancel it returns and releases them when the volume answers. acquire() replaces a cached registration of the same storage and register() reuses a live lease: two registrations of one range make peers fail to load the agent (NIXL_ERR_NOT_ALLOWED), e.g. a CUDA put source later used as a get destination. drop() releases every lease even if one fails. Non-strict routed gets no longer mark unpublished keys as planned.
@d4l3k
d4l3k force-pushed the d4l3k/gen-direct-gpu branch from 2980a95 to 144b7e1 Compare October 29, 2026 22:24
@d4l3k
d4l3k merged commit ca729aa into main Oct 29, 2026
8 checks passed
d4l3k added a commit to pytorch/torchtitan that referenced this pull request Nov 3, 2026
Root issue: RL generators prefetch each policy from the StorageVolume
into a full pinned CPU copy of the weights before loading it into vLLM.
GB300 generator hosts have little CPU memory and OOM on this copy.

Fix: one load path, `VLLMModelWrapper.load_model_state_dict(fetch)`,
walks the FSDP groups one at a time (reshard, restore sharded params,
`fetch` the group's subset in place, unshard and free; buffers last).
Every parameter must be filled.
`Generator.Config.model_state_dict_load_mode` picks the fetch:

| `model_state_dict_load_mode` | prefetch | fetch during load |
generator host memory |
|---|---|---|---|
| `"cpu_prefetch"` (default, previous behavior) | StorageVolume ->
pinned CPU copy, overlaps generation | `copy_` from the pinned copy | +1
full weight copy |
| `"gpu_fetch"` | no-op | TorchStore read straight into the GPU weights
| none |

A gloo all-reduce agrees on load failure across generator ranks before
rank 0 replies; on a fetch failure every rank still unshards every group
(avoids an all-gather hang) and then stops the engine loop. gpu_fetch
tradeoff: the transfer runs inside the load while the engine is paused
rather than overlapping generation. gpu_fetch rejects quantized
parameters at init. Parameters whose dtype differs from the model dtype
(float32 GDN `A_log`/`dt_bias`,
#5691) are fetched into
model-dtype staging tensors and cast, since the trainer sends the model
dtype and torchstore requires matching dtypes. The trainer-side
duplicate copy (#5654) was
removed by #5659.

Requires meta-pytorch/torchstore#252 (merged).

Test plan:
- CPU: `pytest tests/rl/unit_tests/cpu` with no GPU: all pass (gpu_fetch
load path covered by `test_vllm_wrapper`/`test_generator`, incl. float32
param staging).
- GPU (GB200): `torchrun --nproc-per-node=2 -m pytest
tests/rl/unit_tests/gpu/test_bitwise_parity.py::TestBitwiseParityQwen35DebugVarlen`:
5 passed on both ranks. Trainer vs vLLM prefill and decode are bitwise
identical, including a reload through `load_model_state_dict` after CUDA
graph replay.
- E2E async RL on GB200 through TorchStore (before merging main):
- Qwen3 debugmodel, gen TP2, 4 steps: logprob_diff/max 0.0099-0.012 in
both modes.
- Qwen3 8B, gen TP1, 5 syncs: logprob_diff/max 0.21-0.31 in both modes
(same as before this PR; untrained weights, not batch invariant).

Generator memory, Qwen3 8B, gen TP1:

| mode | host RSS | GPU growth in sync | sync time |
|---|---|---|---|
| `cpu_prefetch` | 24.7 GiB (16 GiB pinned copy) | +1 GiB on first sync
only | 0.48 s, overlapped with generation |
| `gpu_fetch` | 3.8-3.9 GiB | 0 | 1.1-1.2 s, engine paused |

Lines changed: impl +306/-88, tests +436/-100, CI 0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants