Skip to content

Commit 792c4be

Browse files
HJSangclaude
andcommitted
Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC
An engine on another accelerator stack (sglang-torchtpu) shares neither NCCL nor CUDA IPC with the trainer, and serves no /pull_weights, so none of the existing transfer modes reach it. --update-weight-transfer-mode disk writes the gathered HF buckets as a fresh safetensors checkpoint on a shared filesystem and has the engines reload it through /update_weights_from_disk. --update-weight-transfer-mode shm copies each flattened bucket into a shared-memory tensor and hands its handle to engines on the trainer's hosts through /update_weights_from_tensor, so nothing is written or parsed. In both, the ranks holding identical gathered buckets split them round-robin and copy only after the collective gathers, so the host copies run in parallel. shm runs inside the begin/end weight-update session; disk pauses the engines around its one reload call, as disk-delta does. Engine calls go through the rollout-cell updaters, so a failed engine is reported per cell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 11c135a commit 792c4be

7 files changed

Lines changed: 543 additions & 7 deletions

File tree

‎docs/advanced/disaggregated-rollout.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ miles-managed path:
107107
| `broadcast_packed` | Pack each weight bucket into one byte buffer and issue one NCCL broadcast | Non-colocated Megatron with SGLang mixed-dtype flattened-bucket support; trades contiguous bucket storage for fewer collectives |
108108
| `p2p` | Convert and re-shard weights, then write them directly to rollout-rank memory over RDMA | miles-managed, in-cluster jobs with direct rank-to-rank connectivity; see [P2P Weight Transfer](/advanced/p2p-weight-transfer) |
109109
| `disk-delta` | Publish changed canonical checkpoint bytes to shared storage, let rollout hosts materialize them locally, then reload | Trainer and rollout cannot share an NCCL fabric, or model-sized full-weight transfer dominates the update |
110+
| `disk` | Write the full HF checkpoint to shared storage on every sync, then reload it via `/update_weights_from_disk` | Engines on another accelerator stack (e.g. TPU) with no NCCL, CUDA IPC or `/pull_weights` |
111+
| `shm` | Copy each weight bucket into a host shared-memory tensor and hand the engines its handle via `/update_weights_from_tensor` | Like `disk`, with a single-node trainer and the engines on the same host |
110112

111113
These are weight synchronization choices, not different rollout APIs. In the
112114
broadcast and P2P modes, miles transfers tensors into known engine ranks directly.

‎miles/backends/training_utils/weight_update/protocol.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def pop_metrics(self) -> dict[str, float]:
7777

7878
def get_weight_transfer_protocol(args: Namespace) -> WeightTransferProtocol:
7979
mode = getattr(args, "update_weight_transfer_mode", "broadcast")
80-
if mode not in ("broadcast", "broadcast_packed", "p2p", "disk-delta"):
80+
if mode not in ("broadcast", "broadcast_packed", "p2p", "disk-delta", "disk", "shm"):
8181
raise ValueError(f"Unknown --update-weight-transfer-mode {mode!r}")
8282
if mode == "broadcast_packed" and (getattr(args, "train_backend", None) != "megatron" or args.colocate):
8383
raise ValueError("broadcast_packed requires Megatron non-colocated weight transfer")
@@ -93,6 +93,14 @@ def get_weight_transfer_protocol(args: Namespace) -> WeightTransferProtocol:
9393
from miles.backends.training_utils.weight_update.protocols.delta import UpdateWeightFromDiskDelta
9494

9595
return UpdateWeightFromDiskDelta(args)
96+
if mode == "disk":
97+
from miles.backends.training_utils.weight_update.protocols.disk import UpdateWeightFromDisk
98+
99+
return UpdateWeightFromDisk(args)
100+
if mode == "shm":
101+
from miles.backends.training_utils.weight_update.protocols.shm import UpdateWeightFromSharedMemory
102+
103+
return UpdateWeightFromSharedMemory(args)
96104
if mode == "p2p":
97105
from miles.backends.training_utils.weight_update.protocols.p2p import UpdateWeightP2P
98106

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
import logging
3+
import os
4+
import shutil
5+
from argparse import Namespace
6+
from collections.abc import Sequence
7+
8+
import safetensors.torch
9+
import torch
10+
import torch.distributed as dist
11+
12+
from miles.backends.sglang_utils.sglang_api_client import SGLangApiClient
13+
from miles.backends.training_utils.parallel import ParallelState
14+
from miles.backends.training_utils.weight_update.hf_weight_iterator import WeightUpdatePlacement
15+
from miles.backends.training_utils.weight_update.protocol import WeightTransferProtocol
16+
from miles.backends.training_utils.weight_update.protocols.utils.data_replica import get_data_replica_rank_and_size
17+
from miles.backends.training_utils.weight_update.rollout_cell_updater import create_rollout_cell_updaters
18+
from miles.backends.training_utils.weight_update.session import (
19+
check_weight_sync_results,
20+
maybe_pause_engines,
21+
maybe_resume_engines,
22+
)
23+
from miles.utils import async_utils
24+
from miles.utils.distributed_utils import get_gloo_group
25+
26+
logger = logging.getLogger(__name__)
27+
28+
# everything else in the HF checkpoint (config, tokenizer, remote code) is copied into each version
29+
_WEIGHT_FILE_SUFFIXES = (".safetensors", ".bin", ".pt", ".pth", ".index.json")
30+
31+
32+
class UpdateWeightFromDisk(WeightTransferProtocol):
33+
"""
34+
Full-checkpoint weight sync over a shared filesystem. The ranks holding the gathered HF buckets
35+
split them round-robin and write each as a safetensors shard of a fresh version dir, rank 0 adds
36+
the index and HF assets, and the engines reload it via /update_weights_from_disk. Needs no NCCL,
37+
CUDA IPC or /pull_weights, so it works with engines on another accelerator stack.
38+
"""
39+
40+
# /update_weights_from_disk replaces every weight in one call, so no begin/end session
41+
use_weight_update_session = False
42+
43+
def __init__(self, args: Namespace) -> None:
44+
super().__init__(args)
45+
self.root_dir = args.update_weight_disk_dir
46+
self._version_dir: str | None = None
47+
self._previous_version_dir: str | None = None
48+
self._weight_map: dict[str, str] = {}
49+
self._bucket_index = 0
50+
self._owned_buckets: list[tuple[int, list[tuple[str, torch.Tensor]]]] = []
51+
52+
def connect(
53+
self,
54+
rollout_engines: Sequence[SGLangApiClient],
55+
engine_gpu_counts: Sequence[int] | None,
56+
engine_gpu_offsets: Sequence[int] | None,
57+
engine_cell_ids: Sequence[str],
58+
parallel_state: ParallelState,
59+
placement: WeightUpdatePlacement,
60+
selector: str,
61+
) -> None:
62+
self.rollout_engines = rollout_engines
63+
self.cell_updaters_of_cell_id = create_rollout_cell_updaters(self.args, self.rollout_engines, engine_cell_ids)
64+
self.group_name = "miles-disk"
65+
self._replica_rank, self._replica_size = get_data_replica_rank_and_size(parallel_state, placement)
66+
self.is_sender = True
67+
68+
def begin_sync(self, weight_version: int, iter_buckets) -> bool:
69+
self._version_dir = os.path.join(self.root_dir, f"weight_v{weight_version:06d}")
70+
self._weight_map = {}
71+
self._bucket_index = 0
72+
self._owned_buckets = []
73+
os.makedirs(self._version_dir, exist_ok=True)
74+
return True
75+
76+
def send_bucket(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
77+
if self._bucket_index % self._replica_size == self._replica_rank:
78+
self._owned_buckets.append((self._bucket_index, bucket))
79+
self._bucket_index += 1
80+
81+
def after_base_weights(self) -> None:
82+
# written only after the collective gathers finish, so the ranks' host copies run in parallel
83+
for bucket_index, bucket in self._owned_buckets:
84+
tensors = {name: tensor.detach().to("cpu").contiguous() for name, tensor in bucket}
85+
shard = f"rank{dist.get_rank():03d}-shard{bucket_index:05d}.safetensors"
86+
safetensors.torch.save_file(tensors, os.path.join(self._version_dir, shard))
87+
self._weight_map.update({name: shard for name in tensors})
88+
self._owned_buckets = []
89+
90+
def finalize(self, weight_version: int) -> None:
91+
self._write_index()
92+
self._reload_engines(weight_version)
93+
self._drop_previous_version()
94+
95+
def _write_index(self) -> None:
96+
weight_maps: list = [None] * dist.get_world_size()
97+
dist.all_gather_object(weight_maps, self._weight_map, group=get_gloo_group())
98+
if dist.get_rank() == 0:
99+
weight_map = {name: shard for rank_map in weight_maps for name, shard in rank_map.items()}
100+
index = {"metadata": {}, "weight_map": weight_map}
101+
with open(os.path.join(self._version_dir, "model.safetensors.index.json"), "w") as f:
102+
json.dump(index, f)
103+
for fname in os.listdir(self.args.hf_checkpoint):
104+
path = os.path.join(self.args.hf_checkpoint, fname)
105+
if os.path.isfile(path) and not fname.endswith(_WEIGHT_FILE_SUFFIXES):
106+
shutil.copy(path, self._version_dir)
107+
logger.info("[disk] wrote %d tensors to %s", len(weight_map), self._version_dir)
108+
109+
def _reload_engines(self, weight_version: int) -> None:
110+
if dist.get_rank() == 0:
111+
cell_updaters = list(self.cell_updaters_of_cell_id.values())
112+
maybe_pause_engines(self.args, cell_updaters)
113+
results = async_utils.wait_futures(
114+
[
115+
updater.submit_client_call(
116+
"update_weights_from_disk", model_path=self._version_dir, weight_version=str(weight_version)
117+
)
118+
for updater in cell_updaters
119+
]
120+
)
121+
check_weight_sync_results(results, is_lora=False)
122+
maybe_resume_engines(self.args, cell_updaters)
123+
124+
def _drop_previous_version(self) -> None:
125+
if dist.get_rank() == 0 and self._previous_version_dir not in (None, self._version_dir):
126+
shutil.rmtree(self._previous_version_dir, ignore_errors=True)
127+
self._previous_version_dir = self._version_dir
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from argparse import Namespace
2+
from collections import deque
3+
from collections.abc import Sequence
4+
from concurrent.futures import Future
5+
6+
import torch
7+
import torch.multiprocessing
8+
from sglang.srt.utils import MultiprocessingSerializer
9+
from sglang.srt.weight_sync.tensor_bucket import FlattenedTensorBucket
10+
11+
from miles.backends.sglang_utils.sglang_api_client import SGLangApiClient
12+
from miles.backends.training_utils.parallel import ParallelState
13+
from miles.backends.training_utils.weight_update.hf_weight_iterator import WeightUpdatePlacement
14+
from miles.backends.training_utils.weight_update.protocol import WeightTransferProtocol
15+
from miles.backends.training_utils.weight_update.protocols.utils.data_replica import get_data_replica_rank_and_size
16+
from miles.backends.training_utils.weight_update.rollout_cell_updater import create_rollout_cell_updaters
17+
from miles.backends.training_utils.weight_update.session import check_weight_sync_results
18+
from miles.utils import async_utils
19+
20+
21+
# enough buckets in flight to keep the engines loading while the next one is copied out; arbitrary otherwise
22+
_MAX_BUCKETS_IN_FLIGHT = 4
23+
24+
25+
class UpdateWeightFromSharedMemory(WeightTransferProtocol):
26+
"""
27+
Host weight sync through shared memory, for engines on the trainer's hosts. The ranks holding the
28+
gathered buckets split them round-robin, copy each flattened bucket into a shared-memory tensor
29+
once the collective gathers are done, and hand the engines its handle through
30+
/update_weights_from_tensor; each engine maps it in place, with no file written or parsed.
31+
A bucket is published as soon as it is on the host, so the copies overlap the engine loads, and at most
32+
`_MAX_BUCKETS_IN_FLIGHT` host copies are alive at once, so host memory stays a few buckets at any model size.
33+
"""
34+
35+
def __init__(self, args: Namespace) -> None:
36+
super().__init__(args)
37+
# shared-memory tensors travel by /dev/shm file name, which an unrelated process can open
38+
torch.multiprocessing.set_sharing_strategy("file_system")
39+
self._bucket_index = 0
40+
self._owned_buckets: list[list[tuple[str, torch.Tensor]]] = []
41+
# each published host copy with its engine calls; a copy lives until every engine has loaded it
42+
self._in_flight: deque[tuple[torch.Tensor, list[Future]]] = deque()
43+
44+
def connect(
45+
self,
46+
rollout_engines: Sequence[SGLangApiClient],
47+
engine_gpu_counts: Sequence[int] | None,
48+
engine_gpu_offsets: Sequence[int] | None,
49+
engine_cell_ids: Sequence[str],
50+
parallel_state: ParallelState,
51+
placement: WeightUpdatePlacement,
52+
selector: str,
53+
) -> None:
54+
self.rollout_engines = rollout_engines
55+
self.cell_updaters_of_cell_id = create_rollout_cell_updaters(self.args, self.rollout_engines, engine_cell_ids)
56+
self.group_name = "miles-shm"
57+
self.is_sender = True
58+
self._selector = selector
59+
self._engine_tp_sizes = engine_gpu_counts
60+
self._replica_rank, self._replica_size = get_data_replica_rank_and_size(parallel_state, placement)
61+
62+
def begin_sync(self, weight_version: int, iter_buckets) -> bool:
63+
self._bucket_index = 0
64+
self._owned_buckets = []
65+
return True
66+
67+
def send_bucket(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
68+
if self._bucket_index % self._replica_size == self._replica_rank:
69+
self._owned_buckets.append(bucket)
70+
self._bucket_index += 1
71+
72+
def after_base_weights(self) -> None:
73+
# copied out only after the collective gathers finish, so the ranks' host copies run in parallel
74+
cell_updaters = list(self.cell_updaters_of_cell_id.values())
75+
for bucket in self._owned_buckets:
76+
if len(self._in_flight) == _MAX_BUCKETS_IN_FLIGHT:
77+
self._wait_for_oldest_bucket()
78+
flattened = FlattenedTensorBucket(named_tensors=bucket)
79+
device_tensor = flattened.get_flattened_tensor()
80+
host_tensor = torch.empty_like(device_tensor, device="cpu").share_memory_()
81+
host_tensor.copy_(device_tensor)
82+
payload = MultiprocessingSerializer.serialize(
83+
{"flattened_tensor": host_tensor, "metadata": flattened.get_metadata()}, output_str=True
84+
)
85+
futures = [
86+
updater.submit_client_call(
87+
"update_weights_from_tensor",
88+
serialized_named_tensors=[payload] * tp_size,
89+
load_format="flattened_bucket",
90+
selector=self._selector,
91+
)
92+
for updater, tp_size in zip(cell_updaters, self._engine_tp_sizes, strict=True)
93+
]
94+
self._in_flight.append((host_tensor, futures))
95+
self._owned_buckets = []
96+
97+
def finalize(self, weight_version: int) -> None:
98+
while self._in_flight:
99+
self._wait_for_oldest_bucket()
100+
101+
def _wait_for_oldest_bucket(self) -> None:
102+
_, futures = self._in_flight.popleft()
103+
check_weight_sync_results(async_utils.wait_futures(futures), is_lora=False)

‎miles/utils/arguments.py‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,7 +1114,7 @@ def add_rollout_arguments(parser):
11141114
)
11151115
parser.add_argument(
11161116
"--update-weight-transfer-mode",
1117-
choices=["broadcast", "broadcast_packed", "p2p", "disk-delta"],
1117+
choices=["broadcast", "broadcast_packed", "p2p", "disk-delta", "disk", "shm"],
11181118
default="broadcast",
11191119
help=(
11201120
"The method to transfer weights to remote rollout engines during update weight. "
@@ -1125,17 +1125,22 @@ def add_rollout_arguments(parser):
11251125
"exceed --update-weight-buffer-size. "
11261126
"'disk-delta' diffs each sync against a CPU snapshot of the previous one and publishes "
11271127
"only the changed bytes to --update-weight-disk-dir; each engine's /pull_weights applies "
1128-
"them into a host-local checkpoint that the engine reloads from."
1128+
"them into a host-local checkpoint that the engine reloads from. "
1129+
"'disk' writes the full HF checkpoint to --update-weight-disk-dir on every sync and has "
1130+
"the engines reload it via /update_weights_from_disk; it needs no NCCL, CUDA IPC or "
1131+
"/pull_weights, so it works with engines on another accelerator stack (e.g. TPU). "
1132+
"'shm' hands engines on the trainer's hosts shared-memory tensors through "
1133+
"/update_weights_from_tensor, skipping the files 'disk' writes and parses."
11291134
),
11301135
)
11311136
parser.add_argument(
11321137
"--update-weight-disk-dir",
11331138
type=str,
11341139
default=None,
11351140
help=(
1136-
"Filesystem directory disk-delta weight sync publishes to: one delta directory "
1137-
"(changed tensors only) per sync, written by the trainer and read by every "
1138-
"rollout host. Required for --update-weight-transfer-mode=disk-delta."
1141+
"Filesystem directory disk and disk-delta weight sync publish to, written by the "
1142+
"trainer and read by every rollout host: one full checkpoint (disk) or one delta "
1143+
"directory with the changed tensors only (disk-delta) per sync. Required for both modes."
11391144
),
11401145
)
11411146
parser.add_argument(
@@ -3439,7 +3444,7 @@ def miles_validate_args(args):
34393444
setattr(args, k, v)
34403445

34413446
mode = args.update_weight_transfer_mode
3442-
if mode not in ("broadcast", "broadcast_packed", "p2p", "disk-delta"):
3447+
if mode not in ("broadcast", "broadcast_packed", "p2p", "disk-delta", "disk", "shm"):
34433448
raise ValueError(f"Unknown --update-weight-transfer-mode {mode!r}")
34443449
if mode == "broadcast_packed" and (args.train_backend != "megatron" or args.colocate):
34453450
raise ValueError("broadcast_packed requires Megatron non-colocated weight transfer")
@@ -3883,6 +3888,32 @@ def miles_validate_args(args):
38833888
f"weights restored from --load={args.requested_load}."
38843889
)
38853890

3891+
if args.update_weight_transfer_mode in ("disk", "shm"):
3892+
mode = args.update_weight_transfer_mode
3893+
# colocate always syncs through CUDA IPC (get_weight_transfer_protocol)
3894+
assert not args.colocate, f"--update-weight-transfer-mode {mode} needs disaggregated engines; drop --colocate"
3895+
assert args.lora_rank <= 0, f"LoRA weight sync is not supported for --update-weight-transfer-mode {mode}"
3896+
assert (
3897+
args.prefill_num_servers is None
3898+
), f"--update-weight-transfer-mode {mode} has not been tested when PD is enabled"
3899+
if args.update_weight_transfer_mode == "shm":
3900+
# the engines open the shared-memory tensors by /dev/shm file name, so they must share the trainer's host
3901+
assert args.actor_num_nodes == 1, "--update-weight-transfer-mode shm needs the trainer on one node"
3902+
if args.update_weight_transfer_mode == "disk":
3903+
# /update_weights_from_disk reloads the draft model from the same directory, which has no draft weights
3904+
assert args.sglang_speculative_algorithm is None or args.enable_mtp_training, (
3905+
"--update-weight-transfer-mode disk reloads the speculative draft model from the trainer's "
3906+
"checkpoint; set --enable-mtp-training or drop --sglang-speculative-algorithm"
3907+
)
3908+
assert args.update_weight_disk_dir, (
3909+
"--update-weight-transfer-mode disk needs --update-weight-disk-dir on a filesystem "
3910+
"both the trainer and the rollout engines can read"
3911+
)
3912+
assert os.path.isdir(args.hf_checkpoint), (
3913+
"--update-weight-transfer-mode disk needs --hf-checkpoint to be a local directory: "
3914+
"each published version copies its config and tokenizer files"
3915+
)
3916+
38863917
if args.colocate:
38873918
if args.offload_train is None:
38883919
args.offload_train = True

0 commit comments

Comments
 (0)