NCCL Reshard Refit (Experimental)#
Experimental:
nccl_reshard_refitis an experimental feature.
The default non-colocated transport broadcasts every full parameter tensor from the
training ranks to every generation rank. nccl_reshard_refit replaces that for the bulk
of the payload with a shard-to-shard reshard: each training rank sends only its local
shard, and each generation rank receives exactly the bytes of its own (differently
parallelized) shard. This is both faster and lighter on memory since no rank ever
materializes or receives the full tensor.
Enabling It#
Add the config key (it is NotRequired in PolicyConfig, so use + when overriding
from the CLI):
uv run ./examples/run_grpo.py \
--config <your_config>.yaml \
policy.generation.colocated.enabled=false \
policy.generation.refit_transport=nccl_reshard
At setup, check_nccl_reshard_refit_support() validates the configuration and raises a
single ValueError listing every violation. The current requirements are:
Non-colocated only —
policy.generation.colocated.enabled=false. The colocated path uses IPC and is unaffected by this feature.Megatron training backend —
policy.megatron_cfg.enabled=true(the DTensor training backend is not supported yet.).vLLM generation backend —
policy.generation.backend=vllm(SGLang and TRTLLM backend is not supported yet.).Megatron
expert_tensor_parallel_size(i.e., ETP) must be 1; custom PP layouts (pipeline_model_parallel_layout, virtual PP > 1, embedding/loss pipeline-split accounting) are not supported yet.Precision must match end to end for BF16 train ↔ BF16 gen and blockwise-FP8 train (
fp8_param=true+ blockwise recipe) ↔ FP8 gen (vllm_cfg.precision=fp8). BF16 train → MXFP8 gen is also supported withvllm_cfg.precision=fp8andvllm_cfg.is_mx=true; the generation ranks quantize each received BF16 shard before installing it. Blockwise-FP8 train → MXFP8 gen is not supported.vLLM expert parallelism is supported with the NeMo RL convention
expert_parallel_size == tensor_parallel_size.Generation-side, PP > 1 is not supported.
No ModelOpt real quantization —
policy.generation.real_quant=false. Real-quant rollouts refit through vLLM’s layerwise-reload weight loaders, which the bulkxferdtensorwrites bypass.
Operational knobs:
NRL_REFIT_NUM_STREAMS(default2) — number of CUDA streams the generation side uses to overlap per-PP-stage bulk reshards. Having higher number can increase concurrency of the transportation when PP-size is large, but will have higher memory overhead.
Design Overview#
FFN layers are the dominant payload in the weight transfer. Our profiling shows that the MoE FFN layers account for 97%-98% of the model weights. To balance performance and software sustainability, we chose a dual-path strategy for the nccl-reshard-refit implementation:
Bulk path — the FFN projection weights (
gate_proj/up_proj/down_proj.weight, dense MLP and MoE experts alike; seeis_nccl_reshard_param()). These are resharded shard-to-shard withxferdtensorover dedicated NCCL communicators. For large models this covers the vast majority of the refit bytes. For the current version of implementation, it only detects(experts).N.{gate_proj|up_proj|down_proj}as the subject of this performant transportation path. The coverage will be expanded via future updates. Two FFN-named groups are explicitly excluded and ride the misc path instead: shared-expert weights (*.shared_expert.*, which fuse differently on the vLLM side) and co-trained MTP drafter weights (which vLLM keeps in a separate drafter module updated throughload_weights). MTP weights are recognized two ways: bare-mtp.-prefix HF names (NemotronH, Qwen3.5) viais_nccl_reshard_param(), and DeepSeek-style MTP exported as trailingmodel.layers.Nindices via provenance — the Megatron-side name carries anmtp.module segment (bare for LM bridges,language_model.mtp.*for the VL and EXAONE bridges), so the worker excludes those HF layers when building the metadata (_collect_mtp_hf_layer_names()).Misc path — everything else (embeddings, attention projections, layernorms, the MoE router,
lm_head, FP8_scale_invsiblings, FP8 KV-cache scales, …). These ride a packed broadcast (conventionalpacked_tensor.pyimplementation) over the sharedmodel_update_groupand are loaded on the generation side through the backend’s regularload_weightsmachinery.
The feature is integrated into the nemo_rl/weight_sync/ framework:
create_weight_synchronizer(..., nccl_reshard_refit=True) returns a
NcclReshardWeightSynchronizer whose init_communicator() performs the one-time setup
and whose sync_weights() runs one refit.
Execution Flow: Setup Time#
NcclReshardWeightSynchronizer.init_communicator() runs three steps once, before
training starts:
init_collective()— creates themodel_update_group, a NCCL group spanning all training and generation ranks. The bulk path does not use it; it carries the misc packed-broadcast (and FP8 KV-cache scales), identical to the conventional collective transport.init_nccl_reshard_comm_group()— creates the bulk-path communicator(s): one NCCL group per training PP stage, each spanning that stage’s training ranks plus all generation ranks (non-PP is simplypp_size == 1, a single group over everything). Keeping the bulk path on its own communicators decouples it from the misc broadcast.prepare_nccl_reshard_refit_info()— the metadata exchange. The training side builds a backend-agnostic description of every bulk parameter (build_nccl_reshard_refit_info()innemo_rl/weight_sync/nccl_reshard_utils.py), keyed strictly by HuggingFace parameter names, and ships it to the generation side. Before shipping,make_nccl_reshard_refit_info_wire_safe()converts theMeshInforank tensors andShard/Replicateplacements into plain dicts/lists — Megatron patches torch’s storage unpickler, so raw tensor pickles would requireimport megatroninside the vLLM worker. The generation side rebuilds the objects withrestore_refit_info_placements().
The derived metadata (nccl_reshard_refit_info) contains, per parameter:
name— the HF parameter name (per-expert MoE weights are grouped into a single...experts.{gate,up,down}_proj.weightentry of shape[num_experts, ...], tagged withgrouped_expert_proj);global_shapeanddtypeof the full, unsharded tensor;src_mesh_info/src_placements— the training-side rank mesh (MeshInfo) and DTensor-styleShard/Replicateplacements, derived from the training parallelism (TP/EP/PP; experts live on an EP mesh, everything else on a TP mesh);dst_mesh_info/dst_placements— the same for the generation side (TP mesh, or an EP mesh for experts when vLLM expert parallelism is enabled);pp_stage— which training PP stage owns the parameter (present whenpp_size > 1), used to route it to the right per-stage communicator.
Alongside it, misc_meta (an ordered dict of name -> {shape, dtype}) describes
every misc parameter; the order is load-bearing because producer and consumer walk it in
lockstep during the packed broadcast.
Finally, both sides build their hf_to_local_param_map: a mapping from each bulk HF
parameter name to a LocalParamSpec(base, pre, post) describing how that parameter is
realized locally:
On the training side, a direct parameter’s
baseis the live TP/EP-local shard (sent as-is); grouped MoE experts get aprehook that stacks this rank’s per-expert views into a[num_local_experts, ...]tensor fresh at each refit.On the generation side, a direct parameter’s
baseis the live vLLM parameter (received into in place); a parameter that is a slice of a fused vLLM tensor (densegate_up_proj, grouped-expertw13/w2) gets aprehook that allocates a receive buffer for its region and aposthook that copies the received shard back into the fused parameter.
Execution Flow: Refit Time#
Every training step (with in-flight weight updates, concurrently with generation),
NcclReshardWeightSynchronizer.sync_weights() triggers both sides:
precontains a function that should be executed in-flight before the refit.postcontains a function that should be executed in-flight after the refit.The training side walks
per_layer_params, skipping parameters owned by other PP stages. For each parameter it resolves theLocalParamSpec, runspre(expert stacking) if present, wraps the local shard in aDTensorRef(which reports the global shape while holding only the local tensor), and callsxferdtensor(src, src_mesh, src_placements, None, dst_mesh, dst_placements, group, stream).The generation side walks the same metadata in the same order — every rank in a comm group must issue the same sequence of transfers. Per-PP-stage parameter groups are distributed across
NRL_REFIT_NUM_STREAMSCUDA streams so different stages’ reshards overlap. For each parameter it runspre(receive-buffer allocation), callsxferdtensor(None, ..., dst, ..., group, stream), thenpost(copy back into the fused parameter).
The Misc Path#
After the bulk reshard completes, the misc parameters are transferred. This part is reusing the same code implementation as the conventional packed_tensor refit.
Decoupling Backend-Agnostic Parts and Backend-Dependent Parts#
To facilitate backend extension, the implementation cleanly separates backend-agnostic components from backend-dependent ones. As a result, extending to a new backend only requires implementing the backend-dependent components.
Backend-agnostic (no knowledge of Megatron or vLLM):
nemo_rl/weight_sync/nccl_reshard_utils.py— the metadata builder (build_nccl_reshard_refit_info), mesh/placement derivation (build_mesh_info,get_placements,MeshInfo), the bulk-path whitelist (is_nccl_reshard_param), per-expert grouping into HF-convention grouped entries, the config validator, and theLocalParamSpec,RefitCtx, andHFToLocalParamMapcontracts. All parameter sharding required by the different types of parallelism is handled by this utility.nemo_rl/weight_sync/xferdtensor.py— the transfer entry point and its transport dispatch (see below).nemo_rl/weight_sync/nccl_reshard_weight_synchronizer.pyand the factory routing — the lifecycle orchestration.
The glue that makes this work across backends is the HF naming convention: the training side must describe its parameters using HF names and global shapes, and the generation side maps those HF names onto whatever its own storage layout is.
Backend-dependent:
Training side (
megatron_policy_worker.py): producing the HF-named state-dict metadata; buildinghf_to_local_param_map— resolving each HF name to the local Megatron tensor view and providing the grouped-MoEprestacking hook; theinit_collective/init_nccl_reshard_comm_groupbootstrap methods; thenccl_reshard_refit()send loop; the misc packed-broadcast producer.Generation side (
vllm_backend.py): buildinghf_to_local_param_map— mapping HF names onto vLLM’s fused parameters (qkv_proj,gate_up_proj, grouped-expertw13_weight/w2_weight) withpre/posthooks for the slice regions, which is deliberately shape-driven so the same code handles generation TP and generation EP; the comm bootstrap methods; thenccl_reshard_refit()receive loop; the misc consumer feedingload_weights.
To extend to a new backend, the only piece with genuinely new logic is
build_hf_to_local_param_map. Everything else is boilerplate that follows a fixed
contract and can be copied from the existing backend almost verbatim.
The one backend-specific implementation — build_hf_to_local_param_map: resolve
each bulk HF name to your local storage as a LocalParamSpec — base for tensors
sent/received as-is, and pre/post hooks wherever your layout requires staging
(fused/merged tensors, layout conversions, grouped-expert stacking). This is the only
place your backend’s parameter layout is encoded; all cross-mesh byte movement is
already handled by the shared metadata and xferdtensor.
(A new training backend additionally has to produce the HF-named metadata — names,
global shapes, dtypes, and the parallelism description the agnostic builder consumes —
inside its prepare_nccl_reshard_refit_info, since only the backend knows how to read
its own weights. A new generation backend simply consumes the shipped metadata.)
Copy-paste boilerplate (identical in shape to the existing backend; only names/attributes change):
prepare_nccl_reshard_refit_info— restore the shipped metadata and callbuild_hf_to_local_param_maponce.The communicator bootstrap (
init_collective,init_nccl_reshard_comm_group) — the sameStatelessProcessGroupsetup; the only requirement is the rank convention: training ranks first (per-stage-local for the bulk groups), generation ranks after.The
nccl_reshard_refit()loop — walkper_layer_paramsin metadata order (grouped bypp_stageacrossNRL_REFIT_NUM_STREAMSstreams), resolve eachLocalParamSpec, runpre, callxferdtensor, runpost. It only touches the generic spec/metadata contracts, never your layout.The misc producer/consumer — reuses the conventional packed-broadcast path.
xferdtensor Transports#
xferdtensor() (in nemo_rl/weight_sync/xferdtensor.py) is the single entry point both
workers call. It has the 8-argument signature
xferdtensor(src_tensor, src_mesh, src_placement,
dst_tensor, dst_mesh, dst_placement,
process_group, stream=None)
and dispatches to one of two transports:
Core NCCL reshard — the reshard operation provided by the nccl4py wrapper (
nccl.m2n.reshard). When the package is accesible, this is the default: the local shards, mesh rank grids, and placements are handed to the NCCL library, which executes the cross-mesh redistribution natively.xferdtensor_python_impl(nemo_rl/weight_sync/xferdtensor_python.py) — a pure Python + nccl4py-collectives backup implementation for environments without a proper NCCL / nccl4py reshard installation. It computes the exact shard overlaps between the source and destination layouts, moves each destination region once via batched point-to-point (with striped receives across replica groups), and fans out to replicas with cached split-communicator broadcasts. It is a drop-in with the same signature and is selected automatically whennccl.m2nis not importable.xferdtensor_golden(nemo_rl/weight_sync/xferdtensor.py) — a pure function-only implementation intended for debugging. This implementation simply broadcasts the full tensor to the destination ranks, which then discard the unused parts. While not performant, it guarantees functionally correct outputs.
Both transports honor the stream argument so the transfer is ordered with the caller’s
pre/post staging work on one CUDA stream.
Expected Performance#
Platform |
Model |
Precision |
Train → Gen mapping |
XferDTensor fraction |
Refit time |
|---|---|---|---|---|---|
H100 |
QWEN3 4B (dense) |
BF16 |
DP8 → TP8 |
66.9% |
0.21–0.34s |
H100 |
QWEN3 30B |
BF16 |
EP8×PP2 → TP8×DP2 |
95.0% |
0.74–1.00s |
H100 |
QWEN3 30B |
FP8 |
EP8×DP2 → TP2×DP8 |
93.0% |
2.90–4.00s |
H100 |
DSV3 |
BF16 |
PP16×EP16 → TP32×DP8 |
97.6% |
2.39s-2.83s |
H100 |
QWEN3.5 397B |
BF16 |
TP8xPP8xEP32 -> TP16xDP16 |
97.4% |
1.97s-2.17s |
GB200 |
DSV3 |
BF16 |
PP16×EP16 → TP32×DP8 |
97.6% |
1.93s-2.59s |
GB200 |
Nemotron Ultra-v3 |
BF16 |
TP8xEP32xPP2 -> TP8xDP8 |
93.8% |
2.32s |
The feature supports both dense and MoE models. The table above shows the XferDTensor fraction, which is the proportion of the refit payload that utilizes the high-performance bulk transfer path. As the model size increases, this fraction becomes higher, which is the key to provide a scalable refit time to large models. For FP8 models, the efficiency is currently lower compared to BF16 models.