Skip to content

Commit 6071cd5

Browse files
committed
feat(vllm): add delta-compressed collective refit
Adds optional delta-compressed weight transfer for non-colocated vLLM collective refit. This introduces a delta-aware packed weight transfer protocol that can send either full weights or additive deltas, with support for `dense`, `sparse_indices`, and `sparse_bitmask` delta encodings. The trainer source rank keeps a pinned CPU baseline of the last successfully synced HF-format weights, computes deltas against that baseline, and periodically sends full syncs based on `full_sync_interval`. The feature is disabled by default and only applies to non-colocated vLLM refit. Colocated CUDA IPC, vLLM FP8 weights, and ModelOpt quantized vLLM paths are rejected.
1 parent fad476e commit 6071cd5

12 files changed

Lines changed: 3198 additions & 105 deletions

File tree

nemo_rl/data_plane/__init__.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,11 @@
1818
detail of a specific adapter.
1919
"""
2020

21-
from nemo_rl.data_plane.codec import materialize
22-
from nemo_rl.data_plane.factory import build_data_plane_client
2321
from nemo_rl.data_plane.interfaces import (
2422
DataPlaneClient,
2523
DataPlaneConfig,
2624
KVBatchMeta,
2725
)
28-
from nemo_rl.data_plane.observability import MetricsDataPlaneClient, log_event
2926

3027
__all__ = [
3128
"DataPlaneClient",
@@ -36,3 +33,25 @@
3633
"log_event",
3734
"materialize",
3835
]
36+
37+
38+
def __getattr__(name: str):
39+
if name == "materialize":
40+
from nemo_rl.data_plane.codec import materialize
41+
42+
return materialize
43+
if name == "build_data_plane_client":
44+
from nemo_rl.data_plane.factory import build_data_plane_client
45+
46+
return build_data_plane_client
47+
if name in {"MetricsDataPlaneClient", "log_event"}:
48+
from nemo_rl.data_plane.observability import (
49+
MetricsDataPlaneClient,
50+
log_event,
51+
)
52+
53+
return {
54+
"MetricsDataPlaneClient": MetricsDataPlaneClient,
55+
"log_event": log_event,
56+
}[name]
57+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

nemo_rl/data_plane/interfaces.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,18 @@
3737

3838
from abc import ABC, abstractmethod
3939
from dataclasses import dataclass, field
40-
from typing import Any, Callable, Literal, NotRequired, Sequence, TypedDict
41-
42-
from tensordict import TensorDict
40+
from typing import (
41+
TYPE_CHECKING,
42+
Any,
43+
Callable,
44+
Literal,
45+
NotRequired,
46+
Sequence,
47+
TypedDict,
48+
)
49+
50+
if TYPE_CHECKING:
51+
from tensordict import TensorDict
4352

4453

4554
class DataPlaneConfig(TypedDict):

nemo_rl/models/generation/vllm/config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
from nemo_rl.models.generation.interfaces import GenerationConfig
1818

19+
DeltaCompressionDType = Literal[
20+
"fp16", "float16", "bf16", "bfloat16", "fp32", "float32"
21+
]
22+
1923

2024
class VllmSpecificArgs(TypedDict):
2125
tensor_parallel_size: int
@@ -41,9 +45,34 @@ class VllmSpecificArgs(TypedDict):
4145
tool_parser_plugin: NotRequired[str]
4246

4347

48+
class VllmDeltaCompressionConfig(TypedDict):
49+
# Enables delta-compressed collective refit for non-colocated vLLM.
50+
# Delta payloads use sparse indices to reduce collective payload bytes;
51+
# vLLM still receives decoded dense tensors before load_weights.
52+
# This keeps a pinned CPU baseline of the last synced HF-format weights
53+
# on the source producer rank, roughly one extra model-weight copy.
54+
# Recommended default: false.
55+
enabled: bool
56+
# Floating dtype used when computing and sending deltas.
57+
# Recommended default: ${policy.precision}.
58+
dtype: DeltaCompressionDType
59+
# Number of successful refits between full baseline refreshes.
60+
# Recommended default: 20.
61+
full_sync_interval: int
62+
# Maximum sparse-encoded payload bytes to bucket before broadcasting.
63+
# Smaller values improve refit pipelining; larger values reduce broadcast
64+
# call overhead. Recommended default: 5368709120 (5 GiB).
65+
sparse_bucket_size_bytes: int
66+
# Maximum decoded delta tensor bytes to batch before calling vLLM load_weights.
67+
# Smaller values improve overlap with receives; larger values reduce loader
68+
# call overhead. Recommended default: 536870912 (512 MiB).
69+
delta_load_batch_size_bytes: int
70+
71+
4472
class VllmConfig(GenerationConfig):
4573
vllm_cfg: VllmSpecificArgs
4674
vllm_kwargs: NotRequired[dict[str, Any]]
75+
delta_compression: NotRequired[VllmDeltaCompressionConfig | None]
4776

4877
# quantization config
4978
quant_cfg: NotRequired[str | None]

nemo_rl/models/generation/vllm/vllm_backend.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414
import gc
1515
import traceback
16+
from collections.abc import Iterator
1617
from typing import Any
1718

1819
import torch
@@ -24,7 +25,10 @@
2425
rebuild_cuda_tensor_from_ipc,
2526
)
2627
from nemo_rl.utils.nsys import wrap_with_nvtx_name
27-
from nemo_rl.utils.packed_tensor import packed_broadcast_consumer
28+
from nemo_rl.utils.weight_transfer import (
29+
additive_weight_load_context,
30+
packed_weight_transfer_consumer,
31+
)
2832

2933
try:
3034
import vllm # noqa: F401
@@ -52,6 +56,9 @@ def fix_gpt_oss_export_transpose(key: str, weight: torch.Tensor) -> torch.Tensor
5256

5357

5458
class VllmInternalWorkerExtension:
59+
state_dict_info: dict[str, Any] | None = None
60+
delta_load_batch_size_bytes: int | None = None
61+
5562
def init_collective(
5663
self,
5764
rank_prefix: int,
@@ -98,14 +105,24 @@ def maybe_init_zmq(self):
98105
self.zmq_socket.setsockopt(zmq.LINGER, 0)
99106
self.zmq_socket.connect(self.get_zmq_address())
100107

101-
def prepare_refit_info(self, state_dict_info: dict[str, Any]) -> None:
102-
"""Prepare state dict metadata for weight refitting and IPC streaming.
108+
def prepare_refit_info(
109+
self,
110+
state_dict_info: dict[str, Any],
111+
delta_load_batch_size_bytes: int | None = None,
112+
) -> None:
113+
"""Prepare state dict metadata for IPC/ZMQ weight refitting.
114+
115+
Collective refit receives tensor metadata from the transfer headers.
103116
104117
Args:
105118
state_dict_info (dict): A dictionary containing the info for refit.
106119
e.g. {tensor_name: (shape, dtype)}
120+
delta_load_batch_size_bytes (int | None): Maximum decoded delta bytes
121+
to batch before calling vLLM load_weights. None means delta
122+
transfer is disabled.
107123
"""
108-
self.state_dict_info = state_dict_info # pyrefly: ignore[implicitly-defined-attribute] This class does not define __init__ so assignments like this should be ignored
124+
self.state_dict_info = state_dict_info
125+
self.delta_load_batch_size_bytes = delta_load_batch_size_bytes
109126

110127
def _maybe_process_fp8_kv_cache(self) -> None:
111128
"""Process weights after loading for FP8 KV cache (static scales)."""
@@ -213,7 +230,7 @@ def _load_draft_weights(
213230
draft_weights = self._trim_vocab_padding(draft_model, draft_weights)
214231
draft_model.load_weights(weights=draft_weights)
215232

216-
def _load_weights(self, weights):
233+
def _load_weights(self, weights: list[tuple[str, torch.Tensor]]) -> None:
217234
"""Load weights with GptOss transpose fix, FP8, and draft-weight support.
218235
219236
Applies GPT-OSS down_proj transpose if needed, splits policy/draft
@@ -238,13 +255,35 @@ def _load_weights(self, weights):
238255

239256
self._load_draft_weights(draft_weights)
240257

258+
def _load_weight_deltas(self, weights: list[tuple[str, torch.Tensor]]) -> None:
259+
"""Apply additive weight deltas through vLLM's regular loaders."""
260+
with additive_weight_load_context(self._iter_delta_load_target_tensors()):
261+
self._load_weights(weights)
262+
263+
def _iter_delta_load_target_tensors(self) -> Iterator[torch.Tensor]:
264+
"""Yield model-owned tensors that should receive additive delta loads."""
265+
yield from self.model_runner.model.parameters()
266+
yield from self.model_runner.model.buffers()
267+
268+
draft_owner = getattr(self.model_runner, "drafter", None)
269+
draft_model = getattr(draft_owner, "model", None) if draft_owner else None
270+
if draft_model is not None:
271+
yield from draft_model.parameters()
272+
yield from draft_model.buffers()
273+
241274
@wrap_with_nvtx_name("vllm_internal_worker_extension/update_weights_via_ipc_zmq")
242275
def update_weights_via_ipc_zmq(self) -> bool:
243276
"""Receive and update model weights via ZMQ IPC socket.
244277
245278
Returns:
246279
bool: True if weights were successfully updated.
247280
"""
281+
state_dict_info = self.state_dict_info
282+
assert state_dict_info is not None, (
283+
"state_dict_info is not prepared. "
284+
"Please call prepare_refit_info when initializing the worker."
285+
)
286+
248287
buffer = None
249288
weights = None
250289

@@ -275,7 +314,7 @@ def update_weights_via_ipc_zmq(self) -> bool:
275314
weights = []
276315
offset = 0
277316
for key in list_keys:
278-
shape, dtype = self.state_dict_info[key] # pyrefly
317+
shape, dtype = state_dict_info[key]
279318
if isinstance(shape, list):
280319
shape = torch.Size(shape)
281320

@@ -336,19 +375,14 @@ def update_weights_via_ipc_zmq(self) -> bool:
336375
)
337376
def update_weights_from_collective(self) -> bool:
338377
"""Update the model weights from collective communication."""
339-
assert self.state_dict_info is not None, (
340-
"state_dict_info is not prepared. "
341-
"Please call prepare_refit_info when initializing the worker."
342-
)
343-
344-
load_model_weight_func = self._load_weights
345-
346378
try:
347-
packed_broadcast_consumer(
348-
iterator=iter(self.state_dict_info.items()),
379+
packed_weight_transfer_consumer(
349380
group=self.model_update_group,
350381
src=0,
351-
post_unpack_func=load_model_weight_func,
382+
load_full_weights_func=self._load_weights,
383+
load_delta_weights_func=self._load_weight_deltas,
384+
device=self.device,
385+
delta_load_batch_size_bytes=self.delta_load_batch_size_bytes,
352386
)
353387

354388
# Process weights after loading for FP8 KV cache

nemo_rl/models/generation/vllm/vllm_worker.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,17 @@ def _get_raw_spec_counters(self) -> dict[str, float | list[float]]:
664664
metrics[metric.name] = metric.value
665665
return metrics
666666

667+
def _get_delta_load_batch_size_bytes(self) -> int | None:
668+
delta_config = self.cfg.get("delta_compression", None)
669+
if delta_config is None or not delta_config["enabled"]:
670+
return None
671+
delta_load_batch_size_bytes = int(delta_config["delta_load_batch_size_bytes"])
672+
if delta_load_batch_size_bytes < 1:
673+
raise ValueError(
674+
"delta_compression.delta_load_batch_size_bytes must be >= 1"
675+
)
676+
return delta_load_batch_size_bytes
677+
667678

668679
class VllmGenerationWorkerImpl(BaseVllmGenerationWorker):
669680
def _create_engine(self, llm_kwargs: dict[str, Any]) -> None:
@@ -908,7 +919,10 @@ def report_device_id(self) -> list[str]:
908919

909920
def prepare_refit_info(self, state_dict_info: dict[str, Any]) -> None:
910921
"""Prepare the info for refit."""
911-
self.llm.collective_rpc("prepare_refit_info", args=(state_dict_info,))
922+
self.llm.collective_rpc(
923+
"prepare_refit_info",
924+
args=(state_dict_info, self._get_delta_load_batch_size_bytes()),
925+
)
912926

913927
@wrap_with_nvtx_name("vllm_genertion_worker/update_weights_via_ipc_zmq")
914928
def update_weights_via_ipc_zmq(self) -> bool:

nemo_rl/models/generation/vllm/vllm_worker_async.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,10 @@ async def report_device_id_async(self) -> list[str]:
10731073

10741074
async def prepare_refit_info_async(self, state_dict_info: dict[str, Any]) -> None:
10751075
"""Async version of prepare_refit_info."""
1076-
await self.llm.collective_rpc("prepare_refit_info", args=(state_dict_info,))
1076+
await self.llm.collective_rpc(
1077+
"prepare_refit_info",
1078+
args=(state_dict_info, self._get_delta_load_batch_size_bytes()),
1079+
)
10771080

10781081
async def update_weights_via_ipc_zmq_async(
10791082
self,

nemo_rl/models/policy/workers/dtensor_policy_worker.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@
9292
save_checkpoint,
9393
)
9494
from nemo_rl.utils.nsys import wrap_with_nvtx_name
95-
from nemo_rl.utils.packed_tensor import packed_broadcast_producer
95+
from nemo_rl.utils.weight_transfer import (
96+
create_vllm_delta_transfer_tracker,
97+
packed_weight_transfer_producer,
98+
)
9699

97100

98101
def _attach_context_parallel_hooks(model: nn.Module) -> None:
@@ -229,6 +232,9 @@ def __init__(
229232
configure_dynamo_cache()
230233

231234
self.cfg = config
235+
self.delta_weight_transfer_tracker = create_vllm_delta_transfer_tracker(
236+
self.cfg.get("generation")
237+
)
232238
# torch distributed init. Envars for rank, world_size, and master_addr and master_port are set from the ray remote call
233239
torch.distributed.init_process_group(backend="nccl")
234240
self.rank = torch.distributed.get_rank()
@@ -1795,6 +1801,11 @@ def prepare_refit_info(self) -> Optional[dict[str, Any]]:
17951801
# all tensor will be casted to self.dtype in stream_weights_via_ipc_zmq/broadcast_weights_for_collective
17961802
state_dict_info[name] = (tensor.shape, self.dtype)
17971803

1804+
if self.rank == 0 and self.delta_weight_transfer_tracker is not None:
1805+
self.delta_weight_transfer_tracker.prewarm_baseline_from_metadata(
1806+
state_dict_info
1807+
)
1808+
17981809
return state_dict_info
17991810

18001811
@torch.no_grad()
@@ -1879,14 +1890,15 @@ def _dtensor_post_iter_func(tensor, dtype):
18791890
tensor = tensor.to(dtype, non_blocking=True)
18801891
return tensor
18811892

1882-
# param_iterator will return (name, tensor), we only need tensor
1883-
dtensor_post_iter_func = lambda x: _dtensor_post_iter_func(x[1], self.dtype)
1893+
def _params_iterator():
1894+
for name, tensor in self.model.state_dict().items():
1895+
yield name, _dtensor_post_iter_func(tensor, self.dtype)
18841896

1885-
packed_broadcast_producer(
1886-
iterator=iter(self.model.state_dict().items()),
1897+
packed_weight_transfer_producer(
1898+
iterator=_params_iterator(),
18871899
group=self.model_update_group,
18881900
src=0,
1889-
post_iter_func=dtensor_post_iter_func,
1901+
delta_tracker=self.delta_weight_transfer_tracker,
18901902
)
18911903

18921904
# Manually move model to cpu for cpu offload case

nemo_rl/models/policy/workers/dtensor_policy_worker_v2.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@
7373
)
7474
from nemo_rl.utils.checkpoint import CheckpointingConfig
7575
from nemo_rl.utils.nsys import wrap_with_nvtx_name
76-
from nemo_rl.utils.packed_tensor import packed_broadcast_producer
76+
from nemo_rl.utils.weight_transfer import (
77+
create_vllm_delta_transfer_tracker,
78+
packed_weight_transfer_producer,
79+
)
7780

7881

7982
def dtensor_params_generator(
@@ -336,6 +339,9 @@ def __init__(
336339
self.sampling_params,
337340
_runtime_is_reward_model, # Duplicate, already set as _is_reward_model
338341
) = runtime_config
342+
self.delta_weight_transfer_tracker = create_vllm_delta_transfer_tracker(
343+
self.cfg.get("generation")
344+
)
339345

340346
@wrap_with_nvtx_name("dtensor_policy_worker_v2/train")
341347
def train(
@@ -875,6 +881,11 @@ def prepare_refit_info(self) -> Optional[dict[str, Any]]:
875881
for adapted_fqn, adapted_tensor in adapted_fqn_tensors:
876882
state_dict_info[adapted_fqn] = (adapted_tensor.shape, self.dtype)
877883

884+
if self.rank == 0 and self.delta_weight_transfer_tracker is not None:
885+
self.delta_weight_transfer_tracker.prewarm_baseline_from_metadata(
886+
state_dict_info
887+
)
888+
878889
return state_dict_info
879890

880891
@torch.no_grad()
@@ -985,14 +996,11 @@ def broadcast_weights_for_collective(
985996
)
986997
self.model = self.move_to_cuda(self.model)
987998

988-
# param_iterator will return (name, tensor), we only need tensor
989-
dtensor_post_iter_func = lambda x: x[1]
990-
991-
packed_broadcast_producer(
999+
packed_weight_transfer_producer(
9921000
iterator=dtensor_params_generator(self.model, self.dtype),
9931001
group=self.model_update_group,
9941002
src=0,
995-
post_iter_func=dtensor_post_iter_func,
1003+
delta_tracker=self.delta_weight_transfer_tracker,
9961004
)
9971005

9981006
# Manually move model to cpu for cpu offload case

0 commit comments

Comments
 (0)