main
py 629 lines 23.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Body handling across the EPD proxy's decode retries.
4
5 Exercises the REAL helpers loaded from the ``examples/`` proxy, so a future
6 change to them is what these tests catch.
7 """
8
9 import asyncio
10 import importlib.util
11 import sys
12 from pathlib import Path
13 from types import SimpleNamespace
14 from typing import Any
15
16 import httpx
17 import msgspec
18 import pytest
19 from aiohttp import web
20 from aiohttp.test_utils import TestServer
21
22 PROXY_REL = "examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py"
23
24
25 @pytest.fixture(scope="module")
26 def proxy():
27 path = Path(__file__).parents[4] / PROXY_REL
28 spec = importlib.util.spec_from_file_location("disagg_epd_proxy_retry", path)
29 assert spec is not None and spec.loader is not None
30 module = importlib.util.module_from_spec(spec)
31 sys.modules[spec.name] = module
32 spec.loader.exec_module(module)
33 return module
34
35
36 class _Response:
37 def __init__(self, params):
38 self._params = params
39
40 async def read(self):
41 return msgspec.json.encode({"kv_transfer_params": self._params})
42
43
44 def test_maybe_prefill_leaves_the_caller_body_untouched(proxy, monkeypatch):
45 """A decode retry re-enters this function with the body it was given.
46
47 Mutating that body in place let one attempt's `remote_block_ids` survive
48 into the next, so a retry whose prefill returns nothing sent decode blocks
49 the prefiller may already have freed.
50 """
51 served = [{"remote_block_ids": [1, 2]}, {}]
52
53 async def _stage(req_data, p_url, req_id, dp_rank=None):
54 assert "kv_transfer_params" not in req_data
55 return _Response(served.pop(0))
56
57 monkeypatch.setattr(proxy, "process_prefill_stage", _stage)
58
59 body = {"messages": [], "stream": False}
60 first = asyncio.run(proxy.maybe_prefill(body, "http://prefill", "r1"))
61 assert first["kv_transfer_params"] == {"remote_block_ids": [1, 2]}
62 assert "kv_transfer_params" not in body
63
64 second = asyncio.run(proxy.maybe_prefill(body, "http://prefill", "r1"))
65 assert "kv_transfer_params" not in second
66
67
68 class _EncoderResponse:
69 def __init__(self, params):
70 self.status = 200
71 self._params = params
72
73 async def read(self):
74 return msgspec.json.encode({"ec_transfer_params": self._params})
75
76 async def text(self):
77 return ""
78
79
80 class _EncoderSession:
81 """Serve one canned encoder reply per attempt."""
82
83 def __init__(self, replies):
84 self._replies = list(replies)
85
86 async def post(self, url, data=None, headers=None):
87 return _EncoderResponse(self._replies.pop(0))
88
89
90 @pytest.mark.parametrize("second_type", ["image_url", "audio_url", "input_audio"])
91 @pytest.mark.parametrize("transfer", ["example", "nixl", "mooncake"])
92 def test_partial_audio_metadata_falls_back_with_all_transfers(
93 proxy, monkeypatch, second_type, transfer
94 ):
95 """Never mix metadata-only audio with raw media, or lose EC identities."""
96 import copy
97
98 seen = []
99 reported = {}
100
101 class Session:
102 async def post(self, url, data=None, headers=None):
103 body = msgspec.json.decode(data)
104 seen.append(body)
105 item = body["messages"][0]["content"][0]
106 mm_hash = item["uuid"] + "-processed"
107 entry: dict[str, Any] = {
108 "metadata": {"audio_feature_lengths": [100]} if len(seen) == 1 else {},
109 "item_indices": [0],
110 }
111 if transfer == "nixl":
112 entry.update(peer_host="encoder", peer_port=1234, size_bytes=4096)
113 reported[mm_hash] = entry
114 return _EncoderResponse({mm_hash: entry})
115
116 second = (
117 {"type": second_type, "input_audio": {"data": "large", "format": "wav"}}
118 if second_type == "input_audio"
119 else {"type": second_type, second_type: {"url": "large"}}
120 )
121 body = {
122 "messages": [
123 {
124 "role": "user",
125 "content": [
126 {
127 "type": "audio_url",
128 "audio_url": {"url": "small"},
129 "uuid": "user-audio",
130 },
131 second,
132 ],
133 }
134 ],
135 "mm_processor_kwargs": {"sampling_rate": 16000},
136 }
137 original = copy.deepcopy(body)
138 monkeypatch.setattr(proxy, "encode_session", Session())
139 monkeypatch.setattr(proxy, "NO_REWRITE", False)
140 consumer = "tcp://consumer:1" if transfer == "mooncake" else None
141 prepared, _, _ = asyncio.run(
142 proxy.prepare_for_decode(body, "r", ["http://encoder"], "", consumer)
143 )
144 assert body == original
145 assert prepared["mm_processor_kwargs"] == body["mm_processor_kwargs"]
146 content = prepared["messages"][0]["content"]
147 assert [item["type"] for item in content] == ["audio_url", second_type]
148 assert content[0]["uuid"] == "user-audio"
149 for item, encoded in zip(content, seen):
150 assert item == encoded["messages"][0]["content"][0]
151 params = prepared["ec_transfer_params"]
152 for mm_hash, entry in reported.items():
153 assert params[mm_hash] == entry
154 if transfer == "mooncake":
155 assert params["ec_items"] == [
156 {
157 "mm_hash": mm_hash,
158 "transfer_id": encoded["ec_transfer_params"]["ec_items"][0][
159 "transfer_id"
160 ],
161 }
162 for mm_hash, encoded in zip(reported, seen)
163 ]
164
165
166 @pytest.mark.parametrize("no_rewrite", [False, True])
167 @pytest.mark.parametrize(
168 "batch_size, expected_sizes", [(0, [3, 3]), (1, [1] * 6), (2, [2, 2, 1, 1])]
169 )
170 def test_image_batches_preserve_item_identity(
171 proxy, monkeypatch, no_rewrite, batch_size, expected_sizes
172 ):
173 """Rehashed and repeated images retain metadata and per-occurrence transfers."""
174 from vllm.distributed.ec_transfer.ec_connector.mooncake.scheduler import (
175 ECMooncakeScheduler,
176 )
177
178 seen = []
179
180 class Session:
181 async def post(self, url, data=None, headers=None):
182 body = msgspec.json.decode(data)
183 seen.append((url, body))
184 params: dict[str, dict[str, Any]] = {}
185 for index, item in enumerate(body["messages"][0]["content"]):
186 image = item["image_url"]["url"]
187 entry = params.setdefault(
188 image + "-processed",
189 {
190 "metadata": {"image_grid_thw": [1, 2, ord(image)]},
191 "item_indices": [],
192 "peer_port": 1234,
193 },
194 )
195 entry["item_indices"].append(index)
196 # Response ordering must not affect the image-to-metadata mapping.
197 return _EncoderResponse(dict(reversed(list(params.items()))))
198
199 monkeypatch.setattr(proxy, "encode_session", Session())
200 monkeypatch.setattr(proxy, "NO_REWRITE", no_rewrite)
201 monkeypatch.setattr(proxy, "ENCODER_MAX_BATCH_SIZE", batch_size)
202 monkeypatch.setattr(proxy, "encoder_rr_idx", 0)
203 images = ["A", "A", "C", "D", "A", "F"]
204 body = {
205 "messages": [
206 {
207 "role": "user",
208 "content": [
209 {"type": "image_url", "image_url": {"url": image}}
210 for image in images
211 ],
212 }
213 ],
214 "mm_processor_kwargs": {"max_pixels": 262144},
215 }
216 meta, handles = asyncio.run(
217 proxy.fanout_encoder_primer(
218 body, ["http://e0", "http://e1"], "r", "tcp://consumer:1"
219 )
220 )
221 assert [len(b["messages"][0]["content"]) for _, b in seen] == expected_sizes
222 assert (
223 len(
224 {
225 x["transfer_id"]
226 for _, b in seen
227 for x in b["ec_transfer_params"]["ec_items"]
228 }
229 )
230 == 6
231 )
232 for url, batch in seen:
233 assert batch["mm_processor_kwargs"] == body["mm_processor_kwargs"]
234 for item in batch["messages"][0]["content"]:
235 assert item["image_url"]["url"] in (
236 {"A", "C"} if url.startswith("http://e0/") else {"A", "D", "F"}
237 )
238 assert [meta[i]["image_grid_thw"][-1] for i in range(6)] == list(map(ord, images))
239 assert [meta[i]["ec_mm_hash"] for i in range(6)] == [
240 x + "-processed" for x in images
241 ]
242 assert handles["A-processed"]["peer_port"] == 1234
243 assert "ec_transfer_params" not in body
244
245 transfers_by_encoder: list[list[dict[str, str]]] = [[], []]
246 for url, batch in seen:
247 rank = 0 if url.startswith("http://e0/") else 1
248 transfers_by_encoder[rank].extend(batch["ec_transfer_params"]["ec_items"])
249 expected_transfers = [
250 transfers_by_encoder[i % 2][i // 2]["transfer_id"] for i in range(len(images))
251 ]
252 params = (
253 handles
254 if no_rewrite
255 else proxy.rewrite_for_decode(body, meta)["ec_transfer_params"]
256 )
257 request = SimpleNamespace(
258 ec_transfer_params=params,
259 mm_features=[
260 SimpleNamespace(identifier=image + "-processed") for image in images
261 ],
262 )
263 assert [
264 ECMooncakeScheduler._request_transfer_id(request, i) for i in range(len(images))
265 ] == expected_transfers
266
267
268 def test_batch_rejects_ambiguous_metadata(proxy, monkeypatch):
269 monkeypatch.setattr(
270 proxy,
271 "encode_session",
272 _EncoderSession([{"unknown": {"metadata": {"image_grid_thw": [1, 2, 2]}}}]),
273 )
274 body = {
275 "messages": [
276 {
277 "content": [
278 {"type": "image_url", "image_url": {"url": image}}
279 for image in ["A", "B"]
280 ]
281 }
282 ]
283 }
284 with pytest.raises(proxy.HTTPException, match="cannot be matched"):
285 asyncio.run(proxy.fanout_encoder_primer(body, ["http://e0"], "r"))
286
287
288 def test_a_decode_retry_does_not_inherit_the_previous_handles(proxy, monkeypatch):
289 """The retry loop re-enters `prepare_for_decode` with the same body.
290
291 Recording the encoder's connector handles on that body in place let
292 attempt 1's handle survive into attempt 2, so a second encode that
293 reported nothing still sent decode a handle on an embedding the encoder
294 no longer publishes -- the exact state the retry exists to leave behind.
295 """
296 handle = {"metadata": {"image_grid_thw": [1, 2, 2]}, "peer_port": 1234}
297 monkeypatch.setattr(
298 proxy,
299 "encode_session",
300 _EncoderSession([{"encoder-side-hash": handle}, {}]),
301 )
302
303 async def _no_prefill(req_data, p_url, req_id, dp_rank=None):
304 return req_data
305
306 monkeypatch.setattr(proxy, "maybe_prefill", _no_prefill)
307
308 body = {
309 "messages": [
310 {
311 "role": "user",
312 "content": [{"type": "image_url", "image_url": {"url": "image"}}],
313 }
314 ],
315 "stream": False,
316 }
317 args = ("r1", ["http://encoder"], "http://prefill", None)
318
319 first, _, _ = asyncio.run(proxy.prepare_for_decode(body, *args))
320 reported = first["ec_transfer_params"]
321 assert {key: value for key, value in reported.items() if key != "ec_items"} == {
322 "encoder-side-hash": handle
323 }
324 assert "ec_transfer_params" not in body
325
326 # Attempt 2's encode reports nothing: decode must be told nothing.
327 second, _, _ = asyncio.run(proxy.prepare_for_decode(body, *args))
328 assert "ec_transfer_params" not in second
329
330
331 def test_raw_media_keeps_encoder_transfer_identity(proxy, monkeypatch):
332 handle = {"metadata": {"image_grid_thw": [1, 2, 2]}}
333 monkeypatch.setattr(proxy, "NO_REWRITE", True)
334 monkeypatch.setattr(
335 proxy, "encode_session", _EncoderSession([{"encoded-hash": handle}])
336 )
337 body = {
338 "messages": [
339 {
340 "role": "user",
341 "content": [{"type": "image_url", "image_url": {"url": "image"}}],
342 }
343 ]
344 }
345 prepared, _, _ = asyncio.run(
346 proxy.prepare_for_decode(
347 body, "request", ["http://encoder"], "", "tcp://consumer:1"
348 )
349 )
350 assert prepared["messages"] == body["messages"]
351 assert "ec_transfer_params" not in body
352 params = prepared["ec_transfer_params"]
353 assert params["encoded-hash"] == handle
354 assert params["ec_items"][0]["mm_hash"] == "encoded-hash"
355 assert params["ec_items"][0]["transfer_id"]
356
357
358 @pytest.mark.asyncio
359 @pytest.mark.parametrize("stream", [False, True])
360 @pytest.mark.parametrize("prefill", [False, True])
361 @pytest.mark.parametrize("dynamic", [False, True])
362 @pytest.mark.parametrize(
363 "request_options",
364 [
365 {},
366 {
367 "mm_processor_kwargs": {"max_pixels": 262144},
368 "media_io_kwargs": {"image": {"image_mode": "RGB"}},
369 "priority": -3,
370 "session_id": "session-123",
371 },
372 ],
373 )
374 async def test_http_roundtrip_preserves_payload_and_response_bytes(
375 proxy, monkeypatch, stream, prefill, dynamic, request_options
376 ):
377 """Preserve media and scheduling context across HTTP hops and retries."""
378 seen: dict[str, list[dict]] = {"encode": [], "prefill": [], "decode": []}
379 prefix = b'data: {"content":"'
380 # Split a multibyte character at the old 1024-byte forwarding boundary.
381 expected = (
382 prefix + b"x" * (1023 - len(prefix)) + '图片🌍"}\n\ndata: [DONE]\n\n'.encode()
383 if stream
384 else '{ "choices": [{"message": {"content": "图片🌍"}}] }\n'.encode()
385 )
386
387 async def backend(request):
388 stage = request.match_info["stage"]
389 if dynamic:
390 consumer = "prefill" if prefill else "decode"
391 expected_rank = "0" if stage == consumer else None
392 assert request.headers.get("X-data-parallel-rank") == expected_rank
393 assert request.content_type == "application/json"
394 body = await request.json()
395 seen[stage].append(body)
396 if stage == "encode":
397 params: dict[str, dict[str, Any]] = {}
398 for index, item in enumerate(body["messages"][0]["content"]):
399 mm_hash = item["uuid"]
400 if body.get("mm_processor_kwargs"):
401 mm_hash += "-processed"
402 entry = params.setdefault(
403 mm_hash,
404 {
405 "metadata": {"image_grid_thw": [[1, 2, 2]]},
406 "peer_port": len(seen[stage]),
407 "item_indices": [],
408 },
409 )
410 entry["item_indices"].append(index)
411 return web.json_response({"ec_transfer_params": params})
412 if stage == "prefill":
413 assert body["max_tokens"] == 1 and body["stream"] is False
414 return web.json_response(
415 {"kv_transfer_params": {"remote_block_ids": [len(seen[stage])]}}
416 )
417 if len(seen[stage]) == 1:
418 return web.Response(status=500, text="retry")
419 return web.Response(
420 body=expected,
421 content_type="text/event-stream" if stream else "application/json",
422 )
423
424 backend_app = web.Application()
425 backend_app.router.add_post("/{stage}/v1/chat/completions", backend)
426 async with TestServer(backend_app) as server:
427 for stage, field in [
428 ("encode", "e_urls"),
429 ("prefill", "p_urls"),
430 ("decode", "d_urls"),
431 ]:
432 urls = [str(server.make_url(f"/{stage}"))]
433 if stage == "prefill" and not prefill:
434 urls = []
435 monkeypatch.setattr(proxy.app.state, field, urls, raising=False)
436 monkeypatch.setattr(proxy.app.state, "d_ec_urls", [], raising=False)
437 monkeypatch.setattr(proxy.app.state, "ec_consumer_dp_size", 1, raising=False)
438 monkeypatch.setattr(proxy, "DECODE_RETRIES", 1)
439 monkeypatch.setattr(proxy, "NO_REWRITE", False)
440 target_app = proxy.app
441 if dynamic:
442 monkeypatch.setenv("ADMIN_API_KEY", "test-key")
443 target_app = proxy.build_app(proxy.EPDProxyConfig(probe_interval=0))
444 item = {"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}}
445 body = {
446 "model": "test",
447 "messages": [
448 {
449 "role": "user",
450 "content": [
451 {"type": "text", "text": "描述图片🌍"},
452 item,
453 item,
454 ],
455 }
456 ],
457 "stream": stream,
458 "temperature": 0.01,
459 "max_tokens": 32,
460 "seed": 42,
461 "structured_outputs": {"choice": ["A", "B"]},
462 **request_options,
463 }
464 await proxy.on_startup()
465 try:
466 async with httpx.AsyncClient(
467 transport=httpx.ASGITransport(app=target_app), base_url="http://proxy"
468 ) as client:
469 if dynamic:
470 registrations = [("encode", "encode")]
471 registrations += (
472 [("prefill", "prefill"), ("decode", "decode")]
473 if prefill
474 else [("prefill_decode", "decode")]
475 )
476 for role, stage in registrations:
477 consumer = "prefill" if prefill else "decode"
478 registered = await client.post(
479 "/instances",
480 headers={"X-API-Key": "test-key"},
481 json={
482 "role": role,
483 "url": str(server.make_url(f"/{stage}")),
484 "dp_size": 2 if stage == consumer else 1,
485 },
486 )
487 assert registered.status_code == 200
488 response = await client.post("/v1/chat/completions", json=body)
489 if dynamic:
490 removed = await client.delete(
491 "/instances",
492 headers={"X-API-Key": "test-key"},
493 params={"url": str(server.make_url("/decode"))},
494 )
495 assert removed.json() == {"removed": True}
496 unavailable = await client.post("/v1/chat/completions", json=body)
497 assert unavailable.status_code == 503
498 assert response.status_code == 200
499 assert response.content == expected
500 assert response.headers["content-type"].startswith(
501 "text/event-stream" if stream else "application/json"
502 )
503 finally:
504 await proxy.on_shutdown()
505
506 assert len(seen["encode"]) == 2
507 assert len(seen["decode"]) == 2
508 for requests in seen.values():
509 for forwarded in requests:
510 for key in (
511 "mm_processor_kwargs",
512 "media_io_kwargs",
513 "priority",
514 "session_id",
515 ):
516 assert forwarded.get(key) == body.get(key)
517 final = seen["decode"][-1]
518 for key in [
519 "model",
520 "stream",
521 "temperature",
522 "max_tokens",
523 "seed",
524 "structured_outputs",
525 ]:
526 assert final[key] == body[key]
527 content = final["messages"][0]["content"]
528 assert content[0] == body["messages"][0]["content"][0]
529 for reference in content[1:]:
530 assert reference == {
531 "type": "image_embeds",
532 "image_embeds": {"image_grid_thw": [1, 2, 2]},
533 "uuid": proxy.content_uuid(item),
534 }
535 mm_hash = proxy.content_uuid(item)
536 if request_options:
537 mm_hash += "-processed"
538 assert set(final["ec_transfer_params"]) - {"ec_items"} == {mm_hash}
539 assert final["ec_transfer_params"][mm_hash]["peer_port"] == 2
540 if prefill:
541 assert final["kv_transfer_params"] == {"remote_block_ids": [2]}
542
543
544 @pytest.mark.parametrize("server_keep_alive", ["0.1", "1", "5", "2", "30"])
545 def test_pooled_connections_are_retired_before_the_server_closes_them(
546 proxy, monkeypatch, server_keep_alive
547 ):
548 """The proxy must not hand a request a connection the server has dropped.
549
550 vLLM closes idle keep-alive connections at `VLLM_HTTP_TIMEOUT_KEEP_ALIVE`
551 seconds while aiohttp pools them for 15, so a slow hop leaves a dead
552 connection in the pool. The next request fails with
553 ServerDisconnectedError and the server logs nothing, because it closed the
554 socket before the request arrived.
555 """
556 monkeypatch.setenv("VLLM_HTTP_TIMEOUT_KEEP_ALIVE", server_keep_alive)
557 monkeypatch.setattr(proxy.app.state, "p_urls", [], raising=False)
558
559 asyncio.run(proxy.on_startup())
560 try:
561 # No public accessor for the pool's idle timeout.
562 pooled_for = proxy.encode_session.connector._keepalive_timeout
563 assert pooled_for < float(server_keep_alive)
564 assert proxy.decode_session.connector._keepalive_timeout == pooled_for
565 finally:
566 asyncio.run(proxy.on_shutdown())
567
568
569 @pytest.mark.parametrize(
570 "no_rewrite, transfer",
571 [(False, None), (True, None), (False, "push"), (False, "handle")],
572 )
573 def test_video_audio_fallback(proxy, monkeypatch, no_rewrite, transfer):
574 """Keep raw video and neighboring images, but never discard transfer handles."""
575 import copy
576
577 from vllm.distributed.ec_transfer.ec_connector.utils import collect_ec_item_metadata
578
579 video = {"type": "video_url", "video_url": {"url": "video-with-audio"}}
580 images = [{"type": "image_url", "image_url": {"url": key}} for key in ("A", "B")]
581 body: dict[str, Any] = {
582 "messages": [{"content": [images[0], video, images[1]]}],
583 "mm_processor_kwargs": {"use_audio_in_video": True},
584 }
585 original = copy.deepcopy(body)
586 # Collector indices describe two processed features from one video item.
587 video_metadata = collect_ec_item_metadata(
588 [
589 SimpleNamespace(identifier=key, modality=key, data=None)
590 for key in ("video", "audio")
591 ],
592 SimpleNamespace(fields_for=lambda _: set()),
593 )
594 if transfer == "handle":
595 video_metadata["audio"]["transfer_id"] = "reservation"
596 image_metadata = {
597 key: {"metadata": {"image_grid_thw": [1, 2, size]}, "item_indices": [i]}
598 for i, (key, size) in enumerate((("A", 2), ("B", 4)))
599 }
600 # Images form the first group, the singleton video the second.
601 monkeypatch.setattr(proxy, "ENCODER_MAX_BATCH_SIZE", 0)
602 monkeypatch.setattr(proxy, "NO_REWRITE", no_rewrite)
603 replies = _EncoderSession([image_metadata, video_metadata])
604 monkeypatch.setattr(proxy, "encode_session", replies)
605 consumer = "tcp://consumer:1" if transfer == "push" else None
606 preparation = proxy.prepare_for_decode(body, "r", ["http://e0"], "", consumer)
607 if transfer:
608 with pytest.raises(proxy.HTTPException, match="cannot be matched"):
609 asyncio.run(preparation)
610 return
611
612 prepared, _, _ = asyncio.run(preparation)
613 assert body == original
614 assert prepared["mm_processor_kwargs"] == body["mm_processor_kwargs"]
615 final = prepared["messages"][0]["content"]
616 assert final[1] == video
617 handles = prepared["ec_transfer_params"]
618 assert not video_metadata.keys() & handles.keys()
619 if no_rewrite:
620 assert final == original["messages"][0]["content"]
621 assert "ec_items" not in handles
622 else:
623 for position, image, key in ((0, images[0], "A"), (2, images[1], "B")):
624 assert final[position] == {
625 "type": "image_embeds",
626 "image_embeds": image_metadata[key]["metadata"],
627 "uuid": proxy.content_uuid(image),
628 }
629 assert [item["mm_hash"] for item in handles["ec_items"]] == ["A", "B"]