Skip to content

Commit 8820713

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 22383b0 commit 8820713

14 files changed

Lines changed: 734 additions & 43 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

‎miles/backends/training_utils/weight_update/protocols/broadcast.py‎

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from miles.backends.training_utils.weight_update.hf_weight_iterator import WeightUpdatePlacement
1414
from miles.backends.training_utils.weight_update.protocol import WeightTransferProtocol
1515
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.protocols.utils.flattened_bucket import flatten_weight_bucket
1617
from miles.backends.training_utils.weight_update.rollout_cell_updater import create_rollout_cell_updaters
1718
from miles.utils import async_utils
1819
from miles.utils.distributed_lock import create_world_ticket_lock
@@ -162,7 +163,7 @@ def update_weights_from_distributed(
162163
# Pack before asking receivers to enter their collective, so allocation or
163164
# format validation failures cannot leave them waiting for a broadcast.
164165
if transfer_mode == "broadcast_packed":
165-
tensors = [_flatten_weight_bucket(converted_named_tensors)]
166+
tensors = [flatten_weight_bucket(converted_named_tensors).get_flattened_tensor()]
166167
format_kwargs = {"load_format": "flattened_bucket"}
167168
else:
168169
tensors = [param.data.contiguous() for _, param in converted_named_tensors]
@@ -189,23 +190,3 @@ def update_weights_from_distributed(
189190
handle.wait()
190191

191192
return futures
192-
193-
194-
def _flatten_weight_bucket(named_tensors: Sequence[tuple[str, torch.Tensor]]) -> torch.Tensor:
195-
# Keep SGLang optional for callers using the existing per-tensor protocol.
196-
from sglang.srt.weight_sync.tensor_bucket import FlattenedTensorBucket
197-
198-
if not getattr(FlattenedTensorBucket, "supports_multi_dtypes", False):
199-
raise RuntimeError("Flattened broadcasts require SGLang's mixed-dtype FlattenedTensorBucket")
200-
201-
# SGLang concatenates raw bytes without padding. Each reconstructed dtype
202-
# view requires an aligned offset; preserve names/order and reject unsafe
203-
# layouts before allocating or sending metadata instead of casting values.
204-
offset = 0
205-
for name, tensor in named_tensors:
206-
if offset % tensor.element_size():
207-
raise ValueError(f"Unaligned flattened weight {name}: byte offset {offset}, dtype {tensor.dtype}")
208-
offset += tensor.numel() * tensor.element_size()
209-
return FlattenedTensorBucket(
210-
named_tensors=[(name, tensor.contiguous()) for name, tensor in named_tensors]
211-
).get_flattened_tensor()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 (
17+
OwnedBuckets,
18+
get_data_replica_rank_and_size,
19+
)
20+
from miles.backends.training_utils.weight_update.rollout_cell_updater import create_rollout_cell_updaters
21+
from miles.backends.training_utils.weight_update.session import (
22+
check_weight_sync_results,
23+
maybe_pause_engines,
24+
maybe_resume_engines,
25+
)
26+
from miles.utils import async_utils
27+
from miles.utils.distributed_utils import get_gloo_group
28+
from miles.utils.hf_utils.checkpoint_files import copy_hf_metadata_files
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class UpdateWeightFromDisk(WeightTransferProtocol):
34+
"""
35+
Full-checkpoint weight sync over a shared filesystem. The ranks holding the gathered HF buckets
36+
split them round-robin and write each as a safetensors shard of a fresh version dir, rank 0 adds
37+
the index and HF assets, and the engines reload it via /update_weights_from_disk. Needs no NCCL,
38+
CUDA IPC or /pull_weights, so it works with engines on another accelerator stack.
39+
"""
40+
41+
# /update_weights_from_disk replaces every weight in one call, so no begin/end session
42+
use_weight_update_session = False
43+
44+
def __init__(self, args: Namespace) -> None:
45+
super().__init__(args)
46+
self.root_dir = args.update_weight_disk_dir
47+
self._version_dir: str | None = None
48+
self._previous_version_dir: str | None = None
49+
self._weight_map: dict[str, str] = {}
50+
51+
def connect(
52+
self,
53+
rollout_engines: Sequence[SGLangApiClient],
54+
engine_gpu_counts: Sequence[int] | None,
55+
engine_gpu_offsets: Sequence[int] | None,
56+
engine_cell_ids: Sequence[str],
57+
parallel_state: ParallelState,
58+
placement: WeightUpdatePlacement,
59+
selector: str,
60+
) -> None:
61+
self.rollout_engines = rollout_engines
62+
self.cell_updaters_of_cell_id = create_rollout_cell_updaters(self.args, self.rollout_engines, engine_cell_ids)
63+
self.group_name = "miles-disk"
64+
replica_rank, replica_size = get_data_replica_rank_and_size(parallel_state, placement)
65+
self._owned_buckets = OwnedBuckets(replica_rank=replica_rank, replica_size=replica_size)
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._owned_buckets.reset()
72+
os.makedirs(self._version_dir, exist_ok=True)
73+
return True
74+
75+
def send_bucket(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
76+
self._owned_buckets.offer(bucket)
77+
78+
def after_base_weights(self) -> None:
79+
# written only after the collective gathers finish, so the ranks' host copies run in parallel
80+
for bucket_index, bucket in self._owned_buckets.take():
81+
tensors = {name: tensor.detach().to("cpu").contiguous() for name, tensor in bucket}
82+
shard = f"rank{dist.get_rank():03d}-shard{bucket_index:05d}.safetensors"
83+
safetensors.torch.save_file(tensors, os.path.join(self._version_dir, shard))
84+
self._weight_map.update({name: shard for name in tensors})
85+
86+
def finalize(self, weight_version: int) -> None:
87+
self._write_index()
88+
self._reload_engines(weight_version)
89+
self._drop_previous_version()
90+
91+
def _write_index(self) -> None:
92+
weight_maps: list = [None] * dist.get_world_size()
93+
dist.all_gather_object(weight_maps, self._weight_map, group=get_gloo_group())
94+
if dist.get_rank() == 0:
95+
weight_map = {name: shard for rank_map in weight_maps for name, shard in rank_map.items()}
96+
index = {"metadata": {}, "weight_map": weight_map}
97+
with open(os.path.join(self._version_dir, "model.safetensors.index.json"), "w") as f:
98+
json.dump(index, f)
99+
copy_hf_metadata_files(self.args.hf_checkpoint, self._version_dir)
100+
logger.info("[disk] wrote %d tensors to %s", len(weight_map), self._version_dir)
101+
102+
def _reload_engines(self, weight_version: int) -> None:
103+
if dist.get_rank() == 0:
104+
cell_updaters = list(self.cell_updaters_of_cell_id.values())
105+
maybe_pause_engines(self.args, cell_updaters)
106+
results = async_utils.wait_futures(
107+
[
108+
# one call reloads the whole model, so --update-weight-engine-request-timeout does not bound it
109+
updater.submit_unbounded_client_call(
110+
"update_weights_from_disk", model_path=self._version_dir, weight_version=str(weight_version)
111+
)
112+
for updater in cell_updaters
113+
]
114+
)
115+
check_weight_sync_results(results, is_lora=False)
116+
maybe_resume_engines(self.args, cell_updaters)
117+
118+
def _drop_previous_version(self) -> None:
119+
if dist.get_rank() == 0 and self._previous_version_dir not in (None, self._version_dir):
120+
shutil.rmtree(self._previous_version_dir, ignore_errors=True)
121+
self._previous_version_dir = self._version_dir
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import contextlib
2+
from argparse import Namespace
3+
from collections import deque
4+
from collections.abc import Iterator, Sequence
5+
from concurrent.futures import Future
6+
7+
import torch
8+
import torch.multiprocessing
9+
from sglang.srt.utils import MultiprocessingSerializer
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 (
16+
OwnedBuckets,
17+
get_data_replica_rank_and_size,
18+
)
19+
from miles.backends.training_utils.weight_update.protocols.utils.flattened_bucket import flatten_weight_bucket
20+
from miles.backends.training_utils.weight_update.rollout_cell_updater import create_rollout_cell_updaters
21+
from miles.backends.training_utils.weight_update.session import check_weight_sync_results
22+
from miles.utils import async_utils
23+
24+
25+
# buckets in flight across the host, enough to keep the engines loading while the next one is copied out; arbitrary
26+
# otherwise. Each replica rank gets its share, so /dev/shm holds about this many buckets whatever the rank count.
27+
_MAX_BUCKETS_IN_FLIGHT_PER_HOST = 4
28+
29+
30+
class UpdateWeightFromSharedMemory(WeightTransferProtocol):
31+
"""
32+
Host weight sync through shared memory, for engines on the trainer's host. The ranks holding the
33+
gathered buckets split them round-robin, copy each flattened bucket into a shared-memory tensor
34+
once the collective gathers are done, and hand the engines its handle through
35+
/update_weights_from_tensor; each engine maps it in place, with no file written or parsed.
36+
A bucket is published as soon as it is on the host, so the copies overlap the engine loads. About
37+
`_MAX_BUCKETS_IN_FLIGHT_PER_HOST` host copies are alive at once across the host, so host memory stays a
38+
few buckets at any model size.
39+
"""
40+
41+
def __init__(self, args: Namespace) -> None:
42+
super().__init__(args)
43+
# each published host copy with its engine calls; a copy lives until every engine has loaded it
44+
self._in_flight: deque[tuple[torch.Tensor, list[Future]]] = deque()
45+
46+
def connect(
47+
self,
48+
rollout_engines: Sequence[SGLangApiClient],
49+
engine_gpu_counts: Sequence[int] | None,
50+
engine_gpu_offsets: Sequence[int] | None,
51+
engine_cell_ids: Sequence[str],
52+
parallel_state: ParallelState,
53+
placement: WeightUpdatePlacement,
54+
selector: str,
55+
) -> None:
56+
self.rollout_engines = rollout_engines
57+
self.cell_updaters_of_cell_id = create_rollout_cell_updaters(self.args, self.rollout_engines, engine_cell_ids)
58+
self.group_name = "miles-shm"
59+
self.is_sender = True
60+
self._selector = selector
61+
self._engine_tp_sizes = engine_gpu_counts
62+
replica_rank, replica_size = get_data_replica_rank_and_size(parallel_state, placement)
63+
self._owned_buckets = OwnedBuckets(replica_rank=replica_rank, replica_size=replica_size)
64+
self._max_in_flight = max(1, _MAX_BUCKETS_IN_FLIGHT_PER_HOST // replica_size)
65+
66+
def begin_sync(self, weight_version: int, iter_buckets) -> bool:
67+
self._owned_buckets.reset()
68+
return True
69+
70+
def send_bucket(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
71+
self._owned_buckets.offer(bucket)
72+
73+
def after_base_weights(self) -> None:
74+
# copied out only after the collective gathers finish, so the ranks' host copies run in parallel
75+
cell_updaters = list(self.cell_updaters_of_cell_id.values())
76+
for _, bucket in self._owned_buckets.take():
77+
if len(self._in_flight) == self._max_in_flight:
78+
self._wait_for_oldest_bucket()
79+
host_tensor, metadata = _copy_to_host_bucket(bucket)
80+
with _file_system_sharing():
81+
futures = self._publish(cell_updaters, {"flattened_tensor": host_tensor, "metadata": metadata})
82+
self._in_flight.append((host_tensor, futures))
83+
84+
def _publish(self, cell_updaters: list, bucket_payload: dict) -> list[Future]:
85+
return [
86+
updater.submit_client_call(
87+
"update_weights_from_tensor",
88+
# one serialization per receiving process: each takes its own reference to the /dev/shm file,
89+
# which is unlinked once the references are released, so a shared payload fails a later reader
90+
serialized_named_tensors=[
91+
MultiprocessingSerializer.serialize(bucket_payload, output_str=True) for _ in range(tp_size)
92+
],
93+
load_format="flattened_bucket",
94+
selector=self._selector,
95+
)
96+
for updater, tp_size in zip(cell_updaters, self._engine_tp_sizes, strict=True)
97+
# a payload no engine opens keeps its reference, so the file would outlive the sync
98+
if not updater.is_errored
99+
]
100+
101+
def finalize(self, weight_version: int) -> None:
102+
while self._in_flight:
103+
self._wait_for_oldest_bucket()
104+
105+
def _wait_for_oldest_bucket(self) -> None:
106+
_, futures = self._in_flight.popleft()
107+
check_weight_sync_results(async_utils.wait_futures(futures), is_lora=False)
108+
109+
110+
def _copy_to_host_bucket(bucket: list[tuple[str, torch.Tensor]]) -> tuple[torch.Tensor, list]:
111+
"""The bucket as one shared host byte tensor in the engine's flattened layout, copied tensor by tensor.
112+
113+
Flattening on the device would view each tensor as bytes, which TPU tiles with a padded trailing dimension
114+
(64x for bf16), so the layout comes from meta tensors and each tensor is copied into its slice.
115+
"""
116+
metadata = flatten_weight_bucket([(name, tensor.to("meta")) for name, tensor in bucket]).get_metadata()
117+
with _file_system_sharing():
118+
host_tensor = torch.empty(metadata[-1].end_idx, dtype=torch.uint8).share_memory_()
119+
for (_, tensor), meta in zip(bucket, metadata, strict=True):
120+
host_tensor[meta.start_idx : meta.end_idx].view(tensor.dtype).view(tensor.shape).copy_(tensor)
121+
return host_tensor, metadata
122+
123+
124+
@contextlib.contextmanager
125+
def _file_system_sharing() -> Iterator[None]:
126+
# the payload must name the /dev/shm file, which an unrelated process (the engine) can open; the strategy is
127+
# process-wide, so it is set only while allocating and serializing
128+
previous = torch.multiprocessing.get_sharing_strategy()
129+
torch.multiprocessing.set_sharing_strategy("file_system")
130+
try:
131+
yield
132+
finally:
133+
torch.multiprocessing.set_sharing_strategy(previous)

‎miles/backends/training_utils/weight_update/protocols/utils/data_replica.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import torch
12
import torch.distributed as dist
23

34
from miles.backends.training_utils.parallel import ParallelState
@@ -17,3 +18,27 @@ def get_data_replica_rank_and_size(parallel_state: ParallelState, placement: Wei
1718
all_column_ids: list = [None] * dist.get_world_size()
1819
dist.all_gather_object(all_column_ids, column_id)
1920
return sorted(set(all_column_ids)).index(column_id), dist.get_world_size() // parallel_state.pp.size
21+
22+
23+
class OwnedBuckets:
24+
"""The buckets one rank publishes: the ranks holding identical gathered data take them round-robin."""
25+
26+
def __init__(self, *, replica_rank: int, replica_size: int) -> None:
27+
self._replica_rank = replica_rank
28+
self._replica_size = replica_size
29+
self._next_index = 0
30+
self._owned: list[tuple[int, list[tuple[str, torch.Tensor]]]] = []
31+
32+
def reset(self) -> None:
33+
self._next_index = 0
34+
self._owned = []
35+
36+
def offer(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
37+
if self._next_index % self._replica_size == self._replica_rank:
38+
self._owned.append((self._next_index, bucket))
39+
self._next_index += 1
40+
41+
def take(self) -> list[tuple[int, list[tuple[str, torch.Tensor]]]]:
42+
"""The owned buckets with their stream index, handed over so none stays alive after the sync."""
43+
owned, self._owned = self._owned, []
44+
return owned

0 commit comments

Comments
 (0)