Skip to content

Commit 6e84a57

Browse files
HJSangclaude
andcommitted
Balance disk and shm bucket ownership by bytes instead of round-robin
Each bucket goes to the replica rank with the fewest bytes so far, as Megatron's fully parallel save and PyTorch DCP's dedup_save_plans balance replicated writes; round-robin by count let an embedding-sized bucket skew one rank's share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a3e9d0c commit 6e84a57

3 files changed

Lines changed: 38 additions & 14 deletions

File tree

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,24 @@ def get_data_replica_rank_and_size(parallel_state: ParallelState, placement: Wei
2121

2222

2323
class OwnedBuckets:
24-
"""The buckets one rank publishes, copied to the host: the ranks holding identical gathered data take them
25-
round-robin."""
24+
"""The buckets one rank publishes, copied to the host: each bucket goes to the replica rank with the fewest
25+
bytes so far, so the ranks holding identical gathered data share the writes evenly."""
2626

2727
def __init__(self, *, replica_rank: int, replica_size: int) -> None:
2828
self._replica_rank = replica_rank
2929
self._replica_size = replica_size
30-
self._next_index = 0
31-
self._owned: list[tuple[int, list[tuple[str, torch.Tensor]]]] = []
30+
self.reset()
3231

3332
def reset(self) -> None:
3433
self._next_index = 0
35-
self._owned = []
34+
self._bytes_per_rank = [0] * self._replica_size
35+
self._owned: list[tuple[int, list[tuple[str, torch.Tensor]]]] = []
3636

3737
def offer(self, bucket: list[tuple[str, torch.Tensor]]) -> None:
38-
if self._next_index % self._replica_size == self._replica_rank:
38+
# every rank sees the same buckets in the same order, so every rank picks the same owner
39+
owner = min(range(self._replica_size), key=lambda rank: (self._bytes_per_rank[rank], rank))
40+
self._bytes_per_rank[owner] += sum(tensor.nbytes for _, tensor in bucket)
41+
if owner == self._replica_rank:
3942
# kept on the device until the stream ends, the owned buckets would add model/replica_size to each
4043
# rank's peak device memory. Non-blocking, so the copy overlaps the next gather; into unpinned host
4144
# tensors, since `.to("cpu", non_blocking=True)` pins memory that CUDA's host allocator keeps cached
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import torch
2+
3+
from miles.backends.training_utils.weight_update.protocols.utils.data_replica import OwnedBuckets
4+
5+
6+
def _owned_names(replica_rank: int, buckets: list) -> list[str]:
7+
owned = OwnedBuckets(replica_rank=replica_rank, replica_size=2)
8+
for bucket in buckets:
9+
owned.offer(bucket)
10+
return [name for _, bucket in owned.take() for name, _ in bucket]
11+
12+
13+
def test_one_large_bucket_is_balanced_by_bytes_not_by_count():
14+
"""Round-robin would give rank 0 the large bucket and half the small ones; bytes give it the large one alone."""
15+
buckets = [[("embed", torch.zeros(300))]] + [[(f"layer{i}", torch.zeros(100))] for i in range(3)]
16+
assert _owned_names(0, buckets) == ["embed"]
17+
assert _owned_names(1, buckets) == ["layer0", "layer1", "layer2"]

‎tests/fast/backends/training_utils/weight_update/protocols/test_shm.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,18 @@ def test_another_process_rebuilds_every_tp_rank_bucket():
111111
protocol.finalize(weight_version=1)
112112

113113

114-
def test_a_replica_rank_publishes_only_its_own_buckets():
115-
protocol = _protocol(replica_rank=1, replica_size=2, tp_sizes=[1])
116-
_publish(protocol)
117-
118-
(updater,) = protocol.cell_updaters_of_cell_id.values()
119-
(payload,) = updater.calls[0][1]["serialized_named_tensors"]
120-
assert len(updater.calls) == 1
121-
assert [name for name, _, _ in _rebuild_in_another_process([payload])[0]] == ["layers.1.w"]
114+
def test_the_replica_ranks_publish_every_bucket_exactly_once():
115+
published = []
116+
for replica_rank in range(2):
117+
protocol = _protocol(replica_rank=replica_rank, replica_size=2, tp_sizes=[1])
118+
_publish(protocol)
119+
(updater,) = protocol.cell_updaters_of_cell_id.values()
120+
payloads = [kwargs["serialized_named_tensors"][0] for _, kwargs in updater.calls]
121+
assert payloads, f"replica rank {replica_rank} published nothing"
122+
published += [[name for name, _, _ in bucket] for bucket in _rebuild_in_another_process(payloads)]
123+
protocol.finalize(weight_version=1)
124+
125+
assert sorted(published) == sorted([name for name, _ in bucket] for bucket in _BUCKETS)
122126

123127

124128
def test_a_sync_keeps_at_most_the_window_of_host_copies_alive(monkeypatch):

0 commit comments

Comments
 (0)