Weight synchronization

The trainer’s weights advance every optimizer step; the inference fleet only sees new weights on a refit. The WeightSynchronizer abstraction (weight_sync/) owns that transfer and hides the transport and topology from the GRPO loop, which never branches on backend type or colocation.

The interface

WeightSynchronizer (weight_sync/interfaces.py) is a small ABC the loop drives explicitly:

Method

Role

init_communicator()

Called once at setup, after workers exist. Colocated transports prepare refit metadata; the collective transport also initializes its process group.

mark_stale()

Called after every training step. Flags that the inference weights are behind.

is_stale

Property — True between mark_stale() and the next successful sync_weights().

sync_weights()

Transfers the latest policy weights into the generation backend.

shutdown()

Releases communication resources.

The loop’s usage is the staleness handshake: after an optimizer step it calls mark_stale(); before generating, if is_stale, it calls sync_weights() and then prepare_for_generation() on the engine.

The interface assumes global weight updates — all generation workers update atomically and share one weight version. In async GRPO, heterogeneous weight ages are tracked per-sample in the replay buffer (target_weight_versions), not here.

Transports

create_weight_synchronizer() (weight_sync/factory.py) selects the transport from the deployment topology and the generation backend:

Topology

Backend

Transport

Class

Non-colocated

vLLM

NCCL collective

CollectiveWeightSynchronizer

Colocated

vLLM

IPC / ZMQ

IPCWeightSynchronizer

Colocated

SGLang

HTTP

HTTPWeightSynchronizer

Non-colocated SGLang is not supported (the factory raises NotImplementedError). The non-colocated path additionally requires train_cluster and inference_world_size.

Colocated vs. non-colocated lifecycle

This distinction shapes what sync_weights() does:

  • Colocated transports (IPC, HTTP) own GPU phase transitions internally — offload optimizer state before the refit, prepare_for_generation, offload again after — because the policy and the engine share GPUs and must hand the device back and forth.

  • The NCCL collective transport is a pure data mover. Policy and generation run on separate GPU clusters, so there are no phase transitions; it only forwards weights (and optional FP8 KV-cache scales) over the collective.

For colocated SGLang the engine’s GPU memory is released and re-acquired around each generation phase when enable_memory_saver is set: finish_generation posts release_memory_occupation so the server stops holding mem_fraction_static of the device during training, and prepare_for_generation issues a two-phase wake_up (weights, then KV cache) to resume it. With enable_memory_saver off the server stays resident (the historical always-on behaviour). A colocated generation worker also reserves only a capped GPU fraction (min(0.2, 1/max_colocated_worker_groups)), while the dedicated, non-colocated path still claims a full GPU.

sync_weights() accepts an optional kv_scales dict; only the collective transport honors it, forwarding to policy.broadcast_weights_for_collective(). IPC and HTTP ignore it.

The refit seam

Whatever the transport, the trainer must expose its weights in the layout the inference engine expects — HF parameter names and shapes. The policy worker implements a refit seam that maps its internal representation to that layout:

  • The DTensor backend gathers sharded DTensor parameters and emits the HF state dict.

  • The JAX backend converts NNX arrays (via dlpack on GPU, numpy on CPU) and applies a model-specific inverse of that model’s HF loader. The dense path transposes linear kernels back to HF layout; Qwen3-MoE additionally re-expands fused expert tensors into the per-expert experts.{i}.{gate,up,down}_proj layout the inference engine’s fused-MoE kernel consumes; Qwen3-Next adds its linear-attention parameters; and Gemma 4 maps its multi-parameter router and per-layer embedding table, keeping the experts fused because that is the layout its checkpoint and engine use. Each map is the exact inverse of the model’s loader, so a load→refit round-trip reproduces the HF state dict.

prepare_refit_info() reports the {hf_name: (shape, dtype)} map the collective producer uses to pack the broadcast; the consumer side unpacks it into the engine’s weights. The NCCL broadcast into a live engine is exercised on hardware (see handoff/hardware-deferred-validation.md); the name-map inverse and the JAX→torch value round-trip are covered by CPU tests.