main
py 462 lines 16.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
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 )
14 from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
15
16 _N = 16
17 _BS = 64
18
19
20 class _Pos:
21 def __init__(self, offset, length):
22 self.offset = offset
23 self.length = length
24
25
26 class _Feature:
27 def __init__(self, mm_hash, length=1, identifier=None):
28 self.mm_hash = mm_hash
29 self.identifier = identifier if identifier is not None else mm_hash
30 self.mm_position = _Pos(0, length)
31
32
33 class _Request:
34 _counter = 0
35
36 def __init__(self, features, request_id=None):
37 self.mm_features = features
38 if request_id is None:
39 _Request._counter += 1
40 self.request_id = f"req_{_Request._counter}"
41 else:
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,
62 *,
63 ec_role: ECRole = "ec_both",
64 tensor_parallel_size=1,
65 ) -> ECCPUScheduler:
66 region = ECSharedRegion(
67 engine_id=str(uuid.uuid4()),
68 num_blocks=num_blocks,
69 block_size_bytes=_BS,
70 )
71 monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
72
73 return ECCPUScheduler(
74 create_ec_vllm_config(
75 ec_role=ec_role,
76 tensor_parallel_size=tensor_parallel_size,
77 )
78 )
79
80
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
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):
92 """Pre-populate a ready cache entry backed by real blocks."""
93 s._cache.alloc(mm_hash, n_blocks)
94 s._cache.mark_ready(mm_hash)
95
96
97 def test_offload_reuse_cycle(monkeypatch):
98 s = _make_scheduler(monkeypatch)
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
105 assert s.ensure_cache_available(req, 0) is True
106 s.update_state_after_alloc(req, 0)
107 meta_a = s.build_connector_meta(scheduler_output=None)
108 assert "h1" in meta_a.saves
109 assert meta_a.loads == {}
110 # Not ready until the worker reports the save memcpy complete.
111 assert s.has_cache_item("h1") is False
112
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
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):
127 s = _make_scheduler(monkeypatch, ec_role="ec_producer")
128 assert s.has_cache_item("anything") is False
129 s.shutdown()
130
131
132 def test_connector_keys_on_identifier_not_mm_hash(monkeypatch):
133 """The connector must key the encoder cache on feature.identifier (what
134 has_cache_item is called with), NOT feature.mm_hash."""
135 s = _make_scheduler(monkeypatch)
136 req = _Request([_Feature("PROC_KEY", length=1, identifier="ENC_KEY")])
137 s.update_state_after_alloc(req, 0)
138 meta = s.build_connector_meta(scheduler_output=None)
139 assert "ENC_KEY" in meta.saves
140 assert "PROC_KEY" not in meta.saves
141 # Not ready until the completion report.
142 assert s.has_cache_item("ENC_KEY") is False
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()
148
149
150 def test_cpu_region_fifo_eviction(monkeypatch):
151 # Region holds exactly 2 one-block encodings.
152 s = _make_scheduler(monkeypatch, num_blocks=2)
153
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)
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
162 # Allocate C: must evict A (oldest) to make room.
163 s.update_state_after_alloc(_Request([_Feature("C", length=1)]), 0)
164 s.build_connector_meta(scheduler_output=None)
165 assert s.has_cache_item("A") is False # evicted
166 assert s.has_cache_item("B") is True
167
168
169 def test_multiple_mm_items_per_request(monkeypatch):
170 s = _make_scheduler(monkeypatch)
171 req = _Request([_Feature("h1", length=1), _Feature("h2", length=1)])
172 s.update_state_after_alloc(req, 0)
173 s.update_state_after_alloc(req, 1)
174 meta = s.build_connector_meta(scheduler_output=None)
175 assert set(meta.saves) == {"h1", "h2"}
176
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
182 # Now reload both.
183 s.update_state_after_alloc(req, 0)
184 s.update_state_after_alloc(req, 1)
185 meta2 = s.build_connector_meta(scheduler_output=None)
186 assert set(meta2.loads) == {"h1", "h2"}
187 s.shutdown()
188
189
190 def test_loads_only_serves_hashes_touched_this_step(monkeypatch):
191 s = _make_scheduler(monkeypatch)
192 _seed_cached(s, "a", n_blocks=2)
193 _seed_cached(s, "b", n_blocks=2)
194
195 s.update_state_after_alloc(_Request([_Feature("a")]), 0)
196 meta = s.build_connector_meta(scheduler_output=None)
197 assert "a" in meta.loads
198 assert "b" not in meta.loads
199 s.shutdown()
200
201
202 def test_repeated_reload_same_step_loads_once(monkeypatch):
203 """The same mm_hash requested twice in one step must appear in
204 meta.loads exactly once."""
205 s = _make_scheduler(monkeypatch)
206 _seed_cached(s, "a", n_blocks=2)
207
208 s.update_state_after_alloc(_Request([_Feature("a")]), 0)
209 s.update_state_after_alloc(_Request([_Feature("a")]), 0)
210 meta = s.build_connector_meta(scheduler_output=None)
211 assert list(meta.loads.keys()).count("a") == 1
212 s.shutdown()
213
214
215 def test_load_not_emitted_for_uncached_entry(monkeypatch):
216 """A feature that was never saved must not appear in meta.loads."""
217 s = _make_scheduler(monkeypatch)
218 s.update_state_after_alloc(_Request([_Feature("missing")]), 0)
219 meta = s.build_connector_meta(scheduler_output=None)
220 assert meta.loads == {}
221 s.shutdown()
222
223
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)
231
232 # Load "a" — pins it.
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
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
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
259
260 def test_load_needs_every_participant_before_unpin(monkeypatch):
261 """With two participating ranks, one report must not release the pin.
262
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)
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
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
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
294 # First dispatch, fully reported by both ranks → pin released.
295 s.update_state_after_alloc(_Request([_Feature("a")]), 0)
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
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
312 def test_region_full_skips_save_and_never_blocks(monkeypatch):
313 """When the region is fully occupied by pinned entries, new saves are
314 silently skipped and ensure_cache_available never blocks."""
315 s = _make_scheduler(monkeypatch, num_blocks=1)
316 _seed_cached(s, "pinned", n_blocks=1)
317 s._cache.pin("pinned")
318
319 req = _Request([_Feature("new", length=1)])
320 assert s.ensure_cache_available(req, 0) is True
321
322 s.update_state_after_alloc(req, 0)
323 meta = s.build_connector_meta(scheduler_output=None)
324 assert "new" not in meta.saves
325 assert s.has_cache_item("new") is False
326
327 s._cache.unpin("pinned")
328 s.shutdown()
329
330
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."""
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)
338 meta = s.build_connector_meta(scheduler_output=None)
339 assert "h1" in meta.saves
340 assert meta.loads == {}
341
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)
346 meta2 = s.build_connector_meta(scheduler_output=None)
347 assert meta2.loads == {}
348 s.shutdown()
349
350
351 def test_consumer_only_never_emits_saves(monkeypatch):
352 """A consumer-only scheduler must never populate meta.saves."""
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)])
357 s.update_state_after_alloc(req, 0)
358 meta = s.build_connector_meta(scheduler_output=None)
359 assert meta.saves == {}
360 assert "a" in meta.loads
361 s.shutdown()
362
363
364 def test_consumer_only_has_cache_item(monkeypatch):
365 """Consumer-only: has_cache_item works normally for ready entries."""
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
370 s.shutdown()
371
372
373 def test_save_not_emitted_for_already_cached_entry(monkeypatch):
374 """An entry already in the cache (from a prior save) must not trigger
375 a second allocation or appear in meta.saves again."""
376 s = _make_scheduler(monkeypatch)
377 req = _Request([_Feature("h1", length=1)])
378
379 # First encounter → save.
380 s.update_state_after_alloc(req, 0)
381 meta1 = s.build_connector_meta(scheduler_output=None)
382 assert "h1" in meta1.saves
383
384 # Second encounter (same step or later) → no save.
385 s.update_state_after_alloc(req, 0)
386 meta2 = s.build_connector_meta(scheduler_output=None)
387 assert "h1" not in meta2.saves
388 s.shutdown()
389
390
391 # ── completion reporting (update_connector_output) ──────────────────────────
392
393
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
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)
428 assert s.has_pending_push_work() is True # save not yet confirmed
429
430 s.update_connector_output(_WorkerOutput(saves=["h1"]))
431 assert s.has_pending_push_work() is False
432 s.shutdown()
433
434
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
442 s.update_state_after_alloc(_Request([_Feature("a")]), 0)
443 meta = s.build_connector_meta(scheduler_output=None)
444 assert s.has_pending_push_work() is True # pinned, awaiting unpin
445
446 s.update_connector_output(_WorkerOutput(loads=_load_ids(meta)))
447 assert s.has_pending_push_work() is False
448 s.shutdown()
449
450
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