main
py 432 lines 14.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import time
4 import uuid
5
6 import pytest
7 import torch
8
9 import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
10 from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
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 from vllm.multimodal.inputs import (
16 MultiModalFieldElem,
17 MultiModalKwargsItem,
18 MultiModalSharedField,
19 )
20
21 _N, _BS, _HID, _ES = 16, 64, 32, 2
22
23
24 class _Pos:
25 def __init__(self, offset, length):
26 self.offset, self.length = offset, length
27
28
29 class _Feature:
30 def __init__(self, mm_hash, length=1, data=None):
31 self.mm_hash = mm_hash
32 self.identifier = mm_hash
33 self.mm_position = _Pos(0, length)
34 self.data = data
35 self.modality = "image"
36
37
38 class _Request:
39 def __init__(self, features, params=None, req_id="r1"):
40 self.mm_features = features
41 self.request_id = req_id
42 self.ec_transfer_params = params
43
44
45 class _FakeResults:
46 def __init__(self):
47 self.completed = set()
48 self.tombstoned = set()
49 self.quarantined = set()
50 self.retryable = set()
51 self.settled = []
52
53
54 class _FakeSession:
55 """Records started transfers; lets the test flip them to completed."""
56
57 def __init__(self):
58 self.started = []
59 self._results = _FakeResults()
60
61 def start_xfer(self, mm_hash, block_indices, deadline):
62 self.started.append(mm_hash)
63
64 def poll(self, messages, now):
65 pass
66
67 def take_results(self):
68 r, self._results = self._results, _FakeResults()
69 return r
70
71 def close(self):
72 pass
73
74
75 class _FakeTransport:
76 """Stand-in for ZmqClientTransport so _poll_step runs without ZMQ."""
77
78 def poll(self):
79 return {}
80
81 def poll_dead(self):
82 return []
83
84 def close(self):
85 pass
86
87
88 def _consumer_sched(monkeypatch):
89 def _region(cfg):
90 return ECSharedRegion(
91 engine_id="eng-" + str(uuid.uuid4()), num_blocks=_N, block_size_bytes=_BS
92 )
93
94 monkeypatch.setattr(sched_mod, "create_ec_shared_region", _region)
95
96 # ec_enable_nixl defaults to False, so this builds gate-off; the fields
97 # below flip on the NIXL consumer state directly.
98 s = ECCPUScheduler(create_ec_vllm_config(ec_role="ec_consumer"))
99 # Turn on NIXL consumer state without constructing real transports.
100 s._nixl_enabled = True
101 s._transport = _FakeTransport()
102 s._data = None
103 s._compat_hash = "c"
104 # _setup_nixl normally computes these from model_config; set them
105 # directly since this helper builds gate-off then flips fields on.
106 s._hidden_dim = _HID
107 s._element_size = _ES
108 s._metadata_resolver._cache["image"] = {"image_grid_thw"}
109 return s
110
111
112 def _params(mm_hash, length):
113 return {
114 mm_hash: {
115 "peer_host": "h",
116 "peer_port": 1,
117 "size_bytes": length * _HID * _ES,
118 }
119 }
120
121
122 def test_new_remote_read_defers_then_completes(monkeypatch):
123 s = _consumer_sched(monkeypatch)
124 fake = _FakeSession()
125
126 # Route _start_xfer to our fake session instead of real ZMQ/NIXL, but
127 # still reserve a real not-ready cache entry so mark_ready works.
128 def _fake_start(mm_hash, info, size):
129 entry = s._cache.alloc(mm_hash, 1)
130 assert entry is not None
131 fake.started.append(mm_hash)
132 return True
133
134 monkeypatch.setattr(s, "_start_xfer", _fake_start)
135 req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
136
137 # Step 1: unseen remote item -> read started, request deferred.
138 assert s.ensure_cache_available(req, 0) is False
139 assert "h1" in s._in_flight
140 assert "h1" in fake.started
141 s.build_connector_meta(scheduler_output=None) # re-arms _first_in_batch
142
143 # Session reports completion this step.
144 fake._results.completed.add("h1")
145 s._sessions[("h", 1)] = fake
146
147 # Step 2: poll drains completion -> entry marked ready, request admitted.
148 assert s.ensure_cache_available(req, 0) is True
149 assert "h1" not in s._in_flight
150 assert "h1" in s._step_completed
151 entry = s._cache.get("h1")
152 assert entry is not None and entry.ready
153 # The scheduler now allocates encoder input; the ready entry loads through
154 # the same local path as a natively cached encoding.
155 s.update_state_after_alloc(req, 0)
156 meta = s.build_connector_meta(scheduler_output=None)
157 assert "h1" in meta.loads
158 assert "h1" not in s._step_completed # cleared by promote
159
160 # Step 3: still cached -> admitted directly, no new transfer.
161 assert s.ensure_cache_available(req, 0) is True
162 s.shutdown()
163
164
165 @pytest.mark.parametrize("params", [None, {"x": 123}])
166 def test_no_remote_announcement_is_ready(monkeypatch, params):
167 s = _consumer_sched(monkeypatch)
168 assert s.ensure_cache_available(_Request([_Feature("x")], params=params), 0)
169 assert s.take_unavailable_requests() == set()
170 s.shutdown()
171
172
173 @pytest.mark.parametrize("payload", [None, "metadata", "pixels", "embeds", "shm"])
174 def test_failed_read_falls_back_only_with_local_input(monkeypatch, payload):
175 """A remote miss is fatal only when local model input is unavailable."""
176 s = _consumer_sched(monkeypatch)
177 fake = _FakeSession()
178
179 def _fake_start(mm_hash, info, size):
180 entry = s._cache.alloc(mm_hash, 1)
181 assert entry is not None
182 fake.started.append(mm_hash)
183 return True
184
185 monkeypatch.setattr(s, "_start_xfer", _fake_start)
186 fields = {
187 "metadata": {"image_grid_thw": torch.tensor([1, 2, 2])},
188 "pixels": {"pixel_values": torch.ones(1, 3)},
189 "embeds": {"image_embeds": torch.ones(1, _HID)},
190 "shm": {"address": 123, "monotonic_id": 1},
191 }
192 data = (
193 MultiModalKwargsItem(
194 {
195 key: MultiModalFieldElem(value, MultiModalSharedField(batch_size=1))
196 for key, value in fields[payload].items()
197 }
198 )
199 if payload is not None
200 else None
201 )
202 req = _Request([_Feature("h1", 1, data)], params=_params("h1", 1))
203
204 # Step 1: start the read.
205 assert s.ensure_cache_available(req, 0) is False
206 s.build_connector_meta(scheduler_output=None)
207
208 fake._results.tombstoned.add("h1")
209 s._sessions[("h", 1)] = fake
210
211 can_fallback = payload in ("pixels", "embeds", "shm")
212 assert s.ensure_cache_available(req, 0) is can_fallback
213 assert s._cache.get("h1") is None
214 assert "h1" not in s._in_flight
215 assert s.take_unavailable_requests() == (set() if can_fallback else {"r1"})
216 # Drained by the read, so the scheduler cannot abort it twice.
217 assert s.take_unavailable_requests() == set()
218 if can_fallback:
219 # A later scheduling step must not retry the failed remote source.
220 s.build_connector_meta(scheduler_output=None)
221 assert s.ensure_cache_available(req, 0)
222 assert fake.started == ["h1"]
223 s.shutdown()
224
225
226 @pytest.mark.parametrize("retry", [False, True], ids=["in-flight", "retryable"])
227 def test_remote_wait_budget_survives_retries_and_long_steps(monkeypatch, retry):
228 """Neither retries nor delayed scheduling may restart the request budget."""
229 s = _consumer_sched(monkeypatch)
230 fake = _FakeSession()
231 s._sessions[("h", 1)] = fake
232
233 def start(mm_hash, info, size):
234 assert s._cache.alloc(mm_hash, 1) is not None
235 fake.started.append(mm_hash)
236 return True
237
238 monkeypatch.setattr(s, "_start_xfer", start)
239 now = [1000.0]
240 monkeypatch.setattr(time, "monotonic", lambda: now[0])
241 req = _Request([_Feature("h1")], params=_params("h1", 1))
242 assert not s.ensure_cache_available(req, 0)
243 for elapsed in (20, 40, 121):
244 now[0] = 1000.0 + elapsed
245 if retry:
246 fake._results.retryable.add("h1")
247 s.build_connector_meta(scheduler_output=None)
248 assert not s.ensure_cache_available(req, 0)
249 assert s.take_unavailable_requests() == ({"r1"} if elapsed > 60 else set())
250 assert len(fake.started) == (3 if retry else 1)
251 s.shutdown()
252
253
254 def test_retryable_read_re_requests_without_admitting(monkeypatch):
255 """A not-ready NACK defers the request and re-requests the read.
256
257 Unlike a tombstone it never admits the request, because an admitted request
258 whose media was rewritten away upstream has no embedding and no way to
259 recompute one.
260 """
261 s = _consumer_sched(monkeypatch)
262 fake = _FakeSession()
263
264 def _fake_start(mm_hash, info, size):
265 entry = s._cache.alloc(mm_hash, 1)
266 assert entry is not None
267 fake.started.append(mm_hash)
268 return True
269
270 monkeypatch.setattr(s, "_start_xfer", _fake_start)
271 req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
272
273 assert s.ensure_cache_available(req, 0) is False
274 s.build_connector_meta(scheduler_output=None)
275
276 # Producer's save has not landed: retryable, not tombstoned.
277 fake._results.retryable.add("h1")
278 s._sessions[("h", 1)] = fake
279
280 # The entry is released and the read re-issued in the same step, so the
281 # request stays deferred rather than being admitted unfetched.
282 assert s.ensure_cache_available(req, 0) is False
283 assert "h1" in s._in_flight
284 assert "h1" not in s._tombstones
285 assert fake.started.count("h1") == 2
286 s.shutdown()
287
288
289 @pytest.mark.parametrize(
290 "size_fields",
291 [{"size_bytes": 999}, {"size_bytes": None}, {"size_bytes": "big"}, {}],
292 ids=["mismatch", "null-size", "text-size", "no-size"],
293 )
294 def test_invalid_remote_size_fails_the_request(monkeypatch, size_fields):
295 """Invalid remote sizes fail the request without raising in the scheduler."""
296 s = _consumer_sched(monkeypatch)
297 announced = {"peer_host": "h", "peer_port": 1, **size_fields}
298 req = _Request([_Feature("h1", 1)], params={"h1": announced})
299 assert s.ensure_cache_available(req, 0) is False
300 assert s.take_unavailable_requests() == {"r1"}
301 assert "h1" not in s._in_flight
302 assert s._cache.get("h1") is None
303 s.shutdown()
304
305
306 def test_deferral_budget_is_per_request(monkeypatch):
307 """Shared hashes have independent wait budgets and cancellation cleanup."""
308 s = _consumer_sched(monkeypatch)
309 monkeypatch.setattr(s._cache, "alloc", lambda key, n: None)
310 params = _params("h1", 1)
311 old = _Request([_Feature("h1", 1)], params=params, req_id="old")
312 new = _Request([_Feature("h1", 1)], params=params, req_id="new")
313
314 now = [1000.0]
315 monkeypatch.setattr(time, "monotonic", lambda: now[0])
316 assert s.ensure_cache_available(old, 0) is False
317 now[0] += sched_mod._ADMIT_DEFER_TIMEOUT_S - 1
318 assert s.ensure_cache_available(new, 0) is False
319 assert s.take_unavailable_requests() == set()
320
321 now[0] += 2
322 # `old` is now past its budget; `new` is not.
323 assert s.ensure_cache_available(new, 0) is False
324 assert s.take_unavailable_requests() == set()
325 assert s.request_finished(new) == (False, None)
326 assert set(s._deferred_since) == {("old", "h1")}
327 assert s.ensure_cache_available(old, 0) is False
328 assert s.take_unavailable_requests() == {"old"}
329 assert "h1" not in s._in_flight
330 s.shutdown()
331
332
333 def test_metadata_only_entry_admits_without_transfer(monkeypatch):
334 # A producer that reported placeholder metadata but had no cache entry
335 # to transfer omits peer_host/peer_port/size_bytes entirely; the consumer
336 # must fall back to local compute rather than treating that as a size
337 # mismatch.
338 s = _consumer_sched(monkeypatch)
339 metadata_only = {"h1": {"image_grid_thw": [1, 2, 3]}}
340 req = _Request([_Feature("h1", 1)], params=metadata_only)
341 assert s.ensure_cache_available(req, 0) is True
342 assert "h1" not in s._in_flight
343 assert s._cache.get("h1") is None
344 s.shutdown()
345
346
347 def test_orphan_not_ready_entry_defers_no_realloc(monkeypatch):
348 # Post-quarantine, post-tombstone-consumed orphan: a not-ready cache
349 # entry exists for the mm_hash, but the hash is tracked in none of
350 # _in_flight / _step_completed / _tombstones. Its blocks are held by a
351 # quarantined/settling DMA and must not be re-allocated.
352 s = _consumer_sched(monkeypatch)
353 entry = s._cache.alloc("h1", 1)
354 assert entry is not None and not entry.ready
355 assert "h1" not in s._in_flight
356 assert "h1" not in s._step_completed
357 assert "h1" not in s._tombstones
358
359 # Spy on alloc: record calls, delegate to the real allocator (which
360 # asserts on a duplicate key). A re-alloc for "h1" is the bug.
361 real_alloc = s._cache.alloc
362 calls: list[str] = []
363
364 def _spy_alloc(key, n):
365 calls.append(key)
366 return real_alloc(key, n)
367
368 monkeypatch.setattr(s._cache, "alloc", _spy_alloc)
369 req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
370
371 # Must not raise; the request is deferred while the DMA settles, the
372 # orphan entry is untouched, and alloc is never called again.
373 assert s.ensure_cache_available(req, 0) is False
374 assert calls == []
375 assert s._cache.get("h1") is entry
376 assert not entry.ready
377 assert s.take_unavailable_requests() == set()
378 s.shutdown()
379
380
381 def test_already_computed_feature_admits_without_transfer(monkeypatch):
382 # A feature entirely within num_computed_tokens is skipped by admit; no
383 # cache lookup/alloc or transfer should be triggered for it.
384 s = _consumer_sched(monkeypatch)
385 calls: list[str] = []
386 real_alloc = s._cache.alloc
387
388 def _spy_alloc(key, n):
389 calls.append(key)
390 return real_alloc(key, n)
391
392 monkeypatch.setattr(s._cache, "alloc", _spy_alloc)
393 # Feature spans [0, 4); with num_computed_tokens=4 it is fully computed.
394 req = _Request([_Feature("h1", 4)], params=_params("h1", 4))
395
396 assert s.ensure_cache_available(req, 4) is True
397 assert "h1" not in s._in_flight
398 assert calls == []
399 s.shutdown()
400
401
402 def test_local_ready_entry_admits_without_transfer(monkeypatch):
403 # A READY entry already present in the cache is a local hit: admit
404 # returns True and no new transfer is started for that mm_hash.
405 s = _consumer_sched(monkeypatch)
406 entry = s._cache.alloc("h1", 1)
407 assert entry is not None
408 s._cache.mark_ready("h1")
409 req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
410
411 assert s.ensure_cache_available(req, 0) is True
412 assert "h1" not in s._in_flight
413 s.shutdown()
414
415
416 def test_in_flight_hash_defers_without_second_transfer(monkeypatch):
417 # A mm_hash already tracked in _in_flight must defer the request rather
418 # than starting a duplicate transfer.
419 s = _consumer_sched(monkeypatch)
420 started: list[str] = []
421
422 def _spy_start_xfer(mm_hash, info, size):
423 started.append(mm_hash)
424 return True
425
426 monkeypatch.setattr(s, "_start_xfer", _spy_start_xfer)
427 s._in_flight.add("h1")
428 req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
429
430 assert s.ensure_cache_available(req, 0) is False
431 assert started == []
432 s.shutdown()