main
py 1,328 lines 52.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Protocol tests for the sharded-RDT producer server (`_RDTProducerServer`).
4
5 The publish -> serve -> free_group -> release lifecycle is the subsystem's
6 concurrency core: a group whose barrier never completes stalls `end_sync`
7 forever, and a group freed too early drops CUDA-IPC storage while a consumer is
8 still reading it. Freeing is a PER-GROUP BARRIER: every live consumer signals
9 `free_group(gi)` at every owner of the group exactly once per sync, and the
10 group is released when the count reaches the live total handed to `begin_sync`
11 — one uniform integer, no routed per-producer targets.
12
13 These tests drive the real server. Everything here is CPU-clean: the IPC rebuild
14 is stubbed (`rebuild_cuda_tensor`) and the serve path is only entered as far as
15 the routing guard, which fires before any CUDA work.
16
17 `rdt_produce_weights_batched`'s pack/copy half needs a real GPU buffer; it stays
18 covered by the GPU tests in `test_sharded_rdt_trainer.py`.
19 """
20
21 import contextlib
22 import gc
23 import os
24 import sys
25 import threading
26 import time
27 from concurrent.futures import ThreadPoolExecutor
28 from concurrent.futures import TimeoutError as FuturesTimeout
29 from types import ModuleType, SimpleNamespace
30 from unittest.mock import Mock
31
32 import pytest
33 import torch
34
35 import vllm.distributed.weight_transfer.sharded_rdt_trainer as trainer_mod
36 from vllm.distributed.nixl_utils import alias_nixl_for_ray
37 from vllm.distributed.weight_transfer.sharded_rdt_common import (
38 buffer_alloc_bytes,
39 register_nixl_memory,
40 )
41 from vllm.distributed.weight_transfer.sharded_rdt_trainer import (
42 DEFAULT_GATHER_LOOKAHEAD,
43 ShardedRDTTrainerInitInfo,
44 ShardedRDTTrainerWeightTransferEngine,
45 _RDTProducerServer,
46 )
47
48 # Publish/free are keyed by GROUP INDEX; the names ride the publish entries.
49 GI_A, GI_B = 0, 1
50 GROUP_A = ("model.layers.0.w", "model.layers.0.b")
51 GROUP_B = ("model.layers.1.w",)
52
53
54 @pytest.fixture
55 def ray_nixl_transport(monkeypatch):
56 import ray
57 from ray.experimental.rdt import util
58 from ray.experimental.rdt.nixl_tensor_transport import NixlTensorTransport
59
60 from vllm.distributed import nixl_utils
61 from vllm.platforms import current_platform
62
63 transport = NixlTensorTransport()
64 package = ModuleType("nixl_rocm")
65 api = ModuleType("nixl_rocm._api")
66 api.__dict__.update(nixl_agent=Mock(), nixl_agent_config=Mock())
67 package.__dict__["_api"] = api
68 modules = {package.__name__: package, api.__name__: api}
69 imported_rcache_limits = []
70
71 def import_nixl(name):
72 imported_rcache_limits.append(os.environ.get("UCX_RCACHE_MAX_UNRELEASED"))
73 monkeypatch.setitem(sys.modules, name, modules[name])
74 return modules[name]
75
76 nixl_names = ("nixl", "nixl._api", *modules)
77 for name in nixl_names:
78 monkeypatch.delitem(sys.modules, name, raising=False)
79 monkeypatch.delenv("UCX_RCACHE_MAX_UNRELEASED", raising=False)
80 importer = Mock(side_effect=import_nixl)
81 monkeypatch.setattr(
82 nixl_utils, "importlib", SimpleNamespace(import_module=importer)
83 )
84 monkeypatch.setattr(nixl_utils, "is_nixl_available", lambda: True)
85 monkeypatch.setattr(util, "transport_managers", {"NIXL": transport})
86 monkeypatch.setattr(current_platform, "is_rocm", lambda: True)
87 monkeypatch.setattr(
88 ray,
89 "get_runtime_context",
90 lambda: SimpleNamespace(get_actor_id=lambda: "rdt-actor"),
91 )
92 try:
93 yield transport, api, importer, imported_rcache_limits
94 finally:
95 # The helper inserts aliases directly; remove them before monkeypatch
96 # restores any modules and environment values present before this test.
97 for name in nixl_names:
98 sys.modules.pop(name, None)
99 os.environ.pop("UCX_RCACHE_MAX_UNRELEASED", None)
100
101
102 def test_ray_registers_memory_through_rocm_alias(ray_nixl_transport):
103 """Ray's registration must reach the ROCm agent through the alias."""
104 _, api, importer, _ = ray_nixl_transport
105 register_nixl_memory(torch.empty(4, dtype=torch.uint8))
106 api.nixl_agent.return_value.register_memory.assert_called()
107 assert sys.modules["nixl"] is sys.modules["nixl_rocm"]
108 assert sys.modules["nixl._api"] is api
109
110 importer.reset_mock()
111 alias_nixl_for_ray()
112 importer.assert_not_called()
113
114
115 @pytest.mark.parametrize("rcache_limit", [None, "4096"])
116 def test_ray_nixl_sets_ucx_limit_before_import(
117 monkeypatch, ray_nixl_transport, rcache_limit
118 ):
119 """UCX must see the default or the user's override during the first import."""
120 _, _, _, imported_rcache_limits = ray_nixl_transport
121 if rcache_limit is not None:
122 monkeypatch.setenv("UCX_RCACHE_MAX_UNRELEASED", rcache_limit)
123 alias_nixl_for_ray()
124 assert imported_rcache_limits == [rcache_limit or "1024"] * 2
125
126
127 @pytest.mark.parametrize("existing_api", [False, True])
128 def test_ray_nixl_preserves_non_rocm_or_existing_imports(
129 monkeypatch, ray_nixl_transport, existing_api
130 ):
131 from vllm.platforms import current_platform
132
133 _, _, importer, _ = ray_nixl_transport
134 if existing_api:
135 api = ModuleType("nixl._api")
136 monkeypatch.setitem(sys.modules, "nixl._api", api)
137 else:
138 monkeypatch.setattr(current_platform, "is_rocm", lambda: False)
139 alias_nixl_for_ray()
140 importer.assert_not_called()
141 assert "UCX_RCACHE_MAX_UNRELEASED" not in os.environ
142 if existing_api:
143 assert sys.modules["nixl._api"] is api
144 else:
145 assert "nixl._api" not in sys.modules
146
147
148 def test_ray_nixl_alias_reports_missing_rocm_package(monkeypatch, ray_nixl_transport):
149 from vllm.distributed import nixl_utils
150
151 transport, api, importer, _ = ray_nixl_transport
152 monkeypatch.setattr(nixl_utils, "is_nixl_available", lambda: False)
153 with pytest.raises(ImportError, match="nixl_rocm"):
154 register_nixl_memory(torch.empty(1))
155 assert transport._nixl_agent is None
156 api.nixl_agent.assert_not_called()
157 importer.assert_not_called()
158 assert "nixl" not in sys.modules
159 assert "nixl._api" not in sys.modules
160
161
162 @pytest.fixture
163 def server_factory(monkeypatch):
164 """Build real `_RDTProducerServer` instances without a GPU.
165
166 Three process-global side effects in `__init__` have to be neutralized:
167 accelerator discovery (no driver here), the Ray NIXL monkeypatch (fail-soft
168 already, but pointless), and `gc.freeze()` — which would otherwise leak a
169 frozen GC generation into the pytest process for the rest of the session.
170 """
171 monkeypatch.setattr(
172 torch.accelerator, "current_device_index", lambda *a, **kw: 0, raising=False
173 )
174 monkeypatch.setattr(gc, "freeze", lambda: None)
175
176 # The IPC rebuild: hand back a plain CPU uint8 storage of the requested
177 # size. publish_group then does the real .view(dtype) + as_strided on it, so
178 # the view reconstruction under test is genuine.
179 def _fake_rebuild(*args):
180 return torch.arange(args[0], dtype=torch.uint8) if args else torch.empty(0)
181
182 monkeypatch.setattr(trainer_mod, "rebuild_cuda_tensor", _fake_rebuild)
183
184 built = []
185
186 def _make(**kwargs):
187 kwargs.setdefault("num_rdt_buffers", 2)
188 kwargs.setdefault("buffer_presize_gb", 0.0)
189 kwargs.setdefault("gather_lookahead", 2)
190 server = _RDTProducerServer(**kwargs)
191 built.append(server)
192 return server
193
194 yield _make
195 for server in built:
196 server.shutdown()
197
198
199 def _entries(names, *, nbytes=64, dtype="bfloat16"):
200 """The `(storages, views)` payload the engine's gather loop ships: one
201 storage export shared by every name, plus a per-name as_strided view spec.
202
203 `storages` values stand in for `reduce_tensor`'s args. Only the arity
204 matters here: `publish_group` overwrites index 6 with the server's own
205 device index, so the tuple must be at least 7 long. The stubbed rebuild
206 reads the byte count from index 0.
207 """
208 sid = 1
209 storages = {sid: (nbytes, None, None, None, None, None, -1, None)}
210 itemsize = getattr(torch, dtype).itemsize
211 per = (nbytes // itemsize) // max(1, len(names))
212 views = {name: (sid, dtype, [per], [1], i * per) for i, name in enumerate(names)}
213 return storages, views
214
215
216 def _publish(server, gi, names, **kw):
217 return server.publish_group(gi, _entries(names, **kw))
218
219
220 class TestPublishAndRebuild:
221 def test_publish_makes_every_name_serveable(self, server_factory):
222 server = server_factory()
223 server.begin_sync(1)
224 _publish(server, GI_A, GROUP_A)
225 assert set(server._cache) == set(GROUP_A)
226
227 def test_rebuilt_views_have_the_requested_dtype_and_geometry(self, server_factory):
228 server = server_factory()
229 server.begin_sync(1)
230 _publish(server, GI_A, GROUP_A, nbytes=64, dtype="bfloat16")
231 for name in GROUP_A:
232 t = server._cache[name]
233 assert t.dtype is torch.bfloat16
234 assert tuple(t.shape) == (16,) # 64 bytes / 2 names / 2 bytes
235
236 def test_views_of_one_storage_do_not_overlap(self, server_factory):
237 """One IPC export per storage, one as_strided view per name — the whole
238 point of the storage/view split. Overlapping views would serve the same
239 bytes for two different weights."""
240 server = server_factory()
241 server.begin_sync(1)
242 _publish(server, GI_A, GROUP_A, nbytes=64)
243 a, b = (server._cache[n] for n in GROUP_A)
244 assert a.storage_offset() != b.storage_offset()
245 assert a.data_ptr() != b.data_ptr()
246
247 def test_publish_records_the_groups_names_for_release(self, server_factory):
248 server = server_factory()
249 server.begin_sync(3)
250 _publish(server, GI_A, GROUP_A)
251 assert server._group_names[GI_A] == list(GROUP_A)
252
253 def test_publish_records_the_group_as_unfreed(self, server_factory):
254 server = server_factory()
255 server.begin_sync(1)
256 _publish(server, GI_A, GROUP_A)
257 assert server._inflight_groups == [GI_A]
258
259 def test_the_default_lookahead_is_one(self):
260 """1 is the sweet spot: group N+1 is gathered AND pullable while N is
261 being pulled, with resident memory at its floor of 2 groups — the bound
262 the larger-model runs size against."""
263 assert DEFAULT_GATHER_LOOKAHEAD == 1
264
265
266 class TestFreeBarrier:
267 def test_single_consumer_signal_releases_the_group(self, server_factory):
268 server = server_factory()
269 server.begin_sync(1)
270 _publish(server, GI_A, GROUP_A)
271 server.free_group(GI_A)
272 assert server._inflight_groups == []
273 assert set(server._cache) == set()
274
275 def test_group_is_held_until_the_last_live_consumer_signals(self, server_factory):
276 """Every live consumer signals every owner; releasing on the first
277 signal would drop storage other consumers are still reading."""
278 server = server_factory()
279 server.begin_sync(3)
280 _publish(server, GI_A, GROUP_A)
281 held_after_each_signal = []
282 for _ in range(3):
283 server.free_group(GI_A)
284 held_after_each_signal.append(GI_A in server._inflight_groups)
285 assert held_after_each_signal == [True, True, False]
286
287 def test_intermediate_signals_do_not_release(self, server_factory):
288 server = server_factory()
289 server.begin_sync(3)
290 _publish(server, GI_A, GROUP_A)
291 server.free_group(GI_A)
292 assert server._inflight_groups == [GI_A]
293 assert set(server._cache) == set(GROUP_A)
294 server.free_group(GI_A)
295 assert server._inflight_groups == [GI_A]
296 server.free_group(GI_A)
297 assert server._inflight_groups == []
298
299 def test_a_signal_that_beats_its_publish_is_completed_at_publish(
300 self, server_factory
301 ):
302 """A consumer with nothing to pull from a group signals it at sync
303 start, which can precede the publish. The publish must then release the
304 group rather than wait for a signal that will never come again."""
305 server = server_factory()
306 server.begin_sync(1)
307 server.free_group(GI_A)
308 _publish(server, GI_A, GROUP_A)
309 assert server._inflight_groups == []
310 assert server._freed_pending == [GI_A]
311
312 def test_early_signals_from_every_consumer_complete_at_publish(
313 self, server_factory
314 ):
315 server = server_factory()
316 server.begin_sync(3)
317 for _ in range(3):
318 server.free_group(GI_A)
319 _publish(server, GI_A, GROUP_A)
320 assert server._freed_pending == [GI_A]
321
322 def test_freed_groups_are_reported_back_to_the_engine(self, server_factory):
323 """The engine holds the CUDA-IPC export refs and may only drop them once
324 the server says the group is gone — and it hears that ONLY through
325 ``wait_freed`` / ``end_sync``, never a publish return (a freed notice
326 riding an unharvested async publish while the engine blocks in
327 ``wait_freed`` would wedge the loop)."""
328 server = server_factory()
329 server.begin_sync(1)
330 _publish(server, GI_A, GROUP_A)
331 server.free_group(GI_A)
332 assert server.wait_freed() == [GI_A]
333 assert server._freed_pending == []
334
335 def test_a_signal_for_an_unpublished_group_releases_nothing(self, server_factory):
336 server = server_factory()
337 server.begin_sync(1)
338 _publish(server, GI_A, GROUP_A)
339 server.free_group(99)
340 assert server._inflight_groups == [GI_A]
341 assert set(server._cache) == set(GROUP_A)
342
343 def test_release_drops_cache_and_count_entries(self, server_factory):
344 server = server_factory()
345 server.begin_sync(1)
346 _publish(server, GI_A, GROUP_A)
347 _publish(server, GI_B, GROUP_B)
348 server.free_group(GI_A)
349 assert set(server._cache) == set(GROUP_B)
350 assert server._free_counts.get(GI_A) is None
351 assert server._group_names.get(GI_A) is None
352
353
354 class _CountingSource:
355 """`iter_groups` yielding one CPU-tensor group per call, built lazily so a
356 group's tensors exist only once the engine's credit gate lets it gather."""
357
358 def __init__(self, groups):
359 self._groups = groups
360
361 def iter_groups(self):
362 for names in self._groups:
363 yield list(names), [torch.zeros(16, dtype=torch.float32) for _ in names]
364
365
366 class _ResidencyDict(dict):
367 """The engine's `_inflight`, instrumented: `max_len` is the high-water mark
368 of groups whose CUDA-IPC export refs were alive at once — the trainer's
369 resident-memory bound in groups."""
370
371 def __init__(self):
372 super().__init__()
373 self.max_len = 0
374
375 def __setitem__(self, key, value):
376 super().__setitem__(key, value)
377 self.max_len = max(self.max_len, len(self))
378
379
380 def _loop_engine(server, n_groups, *, lookahead):
381 """A real engine wired straight to a real (non-Ray) server, with only the
382 state `_run_gather_loop` reads. The `_rpc` seam dispatches inline;
383 `_publish_async` sees no `.remote` and runs inline too."""
384 groups = [(f"model.layers.{gi}.w",) for gi in range(n_groups)]
385 e = ShardedRDTTrainerWeightTransferEngine.__new__(
386 ShardedRDTTrainerWeightTransferEngine
387 )
388 e._init_info = ShardedRDTTrainerInitInfo(
389 rank=0, num_consumers=1, gather_lookahead=lookahead
390 )
391 e.source = _CountingSource(groups)
392 e._server = server
393 e._rpc = lambda method, *a: getattr(server, method)(*a)
394 e._groups = [list(g) for g in groups]
395 e._owned_idx = list(range(n_groups))
396 e._held_names = None
397 e._inflight = _ResidencyDict()
398 return e
399
400
401 class TestGatherCredit:
402 """The memory bound gates GATHERS, not publishes: a gathered group is
403 serveable immediately, and the ENGINE's loop stops gathering while more than
404 `gather_lookahead` groups are unfreed. So at most `lookahead + 1` groups are
405 resident — two at the default — while group N+1 is pulled the instant N's
406 pulls finish."""
407
408 @pytest.fixture
409 def gather_engine(self, monkeypatch):
410 """Neutralize the two CUDA touches in the export path so the REAL
411 gather loop runs on CPU: `.cuda()` becomes identity, and the storage
412 export ships the `(nbytes, ..., device, ...)` tuple the fixture's
413 stubbed `rebuild_cuda_tensor` expects."""
414 monkeypatch.setattr(torch.Tensor, "cuda", lambda self: self)
415 monkeypatch.setattr(
416 trainer_mod,
417 "reduce_tensor",
418 lambda base: (None, (base.numel(), None, None, None, None, None, -1, None)),
419 )
420 return _loop_engine
421
422 def test_publish_never_blocks(self, server_factory):
423 """Publishing must not park on a credit: the whole point is that a
424 gathered group becomes serveable immediately. Three publishes with no
425 frees, inline on this thread — a block here hangs the test."""
426 server = server_factory(gather_lookahead=1)
427 server.begin_sync(1)
428 for gi in range(3):
429 _publish(server, gi, (f"model.layers.{gi}.w",))
430 assert server._inflight_groups == [0, 1, 2]
431
432 def test_lookahead_one_holds_at_most_two_groups_resident(
433 self, server_factory, gather_engine
434 ):
435 """THE memory invariant: at lookahead=1 the trainer never holds more
436 than 2 groups of gathered weights, no matter how the consumer paces —
437 and the pipeline still overlaps, because the consumer here refuses to
438 free group N until N+1 is already published (pullable). A gate an
439 off-by-one too tight deadlocks this test; too loose fails max_len."""
440 server = server_factory(gather_lookahead=1)
441 n_groups = 8
442 engine = gather_engine(server, n_groups, lookahead=1)
443
444 failures: list[str] = []
445
446 def _consumer():
447 for gi in range(n_groups):
448 # N+1 must be pullable before N frees
449 target = min(gi + 1, n_groups - 1)
450 deadline = time.monotonic() + 10
451 while time.monotonic() < deadline:
452 with server._cache_cond:
453 if target in server._group_names:
454 break
455 time.sleep(0.002)
456 else:
457 failures.append(
458 f"group {target} never published while {gi} was unfreed"
459 )
460 return
461 server.free_group(gi)
462
463 consumer = threading.Thread(target=_consumer, daemon=True)
464 consumer.start()
465 engine._run_gather_loop(update_future=None, live_count=1)
466 consumer.join(timeout=10)
467
468 assert not consumer.is_alive() and not failures, failures
469 assert engine._inflight.max_len <= 2, (
470 f"{engine._inflight.max_len} groups were resident at lookahead=1"
471 )
472 assert engine._inflight == {} and server._cache == {}, (
473 "everything must be freed by end_sync"
474 )
475
476 def test_lookahead_zero_holds_exactly_one_group(
477 self, server_factory, gather_engine
478 ):
479 """0 is the serialized baseline: the loop waits for group N to be freed
480 before gathering N+1, so exactly ONE group is ever resident and nothing
481 overlaps. A floor of 1 on the bound would silently pipeline instead."""
482 server = server_factory(gather_lookahead=0)
483 engine = gather_engine(server, 4, lookahead=0)
484
485 def _run():
486 # unwound via set_gather_error below
487 with contextlib.suppress(Exception):
488 engine._run_gather_loop(update_future=None, live_count=1)
489
490 loop = threading.Thread(target=_run, daemon=True)
491 loop.start()
492 deadline = time.monotonic() + 5
493 while time.monotonic() < deadline and not server._inflight_groups:
494 time.sleep(0.005)
495 time.sleep(0.1) # a would-be second gather gets every chance to run
496 try:
497 assert engine._inflight.max_len == 1
498 assert server._inflight_groups == [0]
499 finally:
500 server.set_gather_error("test over") # unblock the parked wait_freed
501 loop.join(timeout=5)
502
503 def test_the_residency_bound_scales_with_the_lookahead(
504 self, server_factory, gather_engine
505 ):
506 """Lookahead + 1, not a hardcoded 2: at lookahead=2 a free-nothing
507 consumer sees exactly 3 groups gathered before the loop parks."""
508 server = server_factory(gather_lookahead=2)
509 engine = gather_engine(server, 8, lookahead=2)
510
511 def _run():
512 # unwound via set_gather_error below
513 with contextlib.suppress(Exception):
514 engine._run_gather_loop(update_future=None, live_count=1)
515
516 loop = threading.Thread(target=_run, daemon=True)
517 loop.start()
518 deadline = time.monotonic() + 5
519 while time.monotonic() < deadline and len(server._inflight_groups) < 3:
520 time.sleep(0.005)
521 time.sleep(0.1) # would-be 4th gather gets every chance to (wrongly) run
522 try:
523 assert engine._inflight.max_len == 3
524 assert server._inflight_groups == [0, 1, 2]
525 finally:
526 server.set_gather_error("test over") # unblock the parked wait_freed
527 loop.join(timeout=10)
528 assert not loop.is_alive()
529
530 def test_wait_freed_returns_the_freed_backlog(self, server_factory):
531 server = server_factory()
532 server.begin_sync(1)
533 _publish(server, GI_A, GROUP_A)
534 _publish(server, GI_B, GROUP_B)
535 server.free_group(GI_A)
536 assert server.wait_freed() == [GI_A]
537 server.free_group(GI_B)
538 assert server.wait_freed() == [GI_B]
539
540 def test_a_gather_error_releases_a_blocked_wait_freed(self, server_factory):
541 """Otherwise a trainer-side failure on another rank deadlocks this one
542 inside its credit gate. It must RAISE, not return empty — an empty
543 return would spin the engine straight back into the wait."""
544 server = server_factory()
545 server.begin_sync(1)
546 _publish(server, GI_A, GROUP_A)
547
548 errors: list = []
549 done = threading.Event()
550
551 def _wait():
552 try:
553 server.wait_freed()
554 except BaseException as e: # noqa: BLE001
555 errors.append(e)
556 done.set()
557
558 threading.Thread(target=_wait, daemon=True).start()
559 assert not done.wait(timeout=0.3), (
560 "wait_freed must block while nothing is freed"
561 )
562
563 server.set_gather_error("rank 3 export failed")
564 assert done.wait(timeout=5), "set_gather_error must unblock wait_freed"
565 assert errors and "rank 3 export failed" in str(errors[0])
566
567
568 class TestEndSync:
569 def test_end_sync_returns_immediately_when_everything_is_freed(
570 self, server_factory
571 ):
572 server = server_factory()
573 server.begin_sync(1)
574 _publish(server, GI_A, GROUP_A)
575 server.free_group(GI_A)
576 assert server.end_sync() == [GI_A]
577
578 def test_end_sync_waits_for_an_outstanding_group(self, server_factory):
579 server = server_factory()
580 server.begin_sync(1)
581 _publish(server, GI_A, GROUP_A)
582
583 returned = []
584 done = threading.Event()
585
586 def _end():
587 returned.append(server.end_sync())
588 done.set()
589
590 t = threading.Thread(target=_end, daemon=True)
591 t.start()
592 assert not done.wait(timeout=0.3), "end_sync must wait for the barrier"
593
594 server.free_group(GI_A)
595 assert done.wait(timeout=5)
596 t.join(timeout=5)
597 assert returned == [[GI_A]]
598
599 def test_a_gather_error_releases_a_blocked_end_sync(self, server_factory):
600 server = server_factory()
601 server.begin_sync(1)
602 _publish(server, GI_A, GROUP_A)
603
604 done = threading.Event()
605
606 def _end_then_signal():
607 server.end_sync()
608 done.set()
609
610 threading.Thread(target=_end_then_signal, daemon=True).start()
611 assert not done.wait(timeout=0.3)
612 server.set_gather_error("boom")
613 assert done.wait(timeout=5)
614
615
616 class TestBeginSync:
617 def test_begin_sync_sets_the_barrier_target(self, server_factory):
618 server = server_factory()
619 server.begin_sync(5)
620 assert server._live_count == 5
621
622 def test_the_target_is_floored_at_one(self, server_factory):
623 """A bare/zero call must not make every group free instantly (or divide
624 the barrier by zero)."""
625 server = server_factory()
626 server.begin_sync(0)
627 assert server._live_count == 1
628
629 def test_a_degraded_sync_lowers_the_target(self, server_factory):
630 """FT degraded sync: the live count is the WHOLE degraded-sync
631 mechanism on this side — the group releases after the live consumers
632 alone."""
633 server = server_factory()
634 server.begin_sync(2) # 4 provisioned, 2 alive
635 _publish(server, GI_A, GROUP_A)
636 server.free_group(GI_A)
637 assert server._inflight_groups == [GI_A]
638 server.free_group(GI_A)
639 assert server._inflight_groups == []
640
641 def test_begin_sync_clears_per_sync_state(self, server_factory):
642 server = server_factory()
643 server.begin_sync(2)
644 _publish(server, GI_A, GROUP_A)
645 server.free_group(GI_A)
646
647 server.begin_sync(2)
648 assert server._inflight_groups == []
649 assert server._freed_pending == []
650 assert server._free_counts == {}
651 assert server._group_names == {}
652
653 def test_a_straggler_signal_cannot_credit_the_next_sync(self, server_factory):
654 """begin_sync resets the counts, so a signal that leaked past the sync
655 boundary would over-credit — the reason the consumer drains its fired
656 signals before finishing. Pin the reset side of that contract."""
657 server = server_factory()
658 server.begin_sync(2)
659 _publish(server, GI_A, GROUP_A)
660 server.free_group(GI_A) # one of two consumers; sync then aborts/ends
661 server.begin_sync(2)
662 _publish(server, GI_A, GROUP_A)
663 server.free_group(GI_A)
664 assert server._inflight_groups == [GI_A], (
665 "one signal after the reset must not release"
666 )
667
668 def test_begin_sync_clears_a_previous_gather_error(self, server_factory):
669 server = server_factory()
670 server.set_gather_error("last sync failed")
671 server.begin_sync(1)
672 assert server._gather_error is None
673
674 def test_the_packed_destination_cache_survives_the_sync_boundary(
675 self, server_factory
676 ):
677 """The packed layout repeats every sync — that is what makes caching the
678 destination views worth ~5ms per 384-spec group."""
679 server = server_factory()
680 server._pack_dsts[("sentinel",)] = (0, [])
681 server.begin_sync(1)
682 assert ("sentinel",) in server._pack_dsts
683
684
685 class TestServedNamesGuard:
686 """A producer serves only the names it holds: its stage's groups, and
687 within them its own EP coordinate's experts plus the replicated names.
688 Without this guard a misrouted pull would block forever in the cache wait
689 for a name this rank never publishes."""
690
691 def test_a_pull_for_an_unserved_name_fails_loudly(self, server_factory):
692 server = server_factory(served_names=list(GROUP_A))
693 server.begin_sync(1)
694 with pytest.raises(RuntimeError, match="wrong producer"):
695 server.rdt_produce_weights_batched([(GROUP_B[0], ())], consumer_id=0)
696
697 def test_the_error_names_the_unserved_weights(self, server_factory):
698 server = server_factory(served_names=list(GROUP_A))
699 server.begin_sync(1)
700 with pytest.raises(RuntimeError, match=GROUP_B[0]):
701 server.rdt_produce_weights_batched([(GROUP_B[0], ())], consumer_id=0)
702
703 def test_served_names_none_accepts_anything(self, server_factory):
704 """Gather-to-all: every producer holds every group, so there is nothing
705 to guard. The call proceeds to the cache wait (not exercised here)."""
706 server = server_factory(served_names=None)
707 assert server._served_names is None
708
709
710 class TestStallWatchdog:
711 """The liveness bound on the producer's three blocking waits.
712
713 Engine-death detection is generation-driven, and no generation is in flight
714 during a weight sync, so a consumer that dies INSIDE the sync window has no
715 detector. It never sends its `free_group` signals, the group's barrier never
716 completes, and `wait_freed` / `end_sync` / the serve cache wait block
717 forever — which stops this rank iterating its WeightSource, which is a
718 collective, which wedges every other trainer rank in NCCL with no exception
719 anywhere.
720
721 The watchdog converts that into one real error. These use a tiny timeout;
722 the production default is 300s.
723 """
724
725 def test_a_blocked_wait_freed_fails_instead_of_hanging(self, server_factory):
726 server = server_factory(gather_lookahead=1, stall_timeout_s=0.3)
727 server.begin_sync(1)
728 # the engine's credit gate now waits; nobody will signal
729 _publish(server, GI_A, GROUP_A)
730
731 errors: list = []
732 done = threading.Event()
733
734 def _wait():
735 try:
736 server.wait_freed()
737 except BaseException as e: # noqa: BLE001
738 errors.append(e)
739 done.set()
740
741 threading.Thread(target=_wait, daemon=True).start()
742
743 assert done.wait(timeout=10), (
744 "wait_freed must give up rather than block forever"
745 )
746 assert isinstance(server._gather_error, RuntimeError)
747 assert "RDT stall" in str(server._gather_error)
748 assert errors, "the engine must get an exception, not an empty credit list"
749
750 def test_end_sync_fails_when_a_consumer_never_signals(self, server_factory):
751 """The exact mid-sync death signature: the group is published, one of
752 the two live consumers that owed a signal is gone."""
753 server = server_factory(stall_timeout_s=0.3)
754 server.begin_sync(2)
755 _publish(server, GI_A, GROUP_A)
756 server.free_group(GI_A) # one of the two consumers signals; the other died
757
758 server.end_sync()
759 assert server._gather_error is not None
760 assert "RDT stall" in str(server._gather_error)
761
762 def test_the_error_reaches_every_blocked_waiter(self, server_factory):
763 """The watchdog fires on the existing `set_gather_error` channel, so one
764 stall unwinds the whole rank through one path rather than each waiter
765 timing out separately — here both a consumer parked in the serve cache
766 wait and the engine parked in its credit gate."""
767 server = server_factory(gather_lookahead=1, stall_timeout_s=0.3)
768 server.begin_sync(1)
769 _publish(server, GI_A, GROUP_A)
770
771 errors: list = []
772
773 def _pull():
774 try:
775 server.rdt_produce_weights_batched([(GROUP_B[0], ())], consumer_id=0)
776 except BaseException as e: # noqa: BLE001
777 errors.append(("pull", e))
778
779 def _wait():
780 try:
781 server.wait_freed()
782 except BaseException as e: # noqa: BLE001
783 errors.append(("credit", e))
784
785 puller = threading.Thread(target=_pull, daemon=True)
786 waiter = threading.Thread(target=_wait, daemon=True)
787 puller.start()
788 waiter.start()
789 puller.join(timeout=10)
790 waiter.join(timeout=10)
791 assert not puller.is_alive() and not waiter.is_alive()
792 assert {who for who, _ in errors} == {"pull", "credit"}
793 assert all("gather errored" in str(e) for _, e in errors)
794
795 def test_progress_anywhere_keeps_a_slow_sync_alive(self, server_factory):
796 """A consumer that is slow but signaling must never trip it: the stamp
797 is global to the producer, so a steady trickle of signals holds the
798 whole rank open. Nine sequential publishes with a 0.3s timeout each take
799 longer in total than the timeout, and none may fire."""
800 server = server_factory(gather_lookahead=1, stall_timeout_s=0.3)
801 server.begin_sync(1)
802 for gi in range(9):
803 _publish(server, gi, (f"model.layers.{gi}.w",))
804 time.sleep(0.1)
805 server.free_group(gi)
806 assert server._gather_error is None
807 server.end_sync()
808 assert server._gather_error is None
809
810 def test_begin_sync_resets_the_progress_stamp(self, server_factory):
811 """Syncs are minutes apart. Without the reset the first publish of sync N+1
812 would measure its stall from somewhere inside sync N and fire immediately."""
813 server = server_factory(gather_lookahead=1, stall_timeout_s=0.3)
814 server.begin_sync(1)
815 _publish(server, GI_A, GROUP_A)
816 server.free_group(GI_A)
817 server.end_sync()
818
819 time.sleep(0.5) # the idle gap between syncs, > the timeout
820 server.begin_sync(1)
821 _publish(server, GI_B, GROUP_B)
822 assert server._gather_error is None
823
824 def test_default_timeout_is_the_documented_one(self, server_factory):
825 server = server_factory()
826 assert server._stall_timeout == trainer_mod.DEFAULT_STALL_TIMEOUT_S
827
828
829 class TestConcurrentPublishAndFree:
830 def test_the_lifecycle_survives_concurrent_publishes_and_signals(
831 self, server_factory
832 ):
833 """Every group published must end up freed and out of the cache, with
834 end_sync returning cleanly — the property whose violation is a hang."""
835 server = server_factory(gather_lookahead=2)
836 server.begin_sync(1)
837 groups = [(gi, (f"model.layers.{gi}.w",)) for gi in range(12)]
838
839 def _publisher():
840 for gi, names in groups:
841 _publish(server, gi, names)
842
843 def _signaler():
844 for gi, _names in groups:
845 while True:
846 with server._cache_cond:
847 published = gi in server._group_names
848 if published:
849 break
850 server.free_group(gi)
851
852 pub = threading.Thread(target=_publisher, daemon=True)
853 free = threading.Thread(target=_signaler, daemon=True)
854 pub.start()
855 free.start()
856 pub.join(timeout=10)
857 free.join(timeout=10)
858 assert not pub.is_alive() and not free.is_alive()
859
860 server.end_sync()
861 assert server._inflight_groups == []
862 assert server._cache == {}
863
864 def test_concurrent_signals_of_one_group_release_it_exactly_once(
865 self, server_factory
866 ):
867 """All live consumers signaling at once: the release must happen on the
868 last signal and only once, or the engine drops IPC refs twice (or
869 never)."""
870 server = server_factory()
871 server.begin_sync(8)
872 _publish(server, GI_A, GROUP_A)
873
874 barrier = threading.Barrier(8)
875
876 def _signal():
877 barrier.wait()
878 server.free_group(GI_A)
879
880 threads = [threading.Thread(target=_signal, daemon=True) for _ in range(8)]
881 for t in threads:
882 t.start()
883 for t in threads:
884 t.join(timeout=5)
885 assert not t.is_alive()
886
887 assert server._inflight_groups == []
888 assert server._freed_pending == [GI_A]
889 assert server._cache == {}
890
891
892 class TestFakeServerAgreesWithTheRealOne:
893 """`test_sharded_rdt_trainer.py`'s `_FakeProducerServer` is a second,
894 independent model of this protocol that the engine-side tests assert on. Pin
895 the two against each other so the fake cannot silently drift from the
896 semantics the real server enforces."""
897
898 @staticmethod
899 def _fake():
900 from tests.distributed.test_sharded_rdt_trainer import _FakeProducerServer
901
902 return _FakeProducerServer(auto_free=False)
903
904 def test_the_barrier_and_early_signal_semantics_match(self, server_factory):
905 def publish_fake(srv, gi, names):
906 return srv.publish_group(gi, _entries(names))
907
908 for server, publish in (
909 (server_factory(), _publish),
910 (self._fake(), publish_fake),
911 ):
912 server.begin_sync(2)
913 # early signal, then publish, then the closing signal
914 server.free_group(GI_A)
915 assert publish(server, GI_A, GROUP_A) is None, "publish returns nothing"
916 assert server._inflight_groups == [GI_A]
917 server.free_group(GI_A)
918 assert server._inflight_groups == []
919 assert server.wait_freed() == [GI_A]
920 assert server.end_sync() == []
921
922
923 class TestExportRing:
924 """The packed export path. Its one hard invariant is that the trainer packs
925 a group exactly the way ``publish_group`` rebuilds it — a layout
926 disagreement would serve the wrong bytes with nothing downstream to catch
927 it."""
928
929 @staticmethod
930 def _engine():
931 eng = ShardedRDTTrainerWeightTransferEngine.__new__(
932 ShardedRDTTrainerWeightTransferEngine
933 )
934 eng._export_ring = [None, None]
935 eng._export_ring_args = [None, None]
936 return eng
937
938 def _roundtrip(self, held, slot=0):
939 eng = self._engine()
940 storages, views, _refs = eng._pack_group_for_export(held, slot)
941 assert len(storages) == 1, "a packed group must export exactly one storage"
942 slot_t = eng._export_ring[slot]
943 return {
944 name: torch.as_strided(slot_t.view(getattr(torch, dt)), shape, stride, off)
945 for name, (_sid, dt, shape, stride, off) in views.items()
946 }
947
948 def test_packed_group_rebuilds_bit_identically(self):
949 held = [
950 ("bf16.2d", torch.randn(12, 7).bfloat16()),
951 ("fp32.1d", torch.randn(9)),
952 ("bf16.3d", torch.randn(4, 5, 6).bfloat16()),
953 ("bf16.transposed", torch.randn(6, 11).bfloat16().t()),
954 ("bf16.sliced", torch.randn(8, 32).bfloat16()[:, 3:19]),
955 ]
956 got = self._roundtrip(held)
957 for name, t in held:
958 assert got[name].dtype is t.dtype
959 assert tuple(got[name].shape) == tuple(t.shape)
960 assert torch.equal(got[name], t), name
961
962 def test_a_reused_slot_reallocates_and_still_rebuilds(self):
963 eng = self._engine()
964 eng._pack_group_for_export([("a", torch.randn(4, 4).bfloat16())], 0)
965 small = eng._export_ring[0].numel()
966 big = [("b", torch.randn(256, 128).bfloat16())]
967 _st, views, _rf = eng._pack_group_for_export(big, 0)
968 assert eng._export_ring[0].numel() > small
969 _name, (_sid, dt, shape, stride, off) = next(iter(views.items()))
970 rebuilt = torch.as_strided(
971 eng._export_ring[0].view(getattr(torch, dt)), shape, stride, off
972 )
973 assert torch.equal(rebuilt, big[0][1])
974
975 def test_slots_are_distinct_so_adjacent_groups_cannot_alias(self):
976 eng = self._engine()
977 eng._pack_group_for_export([("a", torch.randn(8, 8).bfloat16())], 0)
978 eng._pack_group_for_export([("b", torch.randn(8, 8).bfloat16())], 1)
979 assert eng._export_ring[0].data_ptr() != eng._export_ring[1].data_ptr()
980
981
982 class TestExportRingSlotSafety:
983 """The export ring packs a group's small storages into one reused buffer and
984 ships per-name views into it, so a slot may only be rewritten once the group
985 that last used it is freed everywhere. The credit gate does not establish
986 that on its own: it bounds how many groups are unfreed, not the order they
987 free in, and barriers complete out of order (a consumer pre-frees a group it
988 pulls nothing from; a light group can finish before an earlier heavy one).
989 """
990
991 @pytest.fixture
992 def gather_engine(self, monkeypatch):
993 monkeypatch.setattr(torch.Tensor, "cuda", lambda self: self)
994 monkeypatch.setattr(
995 trainer_mod,
996 "reduce_tensor",
997 lambda base: (
998 None,
999 (base.numel(), None, None, None, None, None, -1, None),
1000 ),
1001 )
1002 return _loop_engine
1003
1004 @staticmethod
1005 def _await_published(server, gi, timeout=10):
1006 deadline = time.monotonic() + timeout
1007 while time.monotonic() < deadline:
1008 with server._cache_cond:
1009 if gi in server._group_names:
1010 return True
1011 time.sleep(0.002)
1012 return False
1013
1014 def test_a_slot_is_never_rewritten_while_its_group_is_still_live(
1015 self, server_factory, gather_engine
1016 ):
1017 """Free the later group of each pair first and hold the earlier one live
1018 until the NEXT group is gathered -- the window a modular slot counter got
1019 wrong (at lookahead=1 there are 2 slots, so group 2 took group 0's)."""
1020 server = server_factory(gather_lookahead=1)
1021 n_groups = 8
1022 engine = gather_engine(server, n_groups, lookahead=1)
1023
1024 violations: list[tuple[int, int]] = []
1025 last_holder: dict[int, int] = {}
1026 pending: dict[str, int] = {}
1027 orig_pack = engine._pack_group_for_export
1028 orig_pub = engine._publish_async
1029
1030 def _pack(held, slot_idx):
1031 prev = last_holder.get(slot_idx)
1032 if prev is not None and prev in engine._inflight:
1033 violations.append((slot_idx, prev))
1034 pending["slot"] = slot_idx
1035 return orig_pack(held, slot_idx)
1036
1037 def _pub(group_idx, entries):
1038 if "slot" in pending:
1039 last_holder[pending.pop("slot")] = group_idx
1040 return orig_pub(group_idx, entries)
1041
1042 engine._pack_group_for_export = _pack
1043 engine._publish_async = _pub
1044
1045 def _consumer():
1046 for base in range(0, n_groups, 2):
1047 if not self._await_published(server, base + 1):
1048 return
1049 server.free_group(base + 1)
1050 nxt = base + 2
1051 if nxt < n_groups and not self._await_published(server, nxt):
1052 return
1053 server.free_group(base)
1054
1055 consumer = threading.Thread(target=_consumer, daemon=True)
1056 consumer.start()
1057 engine._run_gather_loop(update_future=None, live_count=1)
1058 consumer.join(timeout=15)
1059
1060 assert last_holder, "no group was ring-packed; the test proves nothing"
1061 assert not violations, f"slot rewritten while its group was live: {violations}"
1062 assert engine._inflight == {}
1063
1064 def test_distinct_live_groups_never_share_a_slot(
1065 self, server_factory, gather_engine
1066 ):
1067 server = server_factory(gather_lookahead=1)
1068 engine = gather_engine(server, 6, lookahead=1)
1069 seen: list[dict] = []
1070 orig_pack = engine._pack_group_for_export
1071
1072 def _pack(held, slot_idx):
1073 live = {
1074 g: s for g, s in engine._slot_of_group.items() if g in engine._inflight
1075 }
1076 seen.append({"slot": slot_idx, "live": live})
1077 return orig_pack(held, slot_idx)
1078
1079 engine._pack_group_for_export = _pack
1080
1081 def _consumer():
1082 for gi in range(6):
1083 if not self._await_published(server, gi):
1084 return
1085 server.free_group(gi)
1086
1087 consumer = threading.Thread(target=_consumer, daemon=True)
1088 consumer.start()
1089 engine._run_gather_loop(update_future=None, live_count=1)
1090 consumer.join(timeout=15)
1091
1092 assert seen, "no group was ring-packed; the test proves nothing"
1093 for rec in seen:
1094 assert rec["slot"] not in rec["live"].values(), rec
1095
1096
1097 @pytest.fixture
1098 def sharing_server(server_factory):
1099 """A producer whose serve slots are plain CPU tensors.
1100
1101 The pack is the only part of the serve path that touches a buffer, so
1102 replacing the one allocation seam (``_new_serve_buffer``, which would
1103 ``torch.empty(..., device="cuda")`` and register the result with NIXL) puts
1104 the whole shared-pack path within reach of a CPU test.
1105 """
1106
1107 def _make(**kwargs):
1108 kwargs.setdefault("workers_per_replica", 2)
1109 server = server_factory(**kwargs)
1110 server._serve_device = torch.device("cpu")
1111 server._new_serve_buffer = lambda n: torch.zeros(n, dtype=torch.uint8)
1112 server.packs = 0
1113 real_pack = server._pack_shared
1114
1115 def _counting_pack(gen, specs):
1116 server.packs += 1
1117 return real_pack(gen, specs)
1118
1119 server._pack_shared = _counting_pack
1120 return server
1121
1122 return _make
1123
1124
1125 def _blob_bytes(server, name):
1126 """The cached tensor's bytes, as the pack should land them in the slot."""
1127 t = server._cache[name]
1128 return t.contiguous().view(torch.uint8).flatten()
1129
1130
1131 class TestSharedServeSlots:
1132 """One registered slot serving several inference deployments.
1133
1134 Consumers whose ids differ by a multiple of ``workers_per_replica`` are the
1135 same worker of different deployments: same parallel config, same baked plan,
1136 same chunk sequence, byte-identical pack layout. They rendezvous per chunk,
1137 the last arrival packs, and every one of them returns that blob, so R
1138 deployments cost one ring and one pack rather than R of each.
1139 """
1140
1141 def test_one_deployment_never_shares(self, sharing_server):
1142 """With the width equal to the live count every group is a singleton, so
1143 each call packs for itself out of its own ring."""
1144 server = sharing_server(workers_per_replica=2)
1145 server.begin_sync(2, [0, 1])
1146 _publish(server, GI_A, GROUP_A)
1147 specs = [(GROUP_A[0], ())]
1148 blobs = [server.rdt_produce_weights_batched(specs, c, 0)[0] for c in (0, 1)]
1149
1150 assert server.packs == 2
1151 assert blobs[0].data_ptr() != blobs[1].data_ptr()
1152 assert len(server._serve_rings) == 2
1153
1154 def test_sharers_are_served_one_pack_out_of_one_buffer(self, sharing_server):
1155 server = sharing_server(workers_per_replica=2)
1156 server.begin_sync(4, [0, 1, 2, 3])
1157 _publish(server, GI_A, GROUP_A)
1158 specs = [(GROUP_A[0], ())]
1159
1160 with ThreadPoolExecutor(max_workers=2) as ex:
1161 futures = [
1162 ex.submit(server.rdt_produce_weights_batched, specs, c, 0)
1163 for c in (0, 2)
1164 ]
1165 blobs = [f.result(timeout=10)[0] for f in futures]
1166
1167 expected = _blob_bytes(server, GROUP_A[0])
1168 assert server.packs == 1
1169 assert blobs[0].data_ptr() == blobs[1].data_ptr()
1170 assert len(server._serve_rings) == 1
1171 assert torch.equal(blobs[0][: expected.numel()], expected)
1172
1173 def test_a_sharer_blocks_until_its_peer_arrives(self, sharing_server):
1174 """Without the wait the packer could repack a slot a peer had not read."""
1175 server = sharing_server(workers_per_replica=2, stall_timeout_s=30.0)
1176 server.begin_sync(4, [0, 1, 2, 3])
1177 _publish(server, GI_A, GROUP_A)
1178 specs = [(GROUP_A[0], ())]
1179
1180 with ThreadPoolExecutor(max_workers=2) as ex:
1181 first = ex.submit(server.rdt_produce_weights_batched, specs, 0, 0)
1182 with pytest.raises(FuturesTimeout):
1183 first.result(timeout=0.5)
1184 second = ex.submit(server.rdt_produce_weights_batched, specs, 2, 0)
1185 assert (
1186 first.result(timeout=10)[0].data_ptr()
1187 == second.result(timeout=10)[0].data_ptr()
1188 )
1189
1190 def test_the_slot_follows_the_consumers_issue_order(self, sharing_server):
1191 """The invariant slot safety rests on. The consumer drains pull i before
1192 issuing i+K, so choosing the slot as ``seq % K`` means a slot is only
1193 repacked once the read of its previous contents is over. Deriving it from
1194 a per-call counter on the producer instead (execution order) can hand a
1195 slot to a pull whose predecessor is still being read, because Ray may
1196 start a consumer's concurrent produce calls in any order.
1197 """
1198 server = sharing_server(workers_per_replica=2, num_rdt_buffers=2)
1199 server.begin_sync(4, [0, 1, 2, 3])
1200 names = ("a.w", "b.w", "c.w")
1201 for gi, name in enumerate(names):
1202 _publish(server, gi, (name,))
1203
1204 # Arrive OUT of issue order: seq 1 first, then 0, then 2.
1205 with ThreadPoolExecutor(max_workers=2) as ex:
1206 for seq in (1, 0, 2):
1207 futures = [
1208 ex.submit(
1209 server.rdt_produce_weights_batched,
1210 [(names[seq], ())],
1211 c,
1212 seq,
1213 )
1214 for c in (0, 2)
1215 ]
1216 [f.result(timeout=10) for f in futures]
1217
1218 slots = {seq: gen.slot for seq, gen in server._gens[0].items()}
1219 assert slots == {0: 0, 1: 1, 2: 0}, slots
1220
1221 def test_a_seqless_call_is_refused(self, sharing_server):
1222 """The slot comes from ``seq``, so a caller that sends none cannot be
1223 served safely."""
1224 server = sharing_server(workers_per_replica=2)
1225 server.begin_sync(1, [0])
1226 _publish(server, GI_A, GROUP_A)
1227
1228 with pytest.raises(ValueError, match="requires the consumer's issue"):
1229 server.rdt_produce_weights_batched([(GROUP_A[0], ())], 0)
1230
1231 def test_a_failed_pack_reaches_every_sharer(self, sharing_server):
1232 """Only the packer touches the buffer, so its failure has to be
1233 republished: a waiter must raise rather than read a half-written slot."""
1234 server = sharing_server(workers_per_replica=2)
1235 server.begin_sync(4, [0, 1, 2, 3])
1236 _publish(server, GI_A, GROUP_A)
1237 specs = [(GROUP_A[0], (("mutate_", (), ()),))] # not in ALLOWED_OPS
1238
1239 raised = []
1240 with ThreadPoolExecutor(max_workers=2) as ex:
1241 futures = [
1242 ex.submit(server.rdt_produce_weights_batched, specs, c, 0)
1243 for c in (0, 2)
1244 ]
1245 for future in futures:
1246 with pytest.raises((ValueError, RuntimeError)) as excinfo:
1247 future.result(timeout=10)
1248 raised.append(excinfo.value)
1249
1250 assert any(
1251 isinstance(e, ValueError) and "disallowed op" in str(e) for e in raised
1252 )
1253 assert any("packing this chunk failed" in str(e) for e in raised)
1254
1255 def test_a_degraded_sync_narrows_the_rendezvous(self, sharing_server):
1256 """A dead deployment must not be waited for: a survivor left alone in
1257 its group packs for itself instead of blocking on the corpse."""
1258 server = sharing_server(workers_per_replica=2, stall_timeout_s=30.0)
1259 server.begin_sync(3, [0, 1, 3]) # consumer 2 died
1260 _publish(server, GI_A, GROUP_A)
1261
1262 blob = server.rdt_produce_weights_batched([(GROUP_A[0], ())], 0, 0)[0]
1263
1264 expected = _blob_bytes(server, GROUP_A[0])
1265 assert torch.equal(blob[: expected.numel()], expected)
1266 assert server._sharers_of(0) == frozenset({0})
1267 assert server._sharers_of(1) == frozenset({1, 3})
1268
1269 def test_a_sharer_that_never_arrives_trips_the_watchdog(self, sharing_server):
1270 """A consumer that dies INSIDE the sync window has no detector, exactly
1271 as for the free barrier; the rendezvous waits on the same channel."""
1272 server = sharing_server(workers_per_replica=2, stall_timeout_s=0.3)
1273 server.begin_sync(4, [0, 1, 2, 3])
1274 _publish(server, GI_A, GROUP_A)
1275
1276 with pytest.raises(RuntimeError, match="gather errored|RDT stall"):
1277 server.rdt_produce_weights_batched([(GROUP_A[0], ())], 0, 0)
1278 assert "RDT stall" in str(server._gather_error)
1279
1280
1281 class TestSharedReservation:
1282 """``reserve_serve_buffer`` under sharing: one ring per GROUP, and the
1283 init-time check that the group's members pull the same chunks."""
1284
1285 def test_matching_plans_reserve_one_ring_between_them(self, sharing_server):
1286 server = sharing_server(workers_per_replica=2)
1287 for consumer_id in (0, 2):
1288 server.reserve_serve_buffer(consumer_id, 1024, "plan-a")
1289
1290 assert list(server._serve_rings) == [0]
1291 assert [s.numel() for s in server._serve_rings[0]] == [
1292 buffer_alloc_bytes(1024)
1293 ] * 2
1294
1295 def test_sharing_off_reserves_a_ring_per_consumer(self, sharing_server):
1296 server = sharing_server(workers_per_replica=0)
1297 for consumer_id in (0, 2):
1298 server.reserve_serve_buffer(consumer_id, 1024, "plan-a")
1299
1300 assert sorted(server._serve_rings) == [0, 2]
1301
1302 def test_mismatched_plans_fail_at_reservation(self, sharing_server):
1303 """Sharing rests on the sharers pulling the same chunks. Checked here,
1304 where it names both consumers, rather than at serve time, where it is a
1305 rendezvous nobody completes."""
1306 server = sharing_server(workers_per_replica=2)
1307 server.reserve_serve_buffer(0, 1024, "plan-a")
1308
1309 with pytest.raises(RuntimeError, match="do not pull the same chunks"):
1310 server.reserve_serve_buffer(2, 1024, "plan-b")
1311
1312 def test_different_groups_may_differ(self, sharing_server):
1313 """Only the members of ONE group have to agree; worker 0 and worker 1 of
1314 the same deployment hold different slices and never share a slot."""
1315 server = sharing_server(workers_per_replica=2)
1316 server.reserve_serve_buffer(0, 1024, "plan-a")
1317 server.reserve_serve_buffer(1, 1024, "plan-b")
1318
1319 assert sorted(server._serve_rings) == [0, 1]
1320
1321 def test_a_missing_digest_skips_the_check(self, sharing_server):
1322 """The digest is optional on the wire, so an engine that does not send
1323 one still reserves."""
1324 server = sharing_server(workers_per_replica=2)
1325 server.reserve_serve_buffer(0, 1024, None)
1326 server.reserve_serve_buffer(2, 1024, None)
1327
1328 assert list(server._serve_rings) == [0]