[EC Connector] EC Offloading Connector use events instead of StepTracker (#49994)
Signed-off-by: omerpaz95 <omerpaz95@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
omerpaz95 committed
Aug 27, 2026 at 06:41 UTC
f8d38fbc879c3a223a633d4ec532bdde348570ec
15 files changed
+1049
-645
tests/v1/ec_connector/__init__.py
tests/v1/ec_connector/unit/cpu/scheduler/test_embedding_cache.py
+30
-1
@@ -21,13 +21,15 @@ def _cache(num_blocks: int = 8) -> EmbeddingCache:
21
# ── alloc ────────────────────────────────────────────────────────────────────
22
23
24
-def test_alloc_returns_unique_block_ids():
24
+def test_alloc_returns_unique_ascending_block_ids():
25
cache = _cache()
26
entry = cache.alloc("a", 3)
27
assert entry is not None
28
assert len(entry.block_ids) == 3
29
assert len(set(entry.block_ids)) == 3
30
assert all(0 <= i < 8 for i in entry.block_ids)
31
+ # Ascending order lets consumers detect which blocks are adjacent.
32
+ assert list(entry.block_ids) == sorted(entry.block_ids)
33
34
35
def test_alloc_returns_tuple():
@@ -161,6 +163,33 @@ def test_unpin_asserts_on_unpinned():
163
cache.unpin("a")
164
165
166
+# ── has_held_entries ─────────────────────────────────────────────────────────
167
+
168
+
169
+def test_has_held_entries_empty_cache():
170
+ cache = _cache()
171
+ assert cache.has_held_entries() is False
172
+
173
+
174
+def test_has_held_entries_true_while_not_ready():
175
+ cache = _cache()
176
+ cache.alloc("a", 2) # not ready → held
177
+ assert cache.has_held_entries() is True
178
+ cache.mark_ready("a") # ready + unpinned → settled
179
+ assert cache.has_held_entries() is False
180
+
181
+
182
+def test_has_held_entries_true_while_pinned():
183
+ cache = _cache()
184
+ cache.alloc("a", 2)
185
+ cache.mark_ready("a")
186
+ assert cache.has_held_entries() is False
187
+ cache.pin("a") # pinned → held
188
+ assert cache.has_held_entries() is True
189
+ cache.unpin("a")
190
+ assert cache.has_held_entries() is False
191
+
192
+
193
# ── eviction ─────────────────────────────────────────────────────────────────
194
195
tests/v1/ec_connector/unit/cpu/scheduler/test_scheduler.py
+160
-178
@@ -3,6 +3,11 @@
3
import uuid
4
5
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
6
+from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
7
+from vllm.config.ec_transfer import ECRole
8
+from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
9
+ ECCPUWorkerMetadata,
10
+)
11
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
12
ECSharedRegion,
13
)
@@ -37,13 +42,26 @@ class _Request:
42
self.request_id = request_id
43
44
45
+class _WorkerOutput:
46
+ """Stand-in for ECConnectorOutput carrying a completion report.
47
+
48
+ `saves` are mm_hashes; `loads` are transfer ids. One entry per reporting
49
+ rank, so a list holding the same id twice is two ranks reporting it.
50
+ """
51
+
52
+ def __init__(self, *, saves=None, loads=None):
53
+ self.ec_connector_worker_meta = ECCPUWorkerMetadata(
54
+ completed_saves=list(saves or []),
55
+ completed_loads=list(loads or []),
56
+ )
57
+
58
+
59
def _make_scheduler(
60
monkeypatch,
61
num_blocks=_N,
43
- max_concurrent_batches=1,
62
*,
45
- is_producer=True,
46
- is_consumer=True,
63
+ ec_role: ECRole = "ec_both",
64
+ tensor_parallel_size=1,
65
) -> ECCPUScheduler:
66
region = ECSharedRegion(
67
engine_id=str(uuid.uuid4()),
@@ -52,27 +70,22 @@ def _make_scheduler(
70
)
71
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
72
55
- _is_prod = is_producer
56
- _is_cons = is_consumer
57
- _mcb = max_concurrent_batches
58
-
59
- class _EC:
60
- is_ec_producer = _is_prod
61
- is_ec_consumer = _is_cons
62
-
63
- class _Cfg:
64
- ec_transfer_config = _EC()
73
+ return ECCPUScheduler(
74
+ create_ec_vllm_config(
75
+ ec_role=ec_role,
76
+ tensor_parallel_size=tensor_parallel_size,
77
+ )
78
+ )
79
66
- @property
67
- def max_concurrent_batches(self):
68
- return _mcb
80
70
- return ECCPUScheduler(_Cfg())
81
+def _load_ids(meta) -> list[int]:
82
+ """Transfer ids the scheduler dispatched in this step's metadata."""
83
+ return [transfer_id for transfer_id, _ in meta.loads.values()]
84
85
73
-class _SchedulerOutput:
74
- def __init__(self, finished_req_ids=None):
75
- self.finished_req_ids = finished_req_ids or set()
86
+def _load_blocks(meta, mm_hash: str) -> list[int]:
87
+ """Block ids the scheduler dispatched for one load."""
88
+ return meta.loads[mm_hash][1]
89
90
91
def _seed_cached(s: ECCPUScheduler, mm_hash: str, n_blocks: int):
@@ -83,7 +96,9 @@ def _seed_cached(s: ECCPUScheduler, mm_hash: str, n_blocks: int):
96
97
def test_offload_reuse_cycle(monkeypatch):
98
s = _make_scheduler(monkeypatch)
86
- req = _Request([_Feature("h1", length=1)])
99
+ # Two blocks, so the reload also pins down that every allocated block id
100
+ # comes back in meta.loads, not just the first.
101
+ req = _Request([_Feature("h1", length=2)])
102
103
# Step A: first sight — allocate and save.
104
assert s.has_cache_item("h1") is False
@@ -92,24 +107,24 @@ def test_offload_reuse_cycle(monkeypatch):
107
meta_a = s.build_connector_meta(scheduler_output=None)
108
assert "h1" in meta_a.saves
109
assert meta_a.loads == {}
95
- # Not ready yet (max_concurrent_batches=1 means ready next step).
110
+ # Not ready until the worker reports the save memcpy complete.
111
assert s.has_cache_item("h1") is False
112
98
- # Step B: _step_readiness marks h1 ready (no new allocs).
99
- s.build_connector_meta(scheduler_output=None)
113
+ # Worker reports the save copy finished → entry becomes ready.
114
+ s.update_connector_output(_WorkerOutput(saves=["h1"]))
115
assert s.has_cache_item("h1") is True
116
117
# Step C: reload now works — entry is ready at update_state_after_alloc time.
118
s.update_state_after_alloc(req, 0)
119
meta_c = s.build_connector_meta(scheduler_output=None)
120
assert "h1" in meta_c.loads
106
- assert meta_c.loads["h1"] == meta_a.saves["h1"]
121
+ assert _load_blocks(meta_c, "h1") == meta_a.saves["h1"]
122
123
s.shutdown()
124
125
126
def test_has_cache_item_false_when_not_consumer(monkeypatch):
112
- s = _make_scheduler(monkeypatch, is_consumer=False)
127
+ s = _make_scheduler(monkeypatch, ec_role="ec_producer")
128
assert s.has_cache_item("anything") is False
129
s.shutdown()
130
@@ -123,10 +138,10 @@ def test_connector_keys_on_identifier_not_mm_hash(monkeypatch):
138
meta = s.build_connector_meta(scheduler_output=None)
139
assert "ENC_KEY" in meta.saves
140
assert "PROC_KEY" not in meta.saves
126
- # Not ready yet (1 step delay).
141
+ # Not ready until the completion report.
142
assert s.has_cache_item("ENC_KEY") is False
128
- # After another step it becomes ready — keyed on identifier.
129
- s.build_connector_meta(scheduler_output=None)
143
+ # After the report it becomes ready — keyed on identifier.
144
+ s.update_connector_output(_WorkerOutput(saves=["ENC_KEY"]))
145
assert s.has_cache_item("ENC_KEY") is True
146
assert s.has_cache_item("PROC_KEY") is False
147
s.shutdown()
@@ -136,13 +151,11 @@ def test_cpu_region_fifo_eviction(monkeypatch):
151
# Region holds exactly 2 one-block encodings.
152
s = _make_scheduler(monkeypatch, num_blocks=2)
153
139
- # Fill with A and B, marking each ready.
154
+ # Fill with A and B, marking each ready via a completion report.
155
for h in ("A", "B"):
156
s.update_state_after_alloc(_Request([_Feature(h, length=1)]), 0)
157
s.build_connector_meta(scheduler_output=None)
143
- # After the step above, A's step was called in B's build. A is ready.
144
- # B was submitted in the last build, so one more step makes it ready.
145
- s.build_connector_meta(scheduler_output=None) # marks B ready
158
+ s.update_connector_output(_WorkerOutput(saves=[h]))
159
assert s.has_cache_item("A") is True
160
assert s.has_cache_item("B") is True
161
@@ -161,8 +174,8 @@ def test_multiple_mm_items_per_request(monkeypatch):
174
meta = s.build_connector_meta(scheduler_output=None)
175
assert set(meta.saves) == {"h1", "h2"}
176
164
- # Step delay: mark both ready.
165
- s.build_connector_meta(scheduler_output=None)
177
+ # Worker reports both saves complete.
178
+ s.update_connector_output(_WorkerOutput(saves=["h1", "h2"]))
179
assert s.has_cache_item("h1") is True
180
assert s.has_cache_item("h2") is True
181
@@ -174,26 +187,6 @@ def test_multiple_mm_items_per_request(monkeypatch):
187
s.shutdown()
188
189
177
-def test_load_returns_correct_block_ids(monkeypatch):
178
- """meta.loads must contain the same block IDs that meta.saves
179
- allocated — verified through the public API only."""
180
- s = _make_scheduler(monkeypatch)
181
- req = _Request([_Feature("a", length=2)])
182
-
183
- s.update_state_after_alloc(req, 0)
184
- meta_save = s.build_connector_meta(scheduler_output=None)
185
- assert "a" in meta_save.saves
186
-
187
- # Readiness delay.
188
- s.build_connector_meta(scheduler_output=None)
189
-
190
- # Reload.
191
- s.update_state_after_alloc(req, 0)
192
- meta_load = s.build_connector_meta(scheduler_output=None)
193
- assert meta_load.loads["a"] == meta_save.saves["a"]
194
- s.shutdown()
195
-
196
-
190
def test_loads_only_serves_hashes_touched_this_step(monkeypatch):
191
s = _make_scheduler(monkeypatch)
192
_seed_cached(s, "a", n_blocks=2)
@@ -228,10 +221,10 @@ def test_load_not_emitted_for_uncached_entry(monkeypatch):
221
s.shutdown()
222
223
231
-def test_delayed_unpin_protects_blocks_during_worker_read(monkeypatch):
232
- """Blocks being loaded must not be evictable until the delayed unpin
233
- fires. Observable: a save that needs eviction cannot reclaim a
234
- still-pinned loaded entry."""
224
+def test_load_pin_protects_blocks_until_completion(monkeypatch):
225
+ """Loaded blocks stay pinned until the worker reports the load memcpy
226
+ complete. Observable: a save that needs eviction cannot reclaim a
227
+ still-pinned loaded entry, but can once the completion report arrives."""
228
# 2 blocks, both occupied by "a" (ready).
229
s = _make_scheduler(monkeypatch, num_blocks=2)
230
_seed_cached(s, "a", n_blocks=2)
@@ -240,94 +233,79 @@ def test_delayed_unpin_protects_blocks_during_worker_read(monkeypatch):
233
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
234
meta = s.build_connector_meta(scheduler_output=None)
235
assert "a" in meta.loads
236
+ (transfer_id,) = _load_ids(meta)
237
238
# Try to save "b" (needs 2 blocks, but "a" is pinned) — must fail.
239
s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
240
meta2 = s.build_connector_meta(scheduler_output=None)
241
assert "b" not in meta2.saves # can't evict pinned "a"
242
249
- # After the delay, "a" unpins. Now "b" can evict it.
243
+ # The one participant reports the load complete → "a" unpins. Now "b" can
244
+ # evict it.
245
+ s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
246
s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
247
meta3 = s.build_connector_meta(scheduler_output=None)
248
assert "b" in meta3.saves
249
s.shutdown()
250
251
256
-def test_delayed_unpin_depth_2(monkeypatch):
257
- """With max_concurrent_batches=2, loaded blocks stay pinned for 2 steps
258
- after build. Observable via eviction failure."""
259
- s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=2)
260
- _seed_cached(s, "a", n_blocks=2)
261
-
262
- # Load "a" → pinned.
263
- s.update_state_after_alloc(_Request([_Feature("a")]), 0)
264
- s.build_connector_meta(scheduler_output=None)
265
-
266
- # Step 1 after load: still pinned — can't evict for "b".
267
- s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
268
- meta1 = s.build_connector_meta(scheduler_output=None)
269
- assert "b" not in meta1.saves
270
-
271
- # Step 2 after load: still pinned.
272
- s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
273
- meta2 = s.build_connector_meta(scheduler_output=None)
274
- assert "b" not in meta2.saves
252
+def _can_evict(s: ECCPUScheduler, mm_hash: str, n_blocks: int) -> bool:
253
+ """Whether a new save of `mm_hash` can claim `n_blocks`, i.e. whether the
254
+ blocks currently held by other entries are reclaimable."""
255
+ s.update_state_after_alloc(_Request([_Feature(mm_hash, length=n_blocks)]), 0)
256
+ meta = s.build_connector_meta(scheduler_output=None)
257
+ return mm_hash in meta.saves
258
276
- # Step 3: unpin fires, now eviction works.
277
- s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
278
- meta3 = s.build_connector_meta(scheduler_output=None)
279
- assert "b" in meta3.saves
280
- s.shutdown()
259
260
+def test_load_needs_every_participant_before_unpin(monkeypatch):
261
+ """With two participating ranks, one report must not release the pin.
262
283
-def test_shutdown_unpins_pending_loads(monkeypatch):
284
- """Shutdown must unpin entries still in _pending_loads (not yet drained
285
- to the unpin deque). Observable: after shutdown, the entry is evictable
286
- (alloc can reclaim its blocks)."""
287
- s = _make_scheduler(monkeypatch, num_blocks=2)
263
+ This is the multi-rank case: each rank copies the same CPU blocks into its
264
+ own GPU memory, so the blocks stay in use until the last rank is done.
265
+ """
266
+ s = _make_scheduler(monkeypatch, num_blocks=2, tensor_parallel_size=2)
267
_seed_cached(s, "a", n_blocks=2)
268
269
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
291
- # Don't call build_connector_meta — "a" stays in _pending_loads.
270
+ (transfer_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
271
+
272
+ # First rank reports: still in use by the second, so "a" must hold.
273
+ s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
274
+ assert _can_evict(s, "b", 2) is False
275
+ assert s.has_pending_push_work() is True
276
+
277
+ # Second rank reports: now it is genuinely free.
278
+ s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
279
+ assert s.has_pending_push_work() is False
280
+ assert _can_evict(s, "c", 2) is True
281
s.shutdown()
282
294
- # After shutdown, scheduler reports no items (consumer role is off).
295
- assert s.has_cache_item("a") is False
283
284
+def test_late_report_does_not_release_a_later_load_of_same_hash(monkeypatch):
285
+ """A straggling report from one dispatch must not release the pin taken by
286
+ a subsequent dispatch of the same mm_hash.
287
298
-def test_shutdown_unpins_deferred_unpin_queue(monkeypatch):
299
- """Shutdown must unpin entries queued in the deferred unpin deque
300
- (already drained from _pending_loads but not yet expired)."""
301
- s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=2)
288
+ This is what the transfer id is for: mm_hash identifies a cache entry, not
289
+ a transfer, so it cannot tell the two dispatches apart.
290
+ """
291
+ s = _make_scheduler(monkeypatch, num_blocks=2, tensor_parallel_size=2)
292
_seed_cached(s, "a", n_blocks=2)
293
304
- # Load "a" and build — moves to unpin deque.
294
+ # First dispatch, fully reported by both ranks → pin released.
295
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
306
- s.build_connector_meta(scheduler_output=None)
307
-
308
- # "a" is in the deferred unpin deque (depth=2, not yet expired).
309
- # Shutdown must unpin it.
310
- s.shutdown()
311
- assert s.has_cache_item("a") is False
296
+ (first_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
297
+ s.update_connector_output(_WorkerOutput(loads=[first_id, first_id]))
298
+ assert s.has_pending_push_work() is False
299
300
+ # Second dispatch of the same hash takes a fresh pin.
301
+ s.update_state_after_alloc(_Request([_Feature("a")]), 0)
302
+ (second_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
303
+ assert second_id != first_id
304
314
-def test_eviction_skips_entry_pinned_by_pending_load(monkeypatch):
315
- """A loaded entry still within the unpin delay window must survive
316
- eviction attempts from new saves."""
317
- # 2 blocks: A occupies both, ready.
318
- s = _make_scheduler(monkeypatch, num_blocks=2)
319
- _seed_cached(s, "A", n_blocks=2)
320
-
321
- # Load A → pinned.
322
- s.update_state_after_alloc(_Request([_Feature("A")]), 0)
323
- meta_load = s.build_connector_meta(scheduler_output=None)
324
- assert "A" in meta_load.loads
325
-
326
- # Try to save C (needs 2 blocks) — can't evict pinned A.
327
- s.update_state_after_alloc(_Request([_Feature("C", length=2)]), 0)
328
- meta_save = s.build_connector_meta(scheduler_output=None)
329
- assert "C" not in meta_save.saves
330
- assert s.has_cache_item("A") is True # survived
305
+ # A replayed report for the first dispatch must be ignored entirely.
306
+ s.update_connector_output(_WorkerOutput(loads=[first_id, first_id]))
307
+ assert s.has_pending_push_work() is True
308
+ assert _can_evict(s, "b", 2) is False
309
s.shutdown()
310
311
@@ -350,30 +328,10 @@ def test_region_full_skips_save_and_never_blocks(monkeypatch):
328
s.shutdown()
329
330
353
-def test_step_readiness_depth_2(monkeypatch):
354
- """With max_concurrent_batches=2, entries need 2 steps to become ready."""
355
- s = _make_scheduler(monkeypatch, max_concurrent_batches=2)
356
- req = _Request([_Feature("h1", length=1)])
357
-
358
- s.update_state_after_alloc(req, 0)
359
- meta_0 = s.build_connector_meta(scheduler_output=None)
360
- assert "h1" in meta_0.saves
361
- assert s.has_cache_item("h1") is False
362
-
363
- # One step later: still not ready (depth=2).
364
- s.build_connector_meta(scheduler_output=None)
365
- assert s.has_cache_item("h1") is False
366
-
367
- # Two steps later: now ready.
368
- s.build_connector_meta(scheduler_output=None)
369
- assert s.has_cache_item("h1") is True
370
- s.shutdown()
371
-
372
-
331
def test_producer_only_never_emits_loads(monkeypatch):
332
"""A producer-only scheduler must never populate meta.loads, even when
333
entries are ready."""
376
- s = _make_scheduler(monkeypatch, is_consumer=False)
334
+ s = _make_scheduler(monkeypatch, ec_role="ec_producer")
335
req = _Request([_Feature("h1", length=1)])
336
337
s.update_state_after_alloc(req, 0)
@@ -381,8 +339,7 @@ def test_producer_only_never_emits_loads(monkeypatch):
339
assert "h1" in meta.saves
340
assert meta.loads == {}
341
384
- # Mark ready.
385
- s.build_connector_meta(scheduler_output=None)
342
+ s.update_connector_output(_WorkerOutput(saves=["h1"]))
343
344
# Even if we re-encounter the same feature, no load.
345
s.update_state_after_alloc(req, 0)
@@ -393,7 +350,7 @@ def test_producer_only_never_emits_loads(monkeypatch):
350
351
def test_consumer_only_never_emits_saves(monkeypatch):
352
"""A consumer-only scheduler must never populate meta.saves."""
396
- s = _make_scheduler(monkeypatch, is_producer=False)
353
+ s = _make_scheduler(monkeypatch, ec_role="ec_consumer")
354
_seed_cached(s, "a", n_blocks=1)
355
356
req = _Request([_Feature("a", length=1)])
@@ -406,7 +363,7 @@ def test_consumer_only_never_emits_saves(monkeypatch):
363
364
def test_consumer_only_has_cache_item(monkeypatch):
365
"""Consumer-only: has_cache_item works normally for ready entries."""
409
- s = _make_scheduler(monkeypatch, is_producer=False)
366
+ s = _make_scheduler(monkeypatch, ec_role="ec_consumer")
367
assert s.has_cache_item("a") is False
368
_seed_cached(s, "a", n_blocks=1)
369
assert s.has_cache_item("a") is True
@@ -431,50 +388,75 @@ def test_save_not_emitted_for_already_cached_entry(monkeypatch):
388
s.shutdown()
389
390
434
-# ── first-finish fast-path (idle engine) ────────────────────────────────────
391
+# ── completion reporting (update_connector_output) ──────────────────────────
392
393
437
-def test_first_finish_marks_ready_immediately(monkeypatch):
438
- """When the originating request finishes, the save entry becomes ready
439
- without waiting for the step-count delay."""
440
- s = _make_scheduler(monkeypatch, max_concurrent_batches=5)
441
- req = _Request([_Feature("h1", length=1)], request_id="req_x")
394
+def test_update_connector_output_ignores_foreign_meta(monkeypatch):
395
+ """A worker output whose payload is not an ECCPUWorkerMetadata is a
396
+ no-op — no cache mutation, no crash."""
397
+ s = _make_scheduler(monkeypatch)
398
+ _seed_cached(s, "a", n_blocks=1)
399
443
- s.update_state_after_alloc(req, 0)
400
+ class _Foreign:
401
+ ec_connector_worker_meta = object()
402
+
403
+ s.update_connector_output(_Foreign()) # must not raise
404
+ assert s.has_cache_item("a") is True
405
+ s.shutdown()
406
+
407
+
408
+def test_update_connector_output_ignores_unknown_report(monkeypatch):
409
+ """Reports for a never-seen save hash or a transfer id this scheduler never
410
+ dispatched are dropped rather than mutating unrelated state."""
411
+ s = _make_scheduler(monkeypatch)
412
+ s.update_connector_output(_WorkerOutput(saves=["gone"], loads=[4242]))
413
+ assert s.has_cache_item("gone") is False
414
+ s.shutdown()
415
+
416
+
417
+# ── has_pending_push_work ───────────────────────────────────────────────────
418
+
419
+
420
+def test_has_pending_push_work_tracks_inflight_save(monkeypatch):
421
+ """A dispatched-but-unconfirmed save keeps push work pending until the
422
+ completion report marks it ready."""
423
+ s = _make_scheduler(monkeypatch)
424
+ assert s.has_pending_push_work() is False
425
+
426
+ s.update_state_after_alloc(_Request([_Feature("h1", length=1)]), 0)
427
s.build_connector_meta(scheduler_output=None)
445
- assert s.has_cache_item("h1") is False
428
+ assert s.has_pending_push_work() is True # save not yet confirmed
429
447
- # Request finishes — readiness should fire immediately.
448
- s.build_connector_meta(_SchedulerOutput(finished_req_ids={"req_x"}))
449
- assert s.has_cache_item("h1") is True
430
+ s.update_connector_output(_WorkerOutput(saves=["h1"]))
431
+ assert s.has_pending_push_work() is False
432
s.shutdown()
433
434
453
-def test_first_finish_unpins_immediately(monkeypatch):
454
- """When the originating request finishes, the load entry is unpinned
455
- without waiting for the step-count delay."""
456
- s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=5)
457
- _seed_cached(s, "a", n_blocks=2)
435
+def test_has_pending_push_work_tracks_inflight_load(monkeypatch):
436
+ """A dispatched-but-unconfirmed load keeps push work pending until the
437
+ unpin completion report arrives."""
438
+ s = _make_scheduler(monkeypatch)
439
+ _seed_cached(s, "a", n_blocks=1)
440
+ assert s.has_pending_push_work() is False # ready + unpinned
441
459
- req = _Request([_Feature("a")], request_id="req_y")
460
- s.update_state_after_alloc(req, 0)
442
+ s.update_state_after_alloc(_Request([_Feature("a")]), 0)
443
meta = s.build_connector_meta(scheduler_output=None)
462
- assert "a" in meta.loads
444
+ assert s.has_pending_push_work() is True # pinned, awaiting unpin
445
464
- # "a" is pinned. Try to evict — must fail.
465
- s.update_state_after_alloc(
466
- _Request([_Feature("b", length=2)], request_id="req_z"), 0
467
- )
468
- meta2 = s.build_connector_meta(scheduler_output=None)
469
- assert "b" not in meta2.saves
446
+ s.update_connector_output(_WorkerOutput(loads=_load_ids(meta)))
447
+ assert s.has_pending_push_work() is False
448
+ s.shutdown()
449
471
- # req_y finishes — unpin fires in build_connector_meta (fast-path).
472
- s.build_connector_meta(_SchedulerOutput(finished_req_ids={"req_y"}))
450
474
- # Next step: "a" is now evictable, so "b" can be allocated.
475
- s.update_state_after_alloc(
476
- _Request([_Feature("b", length=2)], request_id="req_w"), 0
477
- )
478
- meta3 = s.build_connector_meta(scheduler_output=None)
479
- assert "b" in meta3.saves
451
+# ── shutdown ────────────────────────────────────────────────────────────────
452
+
453
+
454
+def test_shutdown_disables_roles_and_cleans_region(monkeypatch):
455
+ """After shutdown the scheduler serves no items and the region is
456
+ cleaned up."""
457
+ s = _make_scheduler(monkeypatch, num_blocks=2)
458
+ _seed_cached(s, "a", n_blocks=2)
459
+ assert s.has_cache_item("a") is True
460
+
461
s.shutdown()
462
+ assert s.has_cache_item("a") is False # consumer role disabled
tests/v1/ec_connector/unit/cpu/scheduler/test_step_tracker.py
deleted
-162
@@ -1,162 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Tests for StepTracker."""
4
-
5
-from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.step_tracker import (
6
- StepTracker,
7
-)
8
-
9
-
10
-def test_basic_step_count_expiry_depth_1():
11
- """With max_concurrent_batches=1, entry expires after 1 step."""
12
- t = StepTracker(max_concurrent_batches=1)
13
- t.add("h1", "req_a")
14
-
15
- # Step 1: entry was added, committed to slot. Deque fills to maxlen=1,
16
- # but no expiry yet (the entry is in the slot just committed).
17
- result = t.step(set())
18
- assert result == []
19
-
20
- # Step 2: deque is full, oldest slot (containing h1) expires.
21
- result = t.step(set())
22
- assert result == ["h1"]
23
-
24
-
25
-def test_basic_step_count_expiry_depth_2():
26
- """With max_concurrent_batches=2, entry expires after 2 steps."""
27
- t = StepTracker(max_concurrent_batches=2)
28
- t.add("h1", "req_a")
29
-
30
- assert t.step(set()) == [] # step 1: committed
31
- assert t.step(set()) == [] # step 2: deque fills but h1 in newer slot
32
- assert t.step(set()) == ["h1"] # step 3: h1's slot is oldest, expires
33
-
34
-
35
-def test_first_finish_fast_path():
36
- """Entry is returned immediately when request_id appears in finished."""
37
- t = StepTracker(max_concurrent_batches=3)
38
- t.add("h1", "req_a")
39
-
40
- result = t.step({"req_a"})
41
- assert result == ["h1"]
42
-
43
-
44
-def test_no_double_processing_fast_path_then_expiry():
45
- """Entry fast-pathed should NOT fire again on deque expiry."""
46
- t = StepTracker(max_concurrent_batches=1)
47
- t.add("h1", "req_a")
48
-
49
- # Fast-path fires h1.
50
- result = t.step({"req_a"})
51
- assert result == ["h1"]
52
-
53
- # Normal expiry would fire now, but h1 is already processed.
54
- result = t.step(set())
55
- assert result == []
56
-
57
-
58
-def test_no_double_processing_expiry_then_fast_path():
59
- """Entry expired via step-count should NOT fire again via fast-path."""
60
- t = StepTracker(max_concurrent_batches=1)
61
- t.add("h1", "req_a")
62
-
63
- t.step(set()) # commit
64
- result = t.step(set()) # expiry fires h1
65
- assert result == ["h1"]
66
-
67
- # req_a finishes later — must not re-fire.
68
- result = t.step({"req_a"})
69
- assert result == []
70
-
71
-
72
-def test_multiplicity_same_hash_different_requests():
73
- """Two adds of the same mm_hash (different requests) → returned twice."""
74
- t = StepTracker(max_concurrent_batches=3)
75
- t.add("h1", "req_a")
76
- t.add("h1", "req_b")
77
-
78
- # Both requests finish in the same step.
79
- result = t.step({"req_a", "req_b"})
80
- assert sorted(result) == ["h1", "h1"]
81
-
82
-
83
-def test_mixed_expiry_and_fast_path():
84
- """Some entries expire, others fast-path, in the same step."""
85
- t = StepTracker(max_concurrent_batches=1)
86
- t.add("h1", "req_a")
87
-
88
- t.step(set()) # commit h1's slot
89
-
90
- # Add h2 in the next step.
91
- t.add("h2", "req_b")
92
-
93
- # This step: h1's slot expires AND req_b finishes.
94
- result = t.step({"req_b"})
95
- assert set(result) == {"h1", "h2"}
96
-
97
-
98
-def test_drain_all_returns_unprocessed():
99
- """drain_all returns everything pending and clears state."""
100
- t = StepTracker(max_concurrent_batches=3)
101
- t.add("h1", "req_a")
102
- t.add("h2", "req_b")
103
- t.step(set()) # commit both
104
- t.add("h3", "req_c") # in _current, not yet committed
105
-
106
- result = t.drain_all()
107
- assert set(result) == {"h1", "h2", "h3"}
108
-
109
- # After drain, everything is empty.
110
- assert t.step(set()) == []
111
- assert t.drain_all() == []
112
-
113
-
114
-def test_drain_all_skips_already_processed():
115
- """drain_all should not re-return entries already fast-pathed."""
116
- t = StepTracker(max_concurrent_batches=3)
117
- t.add("h1", "req_a")
118
- t.add("h2", "req_b")
119
-
120
- # Fast-path h1.
121
- result = t.step({"req_a"})
122
- assert result == ["h1"]
123
-
124
- # drain should only return h2.
125
- result = t.drain_all()
126
- assert result == ["h2"]
127
-
128
-
129
-def test_empty_step_returns_nothing():
130
- """step() with nothing pending returns empty list."""
131
- t = StepTracker(max_concurrent_batches=2)
132
- assert t.step(set()) == []
133
- assert t.step({"req_x"}) == []
134
-
135
-
136
-def test_add_after_step_goes_to_next_slot():
137
- """Entries added after a step go into the next slot, not the current."""
138
- t = StepTracker(max_concurrent_batches=1)
139
-
140
- t.step(set()) # empty step
141
- t.add("h1", "req_a")
142
-
143
- # h1 is in _current. Next step commits it.
144
- t.step(set())
145
-
146
- # Now the deque is full (1 slot with h1). Next step expires it.
147
- result = t.step(set())
148
- assert result == ["h1"]
149
-
150
-
151
-def test_fast_path_on_current_entries():
152
- """Entries still in _current (not yet committed) can be fast-pathed."""
153
- t = StepTracker(max_concurrent_batches=3)
154
- t.add("h1", "req_a")
155
-
156
- # req_a finishes in the same step h1 was added.
157
- result = t.step({"req_a"})
158
- assert result == ["h1"]
159
-
160
- # Entry is committed to slot but marked processed — won't re-fire.
161
- for _ in range(5):
162
- assert t.step(set()) == []
tests/v1/ec_connector/unit/cpu/worker/test_worker.py
+342
-71
@@ -22,14 +22,16 @@ Mocking policy
22
23
import contextlib
24
import logging
25
+import time
26
import uuid
27
+from collections import deque
28
from unittest.mock import MagicMock, Mock, patch
29
30
+import numpy as np
31
import pytest
32
import torch
33
31
-from vllm.config import VllmConfig
32
-from vllm.config.parallel import ParallelConfig
34
+from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
35
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
36
ECCPUConnectorMetadata,
37
)
@@ -55,6 +57,18 @@ _requires_accelerator = pytest.mark.skipif(
57
reason="exercises real accelerator stream/event coordination in ECCPUWorker",
58
)
59
60
+# The memory-lifetime tests hold a copy back with `torch.cuda._sleep`, which the
61
+# CUDA-like platforms provide but XPU does not.
62
+_requires_cuda_alike = pytest.mark.skipif(
63
+ not current_platform.is_cuda_alike(),
64
+ reason="stalling the compute stream requires a CUDA-like platform",
65
+)
66
+
67
+# Cycles to stall the compute stream for. Must comfortably outlast the host-side
68
+# work the lifetime tests do while a copy is deliberately held back — ~0.5 s on
69
+# a current datacenter GPU.
70
+_STALL_CYCLES = 1_000_000_000
71
+
72
73
def _make_region() -> ECSharedRegion:
74
"""Fresh region backed by a real per-test mmap file."""
@@ -66,18 +80,28 @@ def _make_region() -> ECSharedRegion:
80
81
82
def _vllm_config(rank: int = 0) -> Mock:
69
- cfg = Mock(spec=VllmConfig)
70
- cfg.parallel_config = Mock(spec=ParallelConfig)
71
- cfg.parallel_config.rank = rank
72
- cfg.model_config = Mock()
73
- cfg.model_config.dtype = _DTYPE
74
- return cfg
83
+ return create_ec_vllm_config(rank=rank, dtype=_DTYPE)
84
85
86
def _meta(
87
*, saves: dict | None = None, loads: dict | None = None
88
) -> ECCPUConnectorMetadata:
80
- return ECCPUConnectorMetadata(saves=saves or {}, loads=loads or {})
89
+ """Build step metadata from plain `{mm_hash: block_ids}` dicts.
90
+
91
+ Load transfer ids are synthesized here so tests can keep naming loads by
92
+ mm_hash; `_load_id` recovers the id the worker will report.
93
+ """
94
+ return ECCPUConnectorMetadata(
95
+ saves=saves or {},
96
+ loads={
97
+ mm_hash: (idx, block_ids)
98
+ for idx, (mm_hash, block_ids) in enumerate((loads or {}).items())
99
+ },
100
+ )
101
+
102
+
103
+def _load_id(meta: ECCPUConnectorMetadata, mm_hash: str) -> int:
104
+ return meta.loads[mm_hash][0]
105
106
107
# ── fixtures ─────────────────────────────────────────────────────────────────
@@ -136,19 +160,52 @@ def make_worker():
160
region.cleanup()
161
162
163
+def _wait_for_completion(
164
+ worker: ECCPUWorker, expected, direction: str, timeout_s: float = 30.0
165
+) -> None:
166
+ """Poll ``build_connector_worker_meta`` until ``expected`` is reported done.
167
+
168
+ Saves are reported by mm_hash, loads by transfer id.
169
+ """
170
+ deadline = time.monotonic() + timeout_s
171
+ while time.monotonic() < deadline:
172
+ meta = worker.build_connector_worker_meta()
173
+ if meta is not None and expected in getattr(meta, f"completed_{direction}"):
174
+ return
175
+ time.sleep(0.001)
176
+ raise AssertionError(f"{direction} for {expected!r} never reported complete")
177
+
178
+
179
+def _warm_up_stream_pools(worker: ECCPUWorker) -> None:
180
+ """Run one throwaway save so the stream and event pools are populated.
181
+
182
+ The first CUDA stream creation in a process blocks the host for ~0.5 s on
183
+ driver initialization, which would swallow the stall the lifetime tests
184
+ below install. One flush puts a stream and its events in the worker's pools,
185
+ matching the steady state every step after the first sees.
186
+ """
187
+ scratch = torch.zeros(_HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
188
+ worker.save_caches({"w": scratch}, "w", _meta(saves={"w": [0]}))
189
+ worker.flush_saves()
190
+ _wait_for_completion(worker, "w", "saves")
191
+ assert worker._stream_pool, "warm-up did not recycle a stream"
192
+
193
+
194
# ── save_caches ──────────────────────────────────────────────────────────────
195
196
197
@_requires_accelerator
198
@pytest.mark.parametrize(
144
- "n_elements,n_blocks",
199
+ "n_elements,block_ids",
200
[
146
- (3 * _HIDDEN_DIM, 3), # exact fit: 3 full blocks, no remainder
147
- (3 * _HIDDEN_DIM + 4, 4), # partial last block: 4 fp16 trail in slot 3
201
+ (3 * _HIDDEN_DIM, [7, 2, 5]), # exact fit: 3 full blocks, no remainder
202
+ (3 * _HIDDEN_DIM + 4, [7, 2, 5, 0]), # partial last block: 4 fp16 trail
203
+ (3 * _HIDDEN_DIM, [2, 3, 4]), # adjacent: one coalesced copy
204
+ (3 * _HIDDEN_DIM + 4, [2, 3, 4, 6]), # coalesced run, then a gap
205
],
149
- ids=["exact-fit", "partial-last-block"],
206
+ ids=["exact-fit", "partial-last-block", "adjacent-blocks", "run-then-gap"],
207
)
151
-def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks):
208
+def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, block_ids):
209
"""``save_caches`` + ``flush_saves`` copies the source GPU tensor's bytes
210
into the block IDs named by ``meta.saves``, in the order they appear,
211
via a single batched GPU→CPU copy.
@@ -156,6 +213,8 @@ def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks
213
When ``total_bytes`` is not a multiple of ``block_size_bytes`` the last
214
block is partially written; the unwritten tail must remain whatever
215
was there before. Blocks not named in ``meta.saves`` must be untouched.
216
+ Blocks adjacent in the region share one descriptor, which must land the
217
+ same bytes in the same blocks as one descriptor per block does.
218
"""
219
worker = make_worker()
220
sentinel = 0x5A
@@ -165,9 +224,9 @@ def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks
224
expected_bytes = src.cpu().reshape(-1).view(torch.uint8)
225
total_bytes = n_elements * _DTYPE.itemsize
226
168
- block_ids = [7, 2, 5, 0][:n_blocks]
227
worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
228
worker.flush_saves()
229
+ _wait_for_completion(worker, "h", "saves")
230
231
for slot, block_idx in enumerate(block_ids):
232
block_byte_start = slot * _BLOCK_SIZE_BYTES
@@ -189,6 +248,42 @@ def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks
248
)
249
250
251
+@_requires_accelerator
252
+@pytest.mark.parametrize(
253
+ "n_elements,block_ids,expected_sizes",
254
+ [
255
+ (3 * _HIDDEN_DIM, [2, 3, 4], [3 * _BLOCK_SIZE_BYTES]),
256
+ (3 * _HIDDEN_DIM, [7, 2, 5], [_BLOCK_SIZE_BYTES] * 3),
257
+ (3 * _HIDDEN_DIM, [2, 3, 6], [2 * _BLOCK_SIZE_BYTES, _BLOCK_SIZE_BYTES]),
258
+ (2 * _HIDDEN_DIM + 4, [2, 3, 4], [(2 * _HIDDEN_DIM + 4) * _DTYPE.itemsize]),
259
+ (_HIDDEN_DIM, [2, 3, 4], [_BLOCK_SIZE_BYTES]),
260
+ ],
261
+ ids=[
262
+ "one-run",
263
+ "no-run",
264
+ "run-then-gap",
265
+ "run-stops-at-output",
266
+ "blocks-past-output-skipped",
267
+ ],
268
+)
269
+def test_save_caches_coalesces_only_adjacent_blocks(
270
+ make_worker, n_elements, block_ids, expected_sizes
271
+):
272
+ """One descriptor per run of region-adjacent blocks, not one per block.
273
+
274
+ Per-descriptor overhead dominates these copies, so the byte tests above
275
+ would pass either way; this pins the descriptor layout that makes them
276
+ fast. A coalesced copy also stops at the encoder output, since entries are
277
+ sized by placeholder count, which can exceed the number of embeddings.
278
+ """
279
+ worker = make_worker()
280
+ src = torch.arange(n_elements, dtype=_DTYPE, device=DEVICE_TYPE)
281
+
282
+ worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
283
+
284
+ assert worker._save_bufs.sizes[: worker._save_count].tolist() == expected_sizes
285
+
286
+
287
def test_save_caches_noop_when_mm_hash_not_in_saves(make_worker):
288
"""When the scheduler hasn't pre-allocated blocks for ``mm_hash``,
289
``save_caches`` + ``flush_saves`` is a pure no-op."""
@@ -245,6 +340,7 @@ def test_save_caches_batches_multiple_hashes(make_worker):
340
worker.save_caches(cache, "a", _meta(saves={"a": [1], "b": [3]}))
341
worker.save_caches(cache, "b", _meta(saves={"a": [1], "b": [3]}))
342
worker.flush_saves()
343
+ _wait_for_completion(worker, "a", "saves")
344
345
expected_a = src_a.cpu().view(torch.uint8)
346
expected_b = src_b.cpu().view(torch.uint8)
@@ -258,6 +354,50 @@ def test_save_caches_batches_multiple_hashes(make_worker):
354
assert torch.all(worker._region.blocks[idx] == sentinel)
355
356
357
+class _HighAddress:
358
+ """A device tensor reporting an address with the high bit set.
359
+
360
+ XPU USM allocations live up there and no host allocator will hand out such
361
+ an address, so naming one is the only way to reach the arithmetic. Every
362
+ attribute other than the address comes from the real tensor.
363
+ """
364
+
365
+ def __init__(self, real: torch.Tensor, address: int):
366
+ self._real = real
367
+ self._address = address
368
+
369
+ def __getattr__(self, name):
370
+ return getattr(self._real, name)
371
+
372
+ def view(self, *args):
373
+ return self
374
+
375
+ def data_ptr(self) -> int:
376
+ return self._address
377
+
378
+
379
+@_requires_accelerator
380
+def test_save_descriptors_hold_high_bit_source_addresses(make_worker):
381
+ """Source addresses >= 2**63 survive into the descriptor buffers."""
382
+ worker = make_worker()
383
+ real = torch.zeros(2 * _HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
384
+ address = 2**64 - 8 * _BLOCK_SIZE_BYTES
385
+
386
+ # Two runs, so the second descriptor's address is an offset off the base.
387
+ worker.save_caches(
388
+ {"a": _HighAddress(real, address)}, "a", _meta(saves={"a": [1, 5]})
389
+ )
390
+
391
+ assert worker._save_count == 2
392
+ stored = worker._save_bufs.src_np[:2].astype(np.uint64)
393
+ assert list(stored) == [address, address + _BLOCK_SIZE_BYTES]
394
+
395
+ # The batch describes an address nothing may read from; drop it unflushed.
396
+ worker._save_bufs = None
397
+ worker._save_count = 0
398
+ worker._save_mm_hashes = []
399
+
400
+
401
# ── start_load_caches ────────────────────────────────────────────────────────
402
403
@@ -288,19 +428,31 @@ def test_start_load_caches_copies_with_correct_shape_dtype_and_bytes(make_worker
428
429
430
@_requires_accelerator
291
-def test_start_load_caches_preserves_existing_encoder_cache_entry(make_worker):
292
- """If ``encoder_cache`` already holds the ``mm_hash``, the worker must
293
- not overwrite it."""
431
+@pytest.mark.parametrize(
432
+ "loads,expected_sizes",
433
+ [
434
+ ({"a": [2, 3], "b": [4, 5]}, [4 * _BLOCK_SIZE_BYTES]),
435
+ ({"b": [4, 5], "a": [2, 3]}, [4 * _BLOCK_SIZE_BYTES]),
436
+ ({"a": [2, 3], "b": [6, 7]}, [2 * _BLOCK_SIZE_BYTES] * 2),
437
+ ],
438
+ ids=["adjacent-entries", "adjacent-entries-reversed", "gap-between-entries"],
439
+)
440
+def test_start_load_caches_coalesces_across_entries(make_worker, loads, expected_sizes):
441
+ """Blocks adjacent in the region share a descriptor across entries too.
442
+
443
+ Entries are ordered by first block, so whether they coalesce follows from
444
+ the region layout rather than the order the scheduler dispatched them.
445
+ """
446
worker = make_worker()
295
- worker._region.blocks[0].fill_(0x42)
447
+ captured: list[list[int]] = []
448
297
- sentinel = torch.full((_HIDDEN_DIM,), 7.0, dtype=_DTYPE, device=DEVICE_TYPE)
298
- encoder_cache = {"h": sentinel}
299
- worker.start_load_caches(encoder_cache, _meta(loads={"h": [0]}))
449
+ with patch(
450
+ "vllm.distributed.ec_transfer.ec_connector.cpu.worker.swap_blocks_batch",
451
+ side_effect=lambda src, dst, sizes, **kw: captured.append(sizes.tolist()),
452
+ ):
453
+ worker.start_load_caches({}, _meta(loads=loads))
454
301
- assert encoder_cache["h"] is sentinel, (
302
- "existing encoder_cache entry must not be replaced"
303
- )
455
+ assert captured == [expected_sizes]
456
457
458
@_requires_accelerator
@@ -315,30 +467,34 @@ def test_start_load_caches_noop_when_loads_is_empty(make_worker):
467
468
@_requires_accelerator
469
def test_start_load_caches_skips_cached_and_loads_new_in_same_step(make_worker):
318
- """Cached entries are preserved while new ones are loaded."""
470
+ """Every hash in ``meta.loads`` is copied from mmap, including one whose
471
+ key is already resident in ``encoder_cache`` — there is no worker-side
472
+ skip."""
473
worker = make_worker()
320
- n_blocks = 2
474
+ n_blocks = 3
475
src_orig = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE).reshape(
476
n_blocks, _HIDDEN_DIM
477
)
478
src_int8 = src_orig.view(torch.int8).reshape(n_blocks, _BLOCK_SIZE_BYTES)
325
- new_block_ids = [4, 2]
326
- for i, idx in enumerate(new_block_ids):
479
+ block_ids = [0, 4, 2]
480
+ for i, idx in enumerate(block_ids):
481
worker._region.blocks[idx].copy_(src_int8[i])
482
483
cached_tensor = torch.full((1, _HIDDEN_DIM), 99.0, dtype=_DTYPE, device=DEVICE_TYPE)
484
encoder_cache: dict[str, torch.Tensor] = {"cached_h": cached_tensor}
485
worker.start_load_caches(
486
encoder_cache,
333
- _meta(loads={"cached_h": [0], "new_h": new_block_ids}),
487
+ _meta(loads={"resident_h": [block_ids[0]], "new_h": block_ids[1:]}),
488
)
489
336
- assert encoder_cache["cached_h"] is cached_tensor
337
- assert "new_h" in encoder_cache
338
- out = encoder_cache["new_h"]
339
- assert out.shape == (n_blocks, _HIDDEN_DIM)
340
- assert out.dtype == _DTYPE
341
- assert torch.equal(out.cpu(), src_orig)
490
+ resident = encoder_cache["resident_h"]
491
+ assert resident is not cached_tensor, "resident entry must be reloaded, not skipped"
492
+ assert torch.equal(resident.cpu(), src_orig[:1])
493
+
494
+ new = encoder_cache["new_h"]
495
+ assert new.shape == (2, _HIDDEN_DIM)
496
+ assert new.dtype == _DTYPE
497
+ assert torch.equal(new.cpu(), src_orig[1:])
498
499
500
@_requires_accelerator
@@ -382,6 +538,7 @@ def test_save_then_load_round_trips_bytes(make_worker):
538
).reshape(n_blocks, _HIDDEN_DIM)
539
worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
540
worker.flush_saves()
541
+ _wait_for_completion(worker, "h", "saves")
542
543
encoder_cache: dict[str, torch.Tensor] = {}
544
worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
@@ -392,18 +549,133 @@ def test_save_then_load_round_trips_bytes(make_worker):
549
assert torch.equal(out.cpu(), src.cpu())
550
551
552
+# ── memory lifetime across in-flight copies ─────────────────────────────────
553
+
554
+
555
+@_requires_cuda_alike
556
+def test_save_survives_encoder_cache_free_before_copy_runs(make_worker):
557
+ """The bytes handed to ``save_caches`` must reach the mmap intact even when
558
+ the caller drops its ``encoder_cache`` reference before the copy has run.
559
+
560
+ The model runner pops encoder cache entries on whatever step the scheduler
561
+ asks it to, with no dependency on the save copy having landed. Dropping the
562
+ last reference hands the GPU memory back to the caching allocator, which is
563
+ free to reuse it for the next same-sized allocation on the compute stream —
564
+ the copy reads that memory on a different stream, so the allocator has no
565
+ reason to hold it back.
566
+
567
+ The stall makes the hazard deterministic rather than timing-dependent: the
568
+ save copy is gated behind the compute stream, so it provably has not started
569
+ while the overwrite (issued on an ungated stream and waited on) completes.
570
+
571
+ The mmap must be pinned, as it is in production: a copy into pageable host
572
+ memory blocks the host until it lands, which would hide the hazard.
573
+ """
574
+ worker = make_worker(pin_memory_available=True)
575
+ _warm_up_stream_pools(worker)
576
+ worker._region.blocks.fill_(0x5A)
577
+
578
+ block_ids = [0, 1, 2]
579
+ saved = torch.full(
580
+ (len(block_ids), _HIDDEN_DIM), 1.0, dtype=_DTYPE, device=DEVICE_TYPE
581
+ )
582
+ expected = saved.cpu().reshape(-1).view(torch.uint8)
583
+
584
+ encoder_cache = {"h": saved}
585
+ worker.save_caches(encoder_cache, "h", _meta(saves={"h": block_ids}))
586
+
587
+ torch.cuda._sleep(_STALL_CYCLES)
588
+ worker.flush_saves()
589
+
590
+ saved_ptr = saved.data_ptr()
591
+ del saved
592
+ encoder_cache.clear()
593
+
594
+ # Same size on the same stream, so the allocator hands back the block just
595
+ # freed. Overwriting it from an ungated stream — and waiting for that write
596
+ # — orders the overwrite strictly before the still-stalled save copy.
597
+ overwrite = torch.empty(
598
+ (len(block_ids), _HIDDEN_DIM), dtype=_DTYPE, device=DEVICE_TYPE
599
+ )
600
+ reused = overwrite.data_ptr() == saved_ptr
601
+ other_stream = torch.cuda.Stream()
602
+ with torch.cuda.stream(other_stream):
603
+ overwrite.fill_(-1.0)
604
+ other_stream.synchronize()
605
+
606
+ _wait_for_completion(worker, "h", "saves")
607
+
608
+ actual = worker._region.blocks[block_ids].reshape(-1).view(torch.uint8)
609
+ assert torch.equal(actual, expected), (
610
+ "mmap holds bytes written after save_caches was called; the copy read "
611
+ f"freed source memory (allocator reused the freed block: {reused})"
612
+ )
613
+
614
+
615
+@_requires_cuda_alike
616
+def test_load_buffer_survives_eviction_while_consumer_read_is_queued(make_worker):
617
+ """A loaded encoder cache entry must keep its bytes for a consumer already
618
+ queued on the compute stream, even after the entry is dropped and another
619
+ load is dispatched.
620
+
621
+ ``start_load_caches`` allocates its destination inside the load stream's
622
+ context, which ties that memory to the load stream in the caching allocator.
623
+ Dropping the encoder cache entry returns it to the load stream's pool, where
624
+ the next load can claim it — while the model's read of it is still queued on
625
+ the compute stream.
626
+ """
627
+ worker = make_worker(pin_memory_available=True)
628
+ _warm_up_stream_pools(worker)
629
+ a_blocks, b_blocks = [4, 5], [6, 7]
630
+ for block_idx, fill in zip(a_blocks + b_blocks, (0x11, 0x12, 0x21, 0x22)):
631
+ worker._region.blocks[block_idx].fill_(fill)
632
+ expected_a = worker._region.blocks[a_blocks].reshape(-1).clone()
633
+
634
+ encoder_cache: dict[str, torch.Tensor] = {}
635
+ meta_a = _meta(loads={"a": a_blocks})
636
+ worker.start_load_caches(encoder_cache, meta_a)
637
+ loaded_a = encoder_cache["a"]
638
+
639
+ # Queue a consumer of the loaded entry on the compute stream behind a stall,
640
+ # the way the model reads the encoder cache later in the step.
641
+ torch.cuda._sleep(_STALL_CYCLES)
642
+ consumed = loaded_a.clone()
643
+
644
+ # Draining the completion report recycles the load stream into the pool, so
645
+ # the next load allocates from the same allocator pool the entry was freed to.
646
+ _wait_for_completion(worker, _load_id(meta_a, "a"), "loads")
647
+
648
+ loaded_a_ptr = loaded_a.data_ptr()
649
+ del loaded_a
650
+ encoder_cache.clear()
651
+
652
+ worker.start_load_caches(encoder_cache, _meta(loads={"b": b_blocks}))
653
+ reused = encoder_cache["b"].data_ptr() == loaded_a_ptr
654
+ torch.accelerator.synchronize()
655
+
656
+ assert torch.equal(consumed.cpu().reshape(-1).view(torch.int8), expected_a), (
657
+ "queued consumer read bytes from a later load; the load destination was "
658
+ f"recycled while still in use (allocator reused the buffer: {reused})"
659
+ )
660
+
661
+
662
# ── buffer recycling ────────────────────────────────────────────────────────
663
664
665
@_requires_accelerator
666
def test_buffer_pool_is_reused_across_save_steps(make_worker):
400
- """After flush_saves, descriptor buffers are returned to the pool and
401
- reused on the next flush — no reallocation."""
667
+ """Once a save copy completes its descriptor buffers return to the pool and
668
+ are reused by the next flush — no reallocation.
669
+
670
+ The buffers hold the addresses the copy reads, so they stay out of the pool
671
+ until the transfer's end event fires.
672
+ """
673
worker = make_worker()
674
src = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
675
676
worker.save_caches({"h": src}, "h", _meta(saves={"h": [0]}))
677
worker.flush_saves()
678
+ _wait_for_completion(worker, "h", "saves")
679
680
assert len(worker._buf_pool._pool) == 1
681
buf_id = id(worker._buf_pool._pool[0].src_ptrs)
@@ -411,6 +683,7 @@ def test_buffer_pool_is_reused_across_save_steps(make_worker):
683
# Second step reuses the same buffer.
684
worker.save_caches({"h": src}, "h", _meta(saves={"h": [1]}))
685
worker.flush_saves()
686
+ _wait_for_completion(worker, "h", "saves")
687
688
assert len(worker._buf_pool._pool) == 1
689
assert id(worker._buf_pool._pool[0].src_ptrs) == buf_id
@@ -418,35 +691,29 @@ def test_buffer_pool_is_reused_across_save_steps(make_worker):
691
692
@_requires_accelerator
693
def test_buffer_pool_is_reused_across_load_steps(make_worker):
421
- """After start_load_caches, descriptor buffers are returned to the pool
422
- and reused on the next call."""
694
+ """Once a load copy completes its descriptor buffers return to the pool and
695
+ are reused by the next call — no reallocation."""
696
worker = make_worker()
697
worker._region.blocks[0].fill_(0x01)
698
worker._region.blocks[1].fill_(0x02)
699
700
encoder_cache: dict[str, torch.Tensor] = {}
428
- worker.start_load_caches(encoder_cache, _meta(loads={"a": [0]}))
701
+ meta = _meta(loads={"a": [0]})
702
+ worker.start_load_caches(encoder_cache, meta)
703
+ _wait_for_completion(worker, _load_id(meta, "a"), "loads")
704
705
assert len(worker._buf_pool._pool) == 1
706
buf_id = id(worker._buf_pool._pool[0].src_ptrs)
707
708
encoder_cache2: dict[str, torch.Tensor] = {}
434
- worker.start_load_caches(encoder_cache2, _meta(loads={"b": [1]}))
709
+ meta2 = _meta(loads={"b": [1]})
710
+ worker.start_load_caches(encoder_cache2, meta2)
711
+ _wait_for_completion(worker, _load_id(meta2, "b"), "loads")
712
713
assert len(worker._buf_pool._pool) == 1
714
assert id(worker._buf_pool._pool[0].src_ptrs) == buf_id
715
716
440
-# ── stream management ────────────────────────────────────────────────────────
441
-
442
-
443
-@_requires_accelerator
444
-def test_stream_initialized_at_construction(make_worker):
445
- """``_load_stream`` must be a fully initialized accelerator stream."""
446
- worker = make_worker()
447
- assert isinstance(worker._load_stream, current_platform.Stream)
448
-
449
-
717
# ── lifecycle ────────────────────────────────────────────────────────────────
718
719
@@ -500,12 +767,15 @@ def test_shutdown_calls_region_cleanup_and_swallows_errors(caplog_vllm):
767
worker = object.__new__(ECCPUWorker)
768
mock_region = Mock(spec=ECSharedRegion)
769
worker._region = mock_region
503
- worker._load_stream = MagicMock()
770
worker._save_bufs = None
771
worker._save_count = 0
772
+ worker._save_mm_hashes = []
773
+ worker._inflight_saves = deque()
774
+ worker._inflight_loads = deque()
775
+ worker._stream_pool = []
776
+ worker._event_pool = []
777
778
worker.shutdown()
508
- worker._load_stream.synchronize.assert_called_once()
779
mock_region.cleanup.assert_called_once()
780
781
mock_region.cleanup.side_effect = RuntimeError("boom")
@@ -525,12 +795,13 @@ def test_shutdown_calls_region_cleanup_and_swallows_errors(caplog_vllm):
795
796
@_requires_accelerator
797
def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
528
- """Full pipeline: scheduler allocates blocks, worker saves GPU tensor to
529
- mmap via flush_saves, scheduler marks ready after step delay, worker
530
- loads from mmap back to GPU, and the result matches the original.
798
+ """Full pipeline: scheduler allocates blocks, worker saves a GPU tensor to
799
+ mmap via flush_saves, the worker's completion report marks the entry ready,
800
+ the worker loads from mmap back to GPU, and the result matches the original.
801
802
Exercises the real scheduler + worker cooperation through a shared
533
- ECSharedRegion, with real accelerator transfers and stream coordination.
803
+ ECSharedRegion, with real accelerator transfers and stream coordination,
804
+ and the event-driven mark_ready path.
805
"""
806
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
807
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import (
@@ -544,15 +815,7 @@ def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
815
# Build scheduler sharing the same region.
816
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
817
547
- class _EC:
548
- is_ec_producer = True
549
- is_ec_consumer = True
550
-
551
- class _Cfg:
552
- ec_transfer_config = _EC()
553
- max_concurrent_batches = 1
554
-
555
- scheduler = ECCPUScheduler(_Cfg())
818
+ scheduler = ECCPUScheduler(_vllm_config())
819
820
# -- Step 1: scheduler allocates, worker saves --
821
n_blocks = 3
@@ -580,16 +843,24 @@ def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
843
worker.save_caches(encoder_cache, "img_001", meta_save)
844
worker.flush_saves()
845
583
- # -- Step 2: readiness delay (max_concurrent_batches=1) --
584
- meta_step2 = scheduler.build_connector_meta(scheduler_output=None)
846
+ # -- Step 2: worker reports the save memcpy complete → scheduler marks ready --
847
+ torch.accelerator.synchronize()
848
+ worker_meta = worker.build_connector_worker_meta()
849
+ assert worker_meta is not None
850
+ assert "img_001" in worker_meta.completed_saves
851
+
852
+ class _Output:
853
+ ec_connector_worker_meta = worker_meta
854
+
855
+ scheduler.update_connector_output(_Output())
856
assert scheduler.has_cache_item("img_001") is True
586
- assert meta_step2.loads == {} # no load requested yet
857
858
# -- Step 3: scheduler emits load, worker loads --
859
scheduler.update_state_after_alloc(_Request(), 0)
860
meta_load = scheduler.build_connector_meta(scheduler_output=None)
861
assert "img_001" in meta_load.loads
592
- assert meta_load.loads["img_001"] == meta_save.saves["img_001"]
862
+ _, load_blocks = meta_load.loads["img_001"]
863
+ assert load_blocks == meta_save.saves["img_001"]
864
865
load_cache: dict[str, torch.Tensor] = {}
866
worker.start_load_caches(load_cache, meta_load)
tests/v1/ec_connector/unit/test_ec_cpu_connector.py
+4
-3
@@ -48,9 +48,10 @@ def _make_unique_image(base_image, iteration: int):
48
def _wait_for_ec_ready(llm: LLM) -> None:
49
"""Send a dummy request to force a scheduler step.
50
51
- After generate() returns, the EC entry's data is in the mmap but
52
- readiness may not have fired yet (first-finish fires in the NEXT
53
- scheduler step). This forces that step.
51
+ When generate() returns, the save copy has been issued to the GPU but its
52
+ completion event may fire on a later step, so the entry is not yet marked
53
+ ready. This extra step lets the scheduler poll the worker's completion
54
+ report and mark the entry ready.
55
"""
56
llm.generate("hi", SamplingParams(max_tokens=1), use_tqdm=False)
57
tests/v1/ec_connector/unit/test_metadata.py
+13
-13
@@ -38,19 +38,19 @@ def test_metadata_saves_dict_operations():
38
assert meta.saves["mm_hash_2"] == [10, 11]
39
40
41
-def test_metadata_loads_dict_operations():
42
- """The loads dict supports standard dict operations."""
41
+def test_metadata_loads_carry_a_transfer_id_and_blocks():
42
+ """Each load entry is a (transfer_id, block_ids) pair, so the worker can
43
+ report the transfer rather than the mm_hash."""
44
meta = ECCPUConnectorMetadata()
45
45
- # Add multiple entries
46
- meta.loads["mm_hash_1"] = [5, 6, 7]
47
- meta.loads["mm_hash_2"] = [100]
46
+ meta.loads["mm_hash_1"] = (0, [5, 6, 7])
47
+ meta.loads["mm_hash_2"] = (1, [100])
48
49
- assert "mm_hash_1" in meta.loads
50
- assert "mm_hash_2" in meta.loads
49
assert len(meta.loads) == 2
52
- assert meta.loads["mm_hash_1"] == [5, 6, 7]
53
- assert meta.loads["mm_hash_2"] == [100]
50
+ assert meta.loads["mm_hash_1"] == (0, [5, 6, 7])
51
+ transfer_id, block_ids = meta.loads["mm_hash_2"]
52
+ assert transfer_id == 1
53
+ assert block_ids == [100]
54
55
56
def test_metadata_saves_and_loads_are_independent():
@@ -58,11 +58,11 @@ def test_metadata_saves_and_loads_are_independent():
58
meta = ECCPUConnectorMetadata()
59
60
meta.saves["key"] = [1, 2]
61
- meta.loads["key"] = [3, 4]
61
+ meta.loads["key"] = (0, [3, 4])
62
63
# Same key but different dicts should not interfere
64
assert meta.saves["key"] == [1, 2]
65
- assert meta.loads["key"] == [3, 4]
65
+ assert meta.loads["key"] == (0, [3, 4])
66
67
# Modifying one should not affect the other
68
meta.saves["other"] = [99]
@@ -74,11 +74,11 @@ def test_metadata_block_indices_are_integers():
74
meta = ECCPUConnectorMetadata()
75
76
meta.saves["hash"] = [0, 100, 999, 65535]
77
- meta.loads["hash"] = [1, 2, 3]
77
+ meta.loads["hash"] = (0, [1, 2, 3])
78
79
for idx in meta.saves["hash"]:
80
assert isinstance(idx, int)
81
- for idx in meta.loads["hash"]:
81
+ for idx in meta.loads["hash"][1]:
82
assert isinstance(idx, int)
83
84
tests/v1/ec_connector/unit/utils.py
new
+70
@@ -0,0 +1,70 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Shared config builders for EC connector unit tests."""
4
+
5
+import uuid
6
+from unittest.mock import Mock
7
+
8
+import torch
9
+
10
+from vllm.config import ModelConfig, VllmConfig
11
+from vllm.config.ec_transfer import ECRole, ECTransferConfig
12
+from vllm.config.parallel import ParallelConfig
13
+
14
+# The scheduler derives its per-load ack count from the PCP size only under the
15
+# `mp` backend -- which also keeps the world size off the local GPU count, so
16
+# TP > 1 stays expressible on a single-GPU host.
17
+_EXECUTOR_BACKEND = "mp"
18
+_PCP_SIZE = 1
19
+
20
+
21
+def create_ec_vllm_config(
22
+ *,
23
+ ec_role: ECRole = "ec_both",
24
+ tensor_parallel_size: int = 1,
25
+ rank: int = 0,
26
+ dtype: torch.dtype = torch.float16,
27
+) -> Mock:
28
+ """Build a `VllmConfig` stand-in for EC connector unit tests.
29
+
30
+ `ec_transfer_config` and `parallel_config` are the real config objects, so
31
+ role derivation and the rank arithmetic the connector depends on behave as
32
+ they do in production.
33
+
34
+ `model_config` is a stub because nothing under test needs a real one: the
35
+ connector reads only `dtype` here, and the paths that inspect the model
36
+ itself are not exercised. Building a real `ModelConfig` would resolve an HF
37
+ model and inspect its architecture.
38
+
39
+ Args:
40
+ ec_role: EC role this instance plays. `is_ec_producer` and
41
+ `is_ec_consumer` are derived from it, as in production.
42
+ tensor_parallel_size: TP degree.
43
+ rank: Global rank of this worker.
44
+ dtype: Encoder cache dtype.
45
+
46
+ Returns:
47
+ A `VllmConfig`-specced mock carrying the configs above.
48
+ """
49
+ parallel_config = ParallelConfig(
50
+ tensor_parallel_size=tensor_parallel_size,
51
+ prefill_context_parallel_size=_PCP_SIZE,
52
+ distributed_executor_backend=_EXECUTOR_BACKEND,
53
+ rank=rank,
54
+ )
55
+ ec_transfer_config = ECTransferConfig(
56
+ # The role properties report False unless a connector is named.
57
+ ec_connector="ECCPUConnector",
58
+ ec_role=ec_role,
59
+ engine_id=str(uuid.uuid4()),
60
+ )
61
+
62
+ model_config = Mock(spec=ModelConfig)
63
+ model_config.dtype = dtype
64
+
65
+ vllm_config = Mock(spec=VllmConfig)
66
+ vllm_config.ec_transfer_config = ec_transfer_config
67
+ vllm_config.parallel_config = parallel_config
68
+ vllm_config.model_config = model_config
69
+ vllm_config.instance_id = f"ec-test-{uuid.uuid4()}"
70
+ return vllm_config
vllm/distributed/ec_transfer/ec_connector/cpu/common.py
+36
-5
@@ -7,7 +7,10 @@ from typing import TYPE_CHECKING
7
8
import torch
9
10
-from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
10
+from vllm.distributed.ec_transfer.ec_connector.base import (
11
+ ECConnectorMetadata,
12
+ ECConnectorWorkerMetadata,
13
+)
14
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
15
ECSharedRegion,
16
)
@@ -29,10 +32,38 @@ class ECCPUConnectorMetadata(ECConnectorMetadata):
32
# encoder_cache[mm_hash] → mmap at these block IDs.
33
saves: dict[str, list[int]] = field(default_factory=dict)
34
32
- # Consumer role: mm_hashes whose bytes are available in the local mmap;
33
- # the worker's start_load_caches copies mmap[block_ids] → GPU
34
- # encoder_cache.
35
- loads: dict[str, list[int]] = field(default_factory=dict)
35
+ # Consumer role: mm_hashes whose bytes are available in the local mmap,
36
+ # mapped to (transfer_id, block_ids); the worker's start_load_caches
37
+ # copies mmap[block_ids] → GPU encoder_cache and reports the transfer_id
38
+ # once its copy lands. The id is minted per dispatch, so two loads of the
39
+ # same mm_hash in different steps are distinct and a late report from the
40
+ # earlier one cannot release the later one's pin.
41
+ loads: dict[str, tuple[int, list[int]]] = field(default_factory=dict)
42
+
43
+
44
+@dataclass
45
+class ECCPUWorkerMetadata(ECConnectorWorkerMetadata):
46
+ """Per-step worker → scheduler payload for the ECCPUConnector.
47
+
48
+ Reports the GPU copies that completed this step: saved mm_hashes become
49
+ safe to mark ready, and loaded transfers become safe to unpin once every
50
+ participating rank has reported them. Built by
51
+ `ECCPUWorker.build_connector_worker_meta`; consumed by
52
+ `ECCPUScheduler.update_connector_output`.
53
+
54
+ Loads are reported by transfer id rather than mm_hash because every rank
55
+ copies the same blocks: `aggregate` concatenates, so an id appears once
56
+ per reporting rank and the scheduler can count participants off the list.
57
+ """
58
+
59
+ completed_saves: list[str] = field(default_factory=list)
60
+ completed_loads: list[int] = field(default_factory=list)
61
+
62
+ def aggregate(self, other: ECConnectorWorkerMetadata) -> ECConnectorWorkerMetadata:
63
+ assert isinstance(other, ECCPUWorkerMetadata)
64
+ self.completed_saves.extend(other.completed_saves)
65
+ self.completed_loads.extend(other.completed_loads)
66
+ return self
67
68
69
def _get_encoder_cache_hidden_dim(vllm_config: "VllmConfig") -> int:
vllm/distributed/ec_transfer/ec_connector/cpu/connector.py
+16
@@ -15,6 +15,7 @@ import torch
15
from vllm.distributed.ec_transfer.ec_connector.base import (
16
ECConnectorBase,
17
ECConnectorRole,
18
+ ECConnectorWorkerMetadata,
19
)
20
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
21
ECCPUConnectorMetadata,
@@ -24,6 +25,7 @@ from vllm.logger import init_logger
25
if TYPE_CHECKING:
26
from vllm.config import VllmConfig
27
from vllm.v1.core.sched.output import SchedulerOutput
28
+ from vllm.v1.outputs import ECConnectorOutput
29
from vllm.v1.request import Request
30
31
logger = init_logger(__name__)
@@ -113,6 +115,20 @@ class ECCPUConnector(ECConnectorBase):
115
self.connector_worker.flush_saves()
116
return None, None
117
118
+ def build_connector_worker_meta(self) -> ECConnectorWorkerMetadata | None:
119
+ if self.connector_worker is not None:
120
+ return self.connector_worker.build_connector_worker_meta()
121
+ return None
122
+
123
+ def update_connector_output(self, connector_output: "ECConnectorOutput") -> None:
124
+ if self.connector_scheduler is not None:
125
+ self.connector_scheduler.update_connector_output(connector_output)
126
+
127
+ def has_pending_push_work(self) -> bool:
128
+ if self.connector_scheduler is not None:
129
+ return self.connector_scheduler.has_pending_push_work()
130
+ return False
131
+
132
# Shared.
133
def shutdown(self) -> None:
134
if self.connector_scheduler is not None:
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/__init__.py
+63
-31
@@ -11,19 +11,18 @@ from typing import TYPE_CHECKING
11
12
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
13
ECCPUConnectorMetadata,
14
+ ECCPUWorkerMetadata,
15
create_ec_shared_region,
16
)
17
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
18
EmbeddingCache,
19
)
19
-from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.step_tracker import (
20
- StepTracker,
21
-)
20
from vllm.logger import init_logger
21
22
if TYPE_CHECKING:
23
from vllm.config import VllmConfig
24
from vllm.v1.core.sched.output import SchedulerOutput
25
+ from vllm.v1.outputs import ECConnectorOutput
26
from vllm.v1.request import Request
27
28
logger = init_logger(__name__)
@@ -42,16 +41,27 @@ class ECCPUScheduler:
41
# Block allocator + LRU eviction policy for the shared region.
42
self._cache = EmbeddingCache(self._region.num_blocks)
43
45
- max_batches = vllm_config.max_concurrent_batches
46
- # Delays mark_ready until the GPU→mmap DMA is guaranteed complete.
47
- self._ready_tracker = StepTracker(max_batches)
48
- # Delays unpin until the mmap→GPU DMA is guaranteed complete.
49
- self._unpin_tracker = StepTracker(max_batches)
50
-
44
# mm_hash → block IDs allocated this step for GPU→mmap saves.
45
self._pending_saves: dict[str, list[int]] = {}
53
- # mm_hash → block IDs to load from mmap→GPU this step.
54
- self._pending_loads: dict[str, list[int]] = {}
46
+ # mm_hash → (transfer_id, block IDs) to load from mmap→GPU this step.
47
+ self._pending_loads: dict[str, tuple[int, list[int]]] = {}
48
+
49
+ # Dispatched loads awaiting completion reports, keyed by transfer id:
50
+ # transfer_id → (mm_hash, reports still outstanding). The pin taken at
51
+ # dispatch is released only once the count reaches zero.
52
+ self._load_acks: dict[int, tuple[str, int]] = {}
53
+ self._next_transfer_id = 0
54
+
55
+ pc = vllm_config.parallel_config
56
+ # Reports to expect per load. Only pipeline stage 0 runs the EC
57
+ # connector, so the participants are the tp × pcp ranks of that stage.
58
+ # Other executors deliver a single rank's output, and expecting reports
59
+ # that cannot arrive would hold the pin forever.
60
+ self._expected_load_reports = (
61
+ pc.tensor_parallel_size * pc.prefill_context_parallel_size
62
+ if pc.distributed_executor_backend == "mp"
63
+ else 1
64
+ )
65
66
def has_cache_item(self, identifier: str) -> bool:
67
if not self._is_consumer:
@@ -72,30 +82,19 @@ class ECCPUScheduler:
82
entry = self._cache.alloc(mm_hash, feature.mm_position.length)
83
if entry is not None:
84
self._pending_saves[mm_hash] = list(entry.block_ids)
75
- self._ready_tracker.add(mm_hash, request.request_id)
85
86
if self._is_consumer and mm_hash not in self._pending_loads:
87
entry = self._cache.get(mm_hash)
88
if entry is not None and entry.ready:
89
self._cache.pin(mm_hash)
81
- self._pending_loads[mm_hash] = list(entry.block_ids)
82
- self._unpin_tracker.add(mm_hash, request.request_id)
90
+ transfer_id = self._next_transfer_id
91
+ self._next_transfer_id += 1
92
+ self._pending_loads[mm_hash] = (transfer_id, list(entry.block_ids))
93
+ self._load_acks[transfer_id] = (mm_hash, self._expected_load_reports)
94
95
def build_connector_meta(
96
self, scheduler_output: "SchedulerOutput"
97
) -> ECCPUConnectorMetadata:
87
- finished = (
88
- scheduler_output.finished_req_ids if scheduler_output is not None else set()
89
- )
90
-
91
- for key in self._ready_tracker.step(finished):
92
- entry = self._cache.get(key)
93
- if entry is not None and not entry.ready:
94
- self._cache.mark_ready(key)
95
-
96
- for key in self._unpin_tracker.step(finished):
97
- self._cache.unpin(key)
98
-
98
meta = ECCPUConnectorMetadata()
99
if self._is_producer:
100
meta.saves = self._pending_saves
@@ -105,13 +104,46 @@ class ECCPUScheduler:
104
self._pending_loads = {}
105
return meta
106
107
+ def update_connector_output(self, connector_output: "ECConnectorOutput") -> None:
108
+ """Apply the worker's memcpy-completion report to the cache.
109
+
110
+ Completed saves become safe to mark ready. A load is unpinned once
111
+ every participating rank has reported its transfer id; reports for a
112
+ transfer that has already been released, or for one this scheduler
113
+ never dispatched, are ignored.
114
+ """
115
+ meta = connector_output.ec_connector_worker_meta
116
+ if not isinstance(meta, ECCPUWorkerMetadata):
117
+ return
118
+ for mm_hash in meta.completed_saves:
119
+ entry = self._cache.get(mm_hash)
120
+ if entry is not None and not entry.ready:
121
+ self._cache.mark_ready(mm_hash)
122
+ for transfer_id in meta.completed_loads:
123
+ pending = self._load_acks.get(transfer_id)
124
+ if pending is None:
125
+ continue
126
+ mm_hash, outstanding = pending
127
+ if outstanding > 1:
128
+ self._load_acks[transfer_id] = (mm_hash, outstanding - 1)
129
+ continue
130
+ # Drop the entry before unpinning so a replayed report is treated
131
+ # as stale rather than releasing the pin a second time.
132
+ del self._load_acks[transfer_id]
133
+ self._cache.unpin(mm_hash)
134
+
135
+ def has_pending_push_work(self) -> bool:
136
+ """True while any dispatched save or load has not been confirmed done.
137
+
138
+ Keeps the engine stepping so the worker's completion reports are polled
139
+ even when no requests are otherwise runnable.
140
+ """
141
+ return self._cache.has_held_entries()
142
+
143
def shutdown(self) -> None:
109
- # drain_all() covers both entries still in _current (never
110
- # consumed by build_connector_meta) and entries in slots.
144
+ self._pending_saves.clear()
145
self._pending_loads.clear()
112
- for mm_hash in self._unpin_tracker.drain_all():
113
- self._cache.unpin(mm_hash)
114
- self._ready_tracker.drain_all()
146
+ self._load_acks.clear()
147
148
self._is_producer = False
149
self._is_consumer = False
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/embedding_cache.py
+13
-1
@@ -67,6 +67,15 @@ class EmbeddingCache:
67
with self._lock:
68
return self._entries.get(key)
69
70
+ def has_held_entries(self) -> bool:
71
+ """True if any entry is not-ready or pinned (an in-flight transfer).
72
+
73
+ Held entries are exactly those absent from the eviction free list, so
74
+ they represent saves awaiting mark_ready or loads awaiting unpin.
75
+ """
76
+ with self._lock:
77
+ return len(self._entries) != len(self._entries_free_list)
78
+
79
def alloc(self, key: str, n_blocks: int) -> CacheEntry | None:
80
"""Allocate *n_blocks* for *key*, evicting as needed.
81
@@ -84,7 +93,10 @@ class EmbeddingCache:
93
if len(self._free_blocks) + self._evictable_block_count < n_blocks:
94
return None
95
self._evict_until(n_blocks)
87
- block_ids = tuple(self._free_blocks.pop() for _ in range(n_blocks))
96
+ # Canonical ascending order: keeps a freed entry's blocks contiguous
97
+ # in the free list when it is evicted, and lets consumers detect
98
+ # which of an entry's blocks are adjacent.
99
+ block_ids = tuple(sorted(self._free_blocks.pop() for _ in range(n_blocks)))
100
entry = CacheEntry(block_ids)
101
self._entries[key] = entry
102
return entry
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/step_tracker.py
deleted
-115
@@ -1,115 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""StepTracker — step-counting delay with first-finish fast-path.
4
-
5
-Manages a collection of (mm_hash, request_id) entries that need to be
6
-processed after a configurable number of engine steps have elapsed.
7
-Additionally, if the originating request finishes before the step-count
8
-expires, the entry is processed immediately (the GPU transfer is
9
-guaranteed complete by request finish time).
10
-
11
-Used by ECCPUScheduler: one instance for readiness marking, one for
12
-unpins.
13
-"""
14
-
15
-from collections import deque
16
-from dataclasses import dataclass
17
-
18
-
19
-@dataclass(slots=True)
20
-class _PendingEntry:
21
- mm_hash: str
22
- request_id: str
23
- processed: bool = False
24
-
25
-
26
-class StepTracker:
27
- """Track pending operations with step-count delay and first-finish.
28
-
29
- Each ``add(mm_hash, request_id)`` registers an operation that will be
30
- returned from ``step()`` either when:
31
- (a) ``max_concurrent_batches`` steps elapse (deque expiry), OR
32
- (b) ``request_id`` appears in ``finished_req_ids`` (first-finish).
33
-
34
- Each add is processed exactly once — never both (a) and (b).
35
- """
36
-
37
- def __init__(self, max_concurrent_batches: int) -> None:
38
- # One slot per past step; oldest at the right. When the deque is
39
- # full the rightmost slot is popped and its entries expire.
40
- self._slots: deque[list[_PendingEntry]] = deque(maxlen=max_concurrent_batches)
41
- # Reverse index: request_id → entries across all slots + _current.
42
- # Enables O(1) lookup for the first-finish fast-path.
43
- self._req_index: dict[str, list[_PendingEntry]] = {}
44
- # Entries added this step (via add()), committed to a new slot at
45
- # the start of the next step() call.
46
- self._current: list[_PendingEntry] = []
47
-
48
- def add(self, mm_hash: str, request_id: str) -> None:
49
- """Register an mm_hash needing processing after a delay."""
50
- entry = _PendingEntry(mm_hash=mm_hash, request_id=request_id)
51
- self._current.append(entry)
52
- self._req_index.setdefault(request_id, []).append(entry)
53
-
54
- def step(self, finished_req_ids: set[str]) -> list[str]:
55
- """Advance one step. Return mm_hashes ready for processing.
56
-
57
- Returns:
58
- List of mm_hashes to process. May contain duplicates if the
59
- same mm_hash was added multiple times (from different requests).
60
- """
61
- result: list[str] = []
62
-
63
- # Phase 1: first-finish fast-path.
64
- for req_id in finished_req_ids:
65
- entries = self._req_index.pop(req_id, None)
66
- if entries is None:
67
- continue
68
- for entry in entries:
69
- if not entry.processed:
70
- entry.processed = True
71
- result.append(entry.mm_hash)
72
-
73
- # Phase 2: step-count expiry.
74
- if len(self._slots) == self._slots.maxlen:
75
- expired_slot = self._slots.pop()
76
- for entry in expired_slot:
77
- if not entry.processed:
78
- entry.processed = True
79
- result.append(entry.mm_hash)
80
- self._cleanup_expired(expired_slot)
81
-
82
- # Phase 3: commit current entries to new slot.
83
- self._slots.appendleft(self._current)
84
- self._current = []
85
-
86
- return result
87
-
88
- def drain_all(self) -> list[str]:
89
- """Return all unprocessed entries and clear internal state."""
90
- result: list[str] = []
91
- for entry in self._current:
92
- if not entry.processed:
93
- entry.processed = True
94
- result.append(entry.mm_hash)
95
- self._current = []
96
-
97
- for slot in self._slots:
98
- for entry in slot:
99
- if not entry.processed:
100
- entry.processed = True
101
- result.append(entry.mm_hash)
102
- self._slots.clear()
103
- self._req_index.clear()
104
- return result
105
-
106
- def _cleanup_expired(self, slot: list[_PendingEntry]) -> None:
107
- """Prune reverse index for entries in an expired slot."""
108
- for entry in slot:
109
- entries_for_req = self._req_index.get(entry.request_id)
110
- if entries_for_req is None:
111
- # Already removed by fast-path (pop in step()).
112
- continue
113
- entries_for_req.remove(entry)
114
- if not entries_for_req:
115
- del self._req_index[entry.request_id]
vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py
+266
-50
@@ -7,13 +7,19 @@ per-step connector metadata (`ECCPUConnectorMetadata`) to decide which
7
blocks to copy in each direction.
8
"""
9
10
+from collections import deque
11
+from collections.abc import Sequence
12
+from dataclasses import dataclass
13
+from itertools import chain
14
from typing import TYPE_CHECKING
15
16
+import numpy as np
17
import torch
18
19
from vllm._custom_ops import swap_blocks_batch
20
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
21
ECCPUConnectorMetadata,
22
+ ECCPUWorkerMetadata,
23
create_ec_shared_region,
24
)
25
from vllm.distributed.ec_transfer.ec_connector.cpu.worker.descriptor_buffers import (
@@ -34,6 +40,50 @@ if TYPE_CHECKING:
40
logger = init_logger(__name__)
41
42
43
+def _coalesce_runs(
44
+ block_ids: Sequence[int],
45
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
46
+ """Split block ids into runs that are consecutive in the region.
47
+
48
+ Returns `(slots, first_blocks, num_blocks)`, one element per run, where
49
+ `slots` is the run's offset in the flat block sequence. Per-descriptor
50
+ overhead dominates these copies, so describing a run with one descriptor
51
+ rather than one per block is worth an order of magnitude.
52
+
53
+ Both callers derive the non-region side of a run from its slot, so that
54
+ side advances in lockstep with the region and only the region has to be
55
+ split. `EmbeddingCache` hands out ascending ids, which is what makes runs
56
+ findable at all.
57
+ """
58
+
59
+ ids = np.fromiter(block_ids, dtype=np.int64, count=len(block_ids))
60
+ breaks = np.flatnonzero(np.diff(ids) != 1) + 1
61
+ slots = np.concatenate(([0], breaks))
62
+ ends = np.concatenate((breaks, [ids.size]))
63
+ return slots, ids[slots], ends - slots
64
+
65
+
66
+@dataclass
67
+class Transfer:
68
+ """A batched copy in flight on a dedicated GPU stream.
69
+
70
+ Its descriptor buffers and stream are held until `end_event` fires; only
71
+ then are the buffers and stream safe to recycle and the `completions` safe
72
+ to report. `start_event`/`end_event` bracket the copy so its elapsed time
73
+ can be measured on completion.
74
+
75
+ `completions` is what the scheduler is told about this copy: mm_hashes for
76
+ saves, transfer ids for loads.
77
+ """
78
+
79
+ start_event: torch.Event
80
+ end_event: torch.Event
81
+ completions: list[str] | list[int]
82
+ bufs: DescriptorBuffers
83
+ stream: torch.Stream
84
+ num_bytes: int
85
+
86
+
87
class ECCPUWorker:
88
"""Worker-side delegate for the ECCPUConnector.
89
@@ -43,8 +93,18 @@ class ECCPUWorker:
93
batched call in `flush_saves`.
94
- Consumer role: copies `mmap[block_ids]` → `encoder_cache[mm_hash]`
95
for all entries in `metadata.loads` via a single `swap_blocks_batch`
46
- call on the load stream.
96
+ call on a dedicated stream.
97
- On `ec_both` nodes both paths run back-to-back in a single step.
98
+
99
+ Every batched copy runs on a stream drawn from a pool so it overlaps the
100
+ compute stream, bracketed by a start/end event pair. The stream, events,
101
+ and descriptor buffers are recycled once the end event fires.
102
+
103
+ Both paths move memory between the two streams, so each buffer is registered
104
+ against the stream that is not its own: save sources against the save stream,
105
+ load destinations against the compute stream. Without that, the caching
106
+ allocator would be free to reissue memory a copy or a queued read still
107
+ needs, since it only tracks the stream a buffer was allocated on.
108
"""
109
110
def __init__(self, vllm_config: "VllmConfig") -> None:
@@ -62,15 +122,72 @@ class ECCPUWorker:
122
get_tensor_model_parallel_rank() == 0 and get_pcp_group().rank_in_group == 0
123
)
124
65
- # Dedicated stream for async mmap→GPU loads (overlaps with compute).
66
- self._load_stream = current_platform.Stream()
67
-
125
# Descriptor buffer pool (recycled across steps, shared by both paths).
126
self._buf_pool = DescriptorBufferPool()
127
128
# Active save buffer being filled during save_caches calls this step.
129
self._save_bufs: DescriptorBuffers | None = None
130
self._save_count: int = 0
131
+ # Bytes accumulated into the active save buffer this step, for logging.
132
+ self._save_bytes: int = 0
133
+ # mm_hashes accumulated into the active save buffer this step.
134
+ self._save_mm_hashes: list[str] = []
135
+ # Stream the active save buffer's copy will run on, held from the moment
136
+ # the buffer opens so `save_caches` can register its sources against it.
137
+ self._save_stream: torch.Stream | None = None
138
+
139
+ # Batched copies whose end event has not yet fired. Buffers and stream
140
+ # stay held until the event fires; the completions are reported to the
141
+ # scheduler only then. Separate per direction because saves and loads
142
+ # can be in flight concurrently.
143
+ self._inflight_saves: deque[Transfer] = deque()
144
+ self._inflight_loads: deque[Transfer] = deque()
145
+
146
+ # Streams and timing events recycled across steps to avoid per-step
147
+ # create/destroy churn. Shared by both directions.
148
+ self._stream_pool: list[torch.Stream] = []
149
+ self._event_pool: list[torch.Event] = []
150
+
151
+ def _acquire_stream(self) -> torch.Stream:
152
+ if self._stream_pool:
153
+ return self._stream_pool.pop()
154
+ return current_platform.Stream()
155
+
156
+ def _acquire_event(self) -> torch.Event:
157
+ return (
158
+ self._event_pool.pop()
159
+ if self._event_pool
160
+ else torch.Event(enable_timing=True)
161
+ )
162
+
163
+ def _collect_finished(self, inflight: deque[Transfer], direction: str) -> list:
164
+ """Pop transfers whose end event has fired, recycle their stream,
165
+ events, and buffers, and return their completions.
166
+
167
+ The front check is conservative: a transfer is reported only once its
168
+ own end event fires, so completions are always genuinely done, and each
169
+ is reported exactly once because the transfer is popped as it is
170
+ reported. A later transfer that happens to finish first simply waits
171
+ behind the front and is reported on a subsequent poll.
172
+ """
173
+ done: list = []
174
+ while inflight and inflight[0].end_event.query():
175
+ transfer = inflight.popleft()
176
+ done.extend(transfer.completions)
177
+ elapsed_ms = transfer.start_event.elapsed_time(transfer.end_event)
178
+ logger.debug(
179
+ "EC %s: %d entr%s (%d bytes) took %.3f ms",
180
+ direction,
181
+ len(transfer.completions),
182
+ "y" if len(transfer.completions) == 1 else "ies",
183
+ transfer.num_bytes,
184
+ elapsed_ms,
185
+ )
186
+ self._buf_pool.release(transfer.bufs)
187
+ self._stream_pool.append(transfer.stream)
188
+ self._event_pool.append(transfer.start_event)
189
+ self._event_pool.append(transfer.end_event)
190
+ return done
191
192
def save_caches(
193
self,
@@ -78,7 +195,14 @@ class ECCPUWorker:
195
mm_hash: str,
196
connector_metadata: ECCPUConnectorMetadata,
197
) -> None:
81
- """Fill descriptor buffers directly for batched flush."""
198
+ """Fill descriptor buffers directly for batched flush.
199
+
200
+ Registers `encoder_cache[mm_hash]` against the stream the copy will run
201
+ on, so the caching allocator will not hand that memory to another
202
+ allocation before the copy has read it. The descriptor buffers hold raw
203
+ addresses, and the caller is free to evict the entry as soon as this
204
+ step's saves are dispatched.
205
+ """
206
if not self._is_save_rank:
207
return
208
block_ids = connector_metadata.saves.get(mm_hash)
@@ -99,43 +223,95 @@ class ECCPUWorker:
223
if self._save_bufs is None:
224
total = sum(len(v) for v in connector_metadata.saves.values())
225
self._save_bufs = self._buf_pool.acquire(total)
226
+ self._save_stream = self._acquire_stream()
227
+ self._save_mm_hashes = []
228
229
assert self._save_count + len(block_ids) <= self._save_bufs.src_ptrs.numel()
230
231
bufs = self._save_bufs
106
- src_base = src.view(-1).view(torch.uint8).data_ptr()
232
+ assert self._save_stream is not None
233
+ src.record_stream(self._save_stream)
234
+ # Device addresses are unsigned and can have the high bit set (XPU USM),
235
+ # so they are added up in uint64; signed arithmetic rejects them.
236
+ src_base = np.uint64(src.view(-1).view(torch.uint8).data_ptr())
237
dst_base = self._region.blocks.data_ptr()
108
- idx = self._save_count
109
-
110
- for i, block_idx in enumerate(block_ids):
111
- start = i * block_size
112
- bufs.set_ptrs(idx, src_base + start, dst_base + block_idx * block_size)
113
- bufs.sizes[idx] = min(block_size, total_bytes - start)
114
- idx += 1
238
+ # Entries are sized by placeholder count, which can exceed the number
239
+ # of embeddings, so blocks the output never reaches get no descriptor.
240
+ n_used = -(-total_bytes // block_size)
241
+ if n_used:
242
+ slots, first_blocks, num_blocks = _coalesce_runs(block_ids[:n_used])
243
+ run_bytes = num_blocks * block_size
244
+ # The output can stop partway into the final run.
245
+ run_bytes[-1] = total_bytes - slots[-1] * block_size
246
+ bufs.add_copies(
247
+ self._save_count,
248
+ src_base + slots.astype(np.uint64) * np.uint64(block_size),
249
+ dst_base + first_blocks * block_size,
250
+ run_bytes,
251
+ )
252
+ self._save_count += slots.size
253
116
- self._save_count = idx
254
+ self._save_bytes += total_bytes
255
+ self._save_mm_hashes.append(mm_hash)
256
257
def flush_saves(self) -> None:
119
- """Flush all accumulated saves in a single swap_blocks_batch call."""
258
+ """Flush all accumulated saves in a single swap_blocks_batch call.
259
+
260
+ Runs the copy on the stream the batch acquired when it opened, gated
261
+ behind the compute stream that produced the encoder outputs, brackets it
262
+ with start/end events, and enqueues the batch as in-flight. The stream,
263
+ events, and descriptor buffers are recycled once the end event fires (see
264
+ `_collect_finished`), which is also when the saved mm_hashes become
265
+ safe to mark ready.
266
+ """
267
if self._save_count == 0:
268
return
269
270
bufs = self._save_bufs
124
- assert bufs is not None
271
+ stream = self._save_stream
272
+ assert bufs is not None and stream is not None
273
src_ptrs, dst_ptrs, sizes = bufs.src_ptrs, bufs.dst_ptrs, bufs.sizes
274
n = self._save_count
127
- swap_blocks_batch(src_ptrs[:n], dst_ptrs[:n], sizes[:n])
275
+ num_bytes = self._save_bytes
276
+
277
+ # Gate the GPU→CPU copy behind the compute stream: it reads the encoder
278
+ # outputs the model just produced there.
279
+ stream.wait_stream(current_platform.current_stream())
280
+ start_event = self._acquire_event()
281
+ end_event = self._acquire_event()
282
+ with current_platform.stream(stream):
283
+ start_event.record(stream)
284
+ swap_blocks_batch(src_ptrs[:n], dst_ptrs[:n], sizes[:n])
285
+ end_event.record(stream)
286
+
287
+ self._inflight_saves.append(
288
+ Transfer(
289
+ start_event=start_event,
290
+ end_event=end_event,
291
+ completions=self._save_mm_hashes,
292
+ bufs=bufs,
293
+ stream=stream,
294
+ num_bytes=num_bytes,
295
+ )
296
+ )
297
129
- self._buf_pool.release(bufs)
298
self._save_bufs = None
299
+ self._save_stream = None
300
self._save_count = 0
301
+ self._save_bytes = 0
302
+ self._save_mm_hashes = []
303
304
def start_load_caches(
305
self,
306
encoder_cache: dict[str, torch.Tensor],
307
connector_metadata: ECCPUConnectorMetadata,
308
) -> None:
138
- """Consumer path: single batched copy of all loads from mmap→GPU."""
309
+ """Consumer path: single batched copy of all loads from mmap→GPU.
310
+
311
+ The destination is registered against the compute stream that consumes
312
+ it, so the caching allocator will not hand that memory to a later load
313
+ while reads of it are still queued there.
314
+ """
315
if not connector_metadata.loads:
316
return
317
@@ -145,59 +321,99 @@ class ECCPUWorker:
321
device_type = current_platform.device_type
322
src_base = blocks.data_ptr()
323
148
- # Pre-filter: only hashes not already in encoder_cache.
149
- load_items = {
150
- h: idxs
151
- for h, idxs in connector_metadata.loads.items()
152
- if h not in encoder_cache
153
- }
154
- if not load_items:
155
- return
156
-
157
- total_blocks = sum(len(idxs) for idxs in load_items.values())
324
+ # Copy every dispatched load. The scheduler routes an input here only
325
+ # after missing the GPU encoder cache, so a resident hit is not expected;
326
+ # copying unconditionally means every participating rank reports each
327
+ # transfer exactly once, which is what the scheduler counts to release
328
+ # the pin.
329
+ # Ordered by first block so entries that neighbour each other in the
330
+ # region land next to each other in the destination and coalesce into
331
+ # one descriptor. Every loop below consumes this same order.
332
+ load_items = sorted(
333
+ connector_metadata.loads.items(), key=lambda kv: kv[1][1][0]
334
+ )
335
+ total_blocks = sum(len(block_ids) for _, (_, block_ids) in load_items)
336
159
- with current_platform.stream(self._load_stream):
337
+ stream = self._acquire_stream()
338
+ compute_stream = current_platform.current_stream()
339
+ start_event = self._acquire_event()
340
+ end_event = self._acquire_event()
341
+ with current_platform.stream(stream):
342
# Single contiguous destination buffer for all loads.
343
dst_buf = torch.empty(
344
total_blocks, block_size, dtype=torch.int8, device=device_type
345
)
164
- dst_buf_base = dst_buf.data_ptr()
346
+ dst_buf.record_stream(compute_stream)
347
+ # See save_caches: device addresses are added up in uint64.
348
+ dst_buf_base = np.uint64(dst_buf.data_ptr())
349
350
bufs = self._buf_pool.acquire(total_blocks)
167
- src_ptrs = bufs.src_ptrs[:total_blocks]
168
- dst_ptrs = bufs.dst_ptrs[:total_blocks]
169
- sizes = bufs.sizes[:total_blocks]
170
- sizes[:] = block_size
171
-
172
- op_idx = 0
173
- for block_ids in load_items.values():
174
- for block_idx in block_ids:
175
- bufs.set_ptrs(
176
- op_idx,
177
- src_base + block_idx * block_size,
178
- dst_buf_base + op_idx * block_size,
179
- )
180
- op_idx += 1
351
+ slots, first_blocks, num_blocks = _coalesce_runs(
352
+ list(chain.from_iterable(ids for _, (_, ids) in load_items))
353
+ )
354
+ bufs.add_copies(
355
+ 0,
356
+ src_base + first_blocks * block_size,
357
+ dst_buf_base + slots.astype(np.uint64) * np.uint64(block_size),
358
+ num_blocks * block_size,
359
+ )
360
+ op_idx = slots.size
361
+ src_ptrs = bufs.src_ptrs[:op_idx]
362
+ dst_ptrs = bufs.dst_ptrs[:op_idx]
363
+ sizes = bufs.sizes[:op_idx]
364
365
+ start_event.record(stream)
366
swap_blocks_batch(src_ptrs, dst_ptrs, sizes, is_src_access_order_any=True)
183
-
184
- self._buf_pool.release(bufs)
367
+ end_event.record(stream)
368
+ self._inflight_loads.append(
369
+ Transfer(
370
+ start_event=start_event,
371
+ end_event=end_event,
372
+ completions=[tid for _, (tid, _) in load_items],
373
+ bufs=bufs,
374
+ stream=stream,
375
+ num_bytes=total_blocks * block_size,
376
+ )
377
+ )
378
379
# Slice contiguous buffer into per-hash views.
380
offset = 0
188
- for mm_hash, block_ids in load_items.items():
381
+ for mm_hash, (_, block_ids) in load_items:
382
n = len(block_ids)
383
encoder_cache[mm_hash] = (
384
dst_buf[offset : offset + n].view(dtype).reshape(n, -1)
385
)
386
offset += n
387
195
- current_platform.current_stream().wait_stream(self._load_stream)
388
+ current_platform.current_stream().wait_stream(stream)
389
+
390
+ def build_connector_worker_meta(self) -> ECCPUWorkerMetadata | None:
391
+ """Report the GPU copies that completed on this rank this step: saved
392
+ mm_hashes and loaded transfer ids.
393
+
394
+ Returns None when nothing finished, so the scheduler sees no payload.
395
+ """
396
+ completed_saves = self._collect_finished(self._inflight_saves, "save")
397
+ completed_loads = self._collect_finished(self._inflight_loads, "load")
398
+ if not completed_saves and not completed_loads:
399
+ return None
400
+ return ECCPUWorkerMetadata(
401
+ completed_saves=completed_saves,
402
+ completed_loads=completed_loads,
403
+ )
404
405
def shutdown(self) -> None:
198
- self._load_stream.synchronize()
406
+ for transfer in (*self._inflight_saves, *self._inflight_loads):
407
+ transfer.end_event.synchronize()
408
self._save_bufs = None
409
+ self._save_stream = None
410
self._save_count = 0
411
+ self._save_bytes = 0
412
+ self._save_mm_hashes = []
413
+ self._inflight_saves.clear()
414
+ self._inflight_loads.clear()
415
+ self._stream_pool.clear()
416
+ self._event_pool.clear()
417
try:
418
self._region.cleanup()
419
except Exception:
vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py
+36
-15
@@ -22,24 +22,43 @@ class DescriptorBuffers(NamedTuple):
22
src_ptrs: torch.Tensor
23
dst_ptrs: torch.Tensor
24
sizes: torch.Tensor
25
- # Numpy aliases of src_ptrs/dst_ptrs, written via set_ptrs().
25
+ # Numpy aliases of the three tensors, written via add_copies().
26
src_np: np.ndarray
27
dst_np: np.ndarray
28
+ sizes_np: np.ndarray
29
29
- def set_ptrs(self, idx: int, src: int, dst: int) -> None:
30
- """Record the source and destination address of descriptor *idx*.
31
-
32
- TODO(torch>=2.14): drop this indirection and assign the tensors
33
- directly once the minimum supported torch is 2.14. The numpy detour
34
- exists only because torch's setitem unpacks the value as a signed
35
- long long before 2.14 (pytorch#191458), rejecting XPU USM pointers
36
- >= 2**63, while the two's-complement rewrite those versions accept is
37
- in turn rejected by uint64 from 2.14 on. Numpy casts against the
38
- array dtype and so works on either. `sizes` holds byte counts and
39
- needs no such care.
30
+ def add_copies(
31
+ self,
32
+ first: int,
33
+ sources: np.ndarray,
34
+ destinations: np.ndarray,
35
+ byte_counts: np.ndarray,
36
+ ) -> None:
37
+ """Store a batch of copies in these buffers, the first at *first*.
38
+
39
+ One copy is one source address, one destination address, and a number
40
+ of bytes. `swap_blocks_batch` performs every copy held in the buffers
41
+ in a single call, so a caller fills in all of a step's copies before
42
+ asking for any of them to run, and passes *first* to say where its own
43
+ copies begin.
44
+
45
+ Whole batches go in at once because storing a single number into a
46
+ torch tensor costs microseconds. Adding copies one at a time can take
47
+ longer than performing them.
48
+
49
+ TODO(torch>=2.14): write the tensors directly and drop these numpy
50
+ aliases once the minimum supported torch is 2.14. They exist only
51
+ because torch's setitem unpacks the value as a signed long long before
52
+ 2.14 (pytorch#191458), rejecting XPU USM addresses >= 2**63, while the
53
+ two's-complement rewrite those versions accept is in turn rejected by
54
+ uint64 from 2.14 on. Numpy casts against the array's own type and so
55
+ works on either. Byte counts are small enough to need none of this
56
+ care, and use their alias only to match the addresses.
57
"""
41
- self.src_np[idx] = src
42
- self.dst_np[idx] = dst
58
+ last = first + len(sources)
59
+ self.src_np[first:last] = sources
60
+ self.dst_np[first:last] = destinations
61
+ self.sizes_np[first:last] = byte_counts
62
63
64
class DescriptorBufferPool:
@@ -62,7 +81,9 @@ class DescriptorBufferPool:
81
if bufs.src_ptrs.numel() >= n:
82
return bufs
83
src, dst, sizes = (torch.empty(n, dtype=_PTR_DTYPE) for _ in range(3))
65
- return DescriptorBuffers(src, dst, sizes, src.numpy(), dst.numpy())
84
+ return DescriptorBuffers(
85
+ src, dst, sizes, src.numpy(), dst.numpy(), sizes.numpy()
86
+ )
87
88
def release(self, bufs: DescriptorBuffers) -> None:
89
"""Return a buffer triple to the pool for reuse."""