Parameter Server#
The Parameter Server (PS) is the central weight-storage and synchronization hub that
makes PSRL’s decoupled train/gen architecture possible. Training workers push
updated weights to it after every gradient step, rollout instances pull weights
from it on demand (bounded by staleness). Because the PS sits between the two
clusters, training and generation never block on each other.
PSRL’s Parameter Server: a PS Manager coordinates a group of CPU-resident PS Workers co-located with rollout instances, and weights move over NIXL (UCX-selected RDMA / IPC / shared memory).#
Motivation#
In tightly-coupled RLHF systems the training step and the generation step share the same GPU memory, so updating the model is essentially free, but every training iteration has to wait for the previous generation batch to finish, and vice versa. PSRL breaks this barrier by keeping training and generation in separate processes (often on separate nodes), which raises a new question: where do the weights live, and how do we move them across the cluster fast enough that neither side stalls?
The PS answers this with three design decisions:
CPU-resident storage by default. GPU memory is the scarcest resource in the cluster, the PS keeps the canonical model in pinned CPU memory so it never competes with vLLM’s KV cache or the trainer’s optimizer state.
Place PS Workers on participating nodes. In
nixl_cpumode PSRL creates CPU storage workers on distinct rollout, actor, and validation nodes involved in model transfer. Consumers prefer a local shard source when available.Separate control plane from data plane. The PS Manager is a single Ray actor that tracks model versions and orchestrates push/pull permissions, the actual bytes are streamed by PS Workers over NIXL, never through the manager.
Architecture#
PSRL splits the Parameter Server into two cooperating roles, both implemented under
psrl/workers/ps/.
PS Manager (control plane)#
A single Ray actor (PSManager, ps_manager.py)
launched on the head node. It owns:
The current model version tag and a small
ModelStoreper version (either the state dict or a RayObjectRefto it, depending onps_mode).The staleness inventory (the per-version buffers + Reserve/Occupy/Consume protocol described in Staleness Control), the PS Manager is also the request-status tracker, so version assignment and buffer bookkeeping live in the same place.
A single-writer / multi-reader lock on push/pull operations (
_exclusive_push_lockedvs._shared_pull_count): many rollout instances can pull concurrently, but a push from the trainer takes the lock exclusively.A NIXL meta-server that PS Workers and train/rollout sides connect to so they can discover each other’s transport endpoints.
The PS Manager is intentionally stateless w.r.t. tensor data: it routes RPC calls and updates metadata, but the actual bytes flow worker-to-worker.
PS Workers (data plane)#
A group of CPU-side processes (PSStorageWorker +
PSWorkerGroup)
placed on relevant rollout, actor, and validation nodes. Each PS Worker holds a shard of the model
weights in pinned CPU memory and exposes NIXL endpoints that:
The train side writes into (push: each train rank streams its share of the state dict over NIXL into the appropriate PS Worker).
The rollout side reads from (pull: a rollout instance’s GPU ranks pull their shard from the local PS Worker, same-node DMA when possible, cross-node RDMA otherwise).
Local PS workers make same-node transfers possible, while NIXL/UCX handles cross-node transfers when local placement is unavailable.
sequenceDiagram
participant TW as Train Worker
participant PSM as PS Manager
participant PSW as PS Worker
participant RI as Rollout Worker
Note over TW,RI: Training step v -> v+1
TW->>PSM: acquire exclusive push lock(v+1)
PSM-->>TW: lock granted
TW->>PSW: NIXL write(shard), data plane
TW->>PSM: push_model(version=v+1), control plane
PSM->>PSM: bump version tag, free old buffer
PSM->>TW: release push lock
Note over RI: Sync trigger fires
RI->>PSM: acquire shared pull lock
PSM-->>RI: lock granted (concurrent with other rollouts)
RI->>PSW: NIXL read(shard), data plane
RI->>PSM: release pull lock
Note over RI: New version loaded, resume generation
Synchronization Modes (psrl.ps_mode)#
PSRL ships four weight-storage / transport combinations, selected by the single
flag psrl.ps_mode. They differ in where the bytes live and which transport carries
them.
|
Where weights live |
Train → PS path |
PS → Rollout path |
Status |
|---|---|---|---|---|
|
Real state dict in PS Manager process |
|
|
Implemented. Smallest models / debugging, simplest path, no NIXL setup needed. |
|
Ray |
|
|
Implemented. Default fallback when NIXL is unavailable, avoids serializing the state dict but still single-process. |
|
Sharded across PS Workers in pinned CPU memory |
NIXL write from each train rank |
NIXL read into each rollout rank |
Implemented. Recommended default: same-node DMA / cross-node RDMA, no Ray RPC on the hot path. |
|
(planned) sharded across PS Workers in GPU memory |
(planned) NIXL write, GPU-direct |
(planned) NIXL read, GPU-direct |
TBD, not implemented. Initializing this mode currently raises |
The recommended default for any real training run is nixl_cpu: every example
launch script (examples/dapo_trainer/*.sh, examples/retool/*.sh,
examples/mini_swe/*.sh) sets psrl.ps_mode=nixl_cpu. Use cpu / cpu_ref for
tiny smoke tests and debugging where NIXL setup is overkill.
Why CPU, not GPU, in the production default?
A GPU-resident PS would shave one PCIe hop off each pull, but rollout instances need every GB of GPU memory they can get for vLLM’s KV cache and CUDA graphs. Keeping the canonical weights in pinned CPU memory + same-node DMA on pull is usually the better trade, pulls are infrequent compared to prefill, and a larger KV cache pays off on every request.
Push / Pull Path#
Push (after every training step)#
The train worker (rank 0 in each TP/PP group) calls
psrl.ps_manager.acquire_exclusive_push_lock(), waits until no concurrent pull is in flight.Each train rank streams its shard of the freshly-computed state dict over NIXL into the matching PS Worker. The state dict is already sharded across train ranks (FSDP or Megatron TP/PP/CP), so no all-gather is needed.
Once all shards are received, the train side calls
push_model_state_dict_nixl(version_tag=v+1), a control-plane Ray RPC that simply bumps the PS Manager’s version counter and freesv−1’s storage.The PS Manager notifies the rollout coordinator that a new version is available.
Pull (when a rollout instance syncs)#
The rollout instance, driven by the configured sync strategy (see Flexible Rollout), calls
psrl.ps_manager.acquire_shared_pull_lock(). Multiple instances may hold this lock simultaneously.Each GPU rank inside the instance issues a NIXL read from its local PS Worker, UCX picks the best underlying transport (
cuda_ipc/ shared memory on the same node, RDMA across nodes).After all shards land, the rollout instance updates its local
version_tagand resumes generation under the new policy.
How push and pull interact#
The single-writer / multi-reader lock on PSManager gives the following semantics
(see _try_acquire_exclusive_push_lock / _try_acquire_shared_pull_lock):
Operation |
Blocks on in-flight push? |
Blocks on in-flight pull(s)? |
|---|---|---|
Push |
Yes (one writer only) |
Yes (waits for all readers to drain) |
Pull |
Yes (waits for the writer to finish) |
No, multiple pulls run concurrently |
The important architectural property is the last cell: rollout instances never queue behind each other on the PS. A push briefly suspends pulls (and vice versa), but pushes are infrequent (one per training step) and short (one sharded streaming write), while pulls fan out in parallel across all rollout instances. In steady state, generation is not gated by the PS.
Broadcast Initialization#
Config: psrl.broadcast_init.*
At job start, every PS Worker normally reads the same checkpoint from shared
filesystem. With dozens of workers this can hammer the filesystem and add minutes to
startup. broadcast_init replaces the parallel disk read with a tree-broadcast over
NIXL:
psrl:
broadcast_init:
enabled: true # rank-0 reads disk, everyone else gets bytes over NIXL
algorithm: binary_tree # only algorithm currently shipped
When enabled, only the rank-0 PS Worker reads the checkpoint from disk, it then
fans the state dict out to all other workers in ceil(log2(N)) rounds of binary
broadcast over the same NIXL endpoints used for steady-state push/pull
(broadcast.py).
Use this whenever PS Workers outnumber what your shared filesystem can comfortably
serve in parallel (typically >= 4 nodes).
Configuration Reference#
Field |
Type |
Default |
Description |
|---|---|---|---|
|
str |
|
IP of the head node where |
|
str |
|
Storage + transport mode: |
|
str |
|
NIXL meta-server bind address. |
|
int |
|
NIXL meta-server port. Change if it collides with something on your cluster. |
|
int |
|
Pinned temp-memory slot count for non-contiguous tensor transfers. Bump if you see “no free slot” warnings during push. |
|
bool |
|
Let TMS reclaim NIXL pinned buffers when not transferring. See Resource Elasticity. |
|
bool |
|
Rank-0 reads checkpoint, broadcasts to all PS Workers over NIXL instead of parallel disk reads. |
|
str |
|
Broadcast topology. |
PS-Worker count and placement are not configured directly. In nixl_cpu mode they
are derived from the distinct nodes used by rollout, actor, and validation workers,
so model consumers can prefer a local CPU storage worker when one is available.
Compatibility With Other Subsystems#
Staleness Control: version tags and Reserve/Occupy/Consume buffers are owned by the same
PSManageractor, so weight push and buffer state always advance atomically. See Staleness Control.Flexible Rollout: the sync strategy (
psrl.rollout_coordination.sync_and_mig_strategy.sync) is what decides when a rollout instance triggers a pull, the PS only services the pull when asked. See Flexible Rollout.Resource Elasticity (TMS): when
psrl.tms.enable_nixl=True, NIXL’s pinned temp buffers are managed by TMS and can be reclaimed between transfers, lowering the PS’s steady-state memory footprint. See Resource Elasticity.