main
py 202 lines 6.97 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """NIXL request failure isolation with real E/PD servers and an injected NACK.
4
5 Requires two CUDA GPUs and NIXL. Set MODEL to override the small default model.
6 """
7
8 import asyncio
9 import copy
10 import json
11 import os
12 import uuid
13 from pathlib import Path
14
15 import aiohttp
16 import msgspec
17 import pytest
18 import zmq
19 import zmq.asyncio
20 from PIL import Image
21
22 from examples.disaggregated.disaggregated_encoder.disagg_epd_proxy import (
23 rewrite_for_decode,
24 )
25 from tests.utils import RemoteOpenAIServer
26 from vllm.distributed.ec_transfer.ec_connector.cpu.protocol import (
27 XferAck,
28 XferReq,
29 XferStatus,
30 )
31 from vllm.multimodal.utils import encode_image_url
32 from vllm.platforms import current_platform
33 from vllm.utils.network_utils import get_open_port
34
35 MODEL = os.environ.get("MODEL", "Qwen/Qwen2.5-VL-3B-Instruct")
36
37
38 @pytest.fixture(scope="module")
39 def servers():
40 pytest.importorskip("nixl")
41 if not current_platform.is_cuda() or current_platform.device_count() < 2:
42 pytest.skip("Requires two CUDA GPUs")
43 devices = os.environ.get("CUDA_VISIBLE_DEVICES", "0,1").split(",")
44 common = [
45 "--host",
46 "127.0.0.1",
47 "--dtype",
48 "bfloat16",
49 "--max-model-len",
50 "4096",
51 "--max-num-seqs",
52 "4",
53 "--gpu-memory-utilization",
54 "0.7",
55 "--no-enable-prefix-caching",
56 ]
57
58 def config(role):
59 return json.dumps(
60 {
61 "ec_connector": "ECCPUConnector",
62 "ec_role": role,
63 "ec_connector_extra_config": {
64 "ec_enable_nixl": True,
65 "ec_cpu_bytes": 64 << 20,
66 },
67 }
68 )
69
70 with (
71 RemoteOpenAIServer(
72 MODEL,
73 common
74 + [
75 "--mm-encoder-only",
76 "--disable-hybrid-kv-cache-manager",
77 "--ec-transfer-config",
78 config("ec_producer"),
79 ],
80 env_dict={
81 "CUDA_VISIBLE_DEVICES": devices[0],
82 "VLLM_EC_SIDE_CHANNEL_HOST": "127.0.0.1",
83 "VLLM_EC_SIDE_CHANNEL_PORT": str(get_open_port()),
84 },
85 ) as encoder,
86 RemoteOpenAIServer(
87 MODEL,
88 common
89 + ["--enable-mm-embeds", "--ec-transfer-config", config("ec_consumer")],
90 env_dict={"CUDA_VISIBLE_DEVICES": devices[1]},
91 ) as consumer,
92 ):
93 yield encoder, consumer
94
95
96 async def _post(session, server, body):
97 async with session.post(server.url_for("v1/chat/completions"), json=body) as r:
98 return r.status, await r.json()
99
100
101 async def _encode(session, encoder):
102 image = Image.open(Path(__file__).with_name("hato.jpg")).convert("RGB")
103 image.thumbnail((224, 224))
104 item_uuid = uuid.uuid4().hex # Avoid both EC and processor cache hits.
105 body = {
106 "model": MODEL,
107 "messages": [
108 {
109 "role": "user",
110 "content": [
111 {
112 "type": "image_url",
113 "image_url": {"url": encode_image_url(image)},
114 "uuid": item_uuid,
115 },
116 {"type": "text", "text": "Describe the image briefly."},
117 ],
118 }
119 ],
120 "max_tokens": 16,
121 "temperature": 0,
122 "chat_template_kwargs": {"enable_thinking": False},
123 }
124 status, result = await _post(session, encoder, body)
125 assert status == 200, result
126 params = result["ec_transfer_params"]
127 assert len(params) == 1, params
128 ((mm_hash, handle),) = params.items()
129 assert handle["metadata"] and handle["peer_host"] and handle["size_bytes"] > 0
130 rewritten = rewrite_for_decode(
131 body,
132 {
133 0: {
134 **handle["metadata"],
135 "mm_hash": item_uuid,
136 "ec_mm_hash": mm_hash,
137 }
138 },
139 )
140 assert rewritten["messages"][0]["content"][0]["type"] == "image_embeds"
141 for request in (body, rewritten):
142 request["ec_transfer_params"] = {item_uuid: copy.deepcopy(handle)}
143 return body, rewritten, item_uuid
144
145
146 @pytest.mark.asyncio
147 @pytest.mark.parametrize("local_image", [False, True], ids=["metadata-only", "pixels"])
148 async def test_missing_remote_encoding_is_request_scoped(servers, local_image):
149 """A terminal NACK must preserve local fallback and subsequent serving."""
150 encoder, consumer = servers
151 timeout = aiohttp.ClientTimeout(total=120)
152 async with aiohttp.ClientSession(timeout=timeout) as session:
153 original, rewritten, mm_hash = await _encode(session, encoder)
154 request = original if local_image else rewritten
155
156 # Keep the real encoder's shape/metadata; replace only the control peer.
157 # The socket stays bound until completion, so no port or timing race is
158 # needed to make the consumer receive NACK_MISSING.
159 with zmq.asyncio.Context() as ctx, ctx.socket(zmq.ROUTER) as router:
160 router.setsockopt(zmq.LINGER, 0)
161 port = router.bind_to_random_port("tcp://127.0.0.1")
162 request["ec_transfer_params"][mm_hash].update(
163 peer_host="127.0.0.1", peer_port=port
164 )
165
166 async def reject_read():
167 identity, delimiter, payload = await router.recv_multipart()
168 req = msgspec.msgpack.decode(payload, type=XferReq)
169 assert req.mm_hash == mm_hash
170 ack = XferAck(
171 mm_hash=req.mm_hash,
172 status=XferStatus.NACK_MISSING,
173 session_id=req.session_id,
174 )
175 await router.send_multipart(
176 [identity, delimiter, msgspec.msgpack.encode(ack)]
177 )
178
179 rejection = asyncio.create_task(asyncio.wait_for(reject_read(), 30))
180 try:
181 status, result = await _post(session, consumer, request)
182 # A frontend rejection must not count as successful injection.
183 await rejection
184 finally:
185 rejection.cancel()
186 await asyncio.gather(rejection, return_exceptions=True)
187
188 if local_image:
189 assert status == 200, result
190 assert result["choices"][0]["message"]["content"], result
191 else:
192 assert status == 500, result
193 assert result["error"]["type"] == "InternalServerError", result
194
195 async with session.get(consumer.url_for("health")) as health:
196 assert health.status == 200, "The failed read killed EngineCore"
197 # A fresh metadata-only image must cross the real NIXL data path after
198 # the fault, not just pass a frontend health check or hit a local cache.
199 _, healthy, _ = await _encode(session, encoder)
200 status, result = await _post(session, consumer, healthy)
201 assert status == 200, result
202 assert result["choices"][0]["message"]["content"], result