[Bugfix][EC Connector] Fail the request, not the engine, when a remote encoding cannot arrive (#55290)
Signed-off-by: Tianyu Guo <guoty@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com>
Tianyu Guo committed
Sep 9, 2026 at 11:13 UTC
f1aeff60073dda82a423d7d081f132c8a458e16b
8 files changed
+536
-104
tests/v1/ec_connector/integration/README.md
+20
@@ -58,6 +58,26 @@ EC_SHARED_STORAGE_PATH="/tmp/my_ec_cache" bash ./tests/v1/ec_connector/integrati
58
59
## How It Works
60
61
+### NIXL EC failure isolation (1E + 1PD)
62
+
63
+```bash
64
+PATH="$PWD/.venv/bin:$PATH" CUDA_VISIBLE_DEVICES=0,1 \
65
+ .venv/bin/python -m pytest \
66
+ tests/v1/ec_connector/integration/test_nixl_failure.py -v -s
67
+```
68
+
69
+Requires two CUDA GPUs and NIXL. `MODEL` overrides the default
70
+`Qwen/Qwen2.5-VL-3B-Instruct`. The test starts real E/PD servers with CUDA
71
+graphs enabled and uses the proxy's rewrite helper. Only the failing request's
72
+control endpoint is replaced: a ZMQ peer returns `NACK_MISSING` and verifies
73
+that the consumer actually requested the encoding.
74
+
75
+Metadata-only requests must return a request-level error; requests retaining
76
+the image must succeed through local fallback. After each case, a fresh
77
+metadata-only image request must succeed through the real NIXL transfer path.
78
+No server restart, proxy retry, cache hit, or timeout race can mask the result.
79
+This is a correctness test, not a throughput benchmark.
80
+
81
### Mooncake EC (1E + 1PD)
82
83
```bash
tests/v1/ec_connector/integration/test_nixl_failure.py
new
+202
@@ -0,0 +1,202 @@
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
tests/v1/ec_connector/unit/test_scheduler_nixl_consumer.py
+117
-35
@@ -1,13 +1,22 @@
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
@@ -18,10 +27,12 @@ class _Pos:
27
28
29
class _Feature:
21
- def __init__(self, mm_hash, length=1):
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:
@@ -94,6 +105,7 @@ def _consumer_sched(monkeypatch):
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
@@ -150,13 +162,17 @@ def test_new_remote_read_defers_then_completes(monkeypatch):
162
s.shutdown()
163
164
153
-def test_no_params_is_ready(monkeypatch):
165
+@pytest.mark.parametrize("params", [None, {"x": 123}])
166
+def test_no_remote_announcement_is_ready(monkeypatch, params):
167
s = _consumer_sched(monkeypatch)
155
- assert s.ensure_cache_available(_Request([_Feature("x")], params=None), 0) is True
168
+ assert s.ensure_cache_available(_Request([_Feature("x")], params=params), 0)
169
+ assert s.take_unavailable_requests() == set()
170
s.shutdown()
171
172
159
-def test_tombstoned_read_discards_and_blocks_retry(monkeypatch):
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
@@ -167,26 +183,71 @@ def test_tombstoned_read_discards_and_blocks_retry(monkeypatch):
183
return True
184
185
monkeypatch.setattr(s, "_start_xfer", _fake_start)
170
- req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
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
176
- # Producer rejected the read -> tombstoned.
208
fake._results.tombstoned.add("h1")
209
s._sessions[("h", 1)] = fake
210
180
- # Step 2: poll discards the entry and records a tombstone; the tombstone
181
- # is consumed by admit so the request proceeds to local compute.
182
- assert s.ensure_cache_available(req, 0) is True
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
185
- s.build_connector_meta(scheduler_output=None)
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
187
- # Step 3: tombstone was consumed, so a fresh transfer starts again.
188
- assert s.ensure_cache_available(req, 0) is False
189
- assert "h1" in s._in_flight
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
@@ -225,17 +286,50 @@ def test_retryable_read_re_requests_without_admitting(monkeypatch):
286
s.shutdown()
287
288
228
-def test_size_mismatch_skips_transfer(monkeypatch):
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)
230
- # Advertised size disagrees with pos.length * hidden_dim * element_size.
231
- bad = {"h1": {"peer_host": "h", "peer_port": 1, "size_bytes": 999}}
232
- req = _Request([_Feature("h1", 1)], params=bad)
233
- assert s.ensure_cache_available(req, 0) is True
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
@@ -250,7 +344,7 @@ def test_metadata_only_entry_admits_without_transfer(monkeypatch):
344
s.shutdown()
345
346
253
-def test_orphan_not_ready_entry_falls_back_no_realloc(monkeypatch):
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
@@ -274,25 +368,13 @@ def test_orphan_not_ready_entry_falls_back_no_realloc(monkeypatch):
368
monkeypatch.setattr(s._cache, "alloc", _spy_alloc)
369
req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
370
277
- # Must not raise; request falls back to local compute (admitted True),
278
- # the orphan entry is untouched, and alloc is never called again.
279
- assert s.ensure_cache_available(req, 0) is True
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
283
- s.shutdown()
284
-
285
-
286
-def test_alloc_failure_falls_back_to_local(monkeypatch):
287
- s = _consumer_sched(monkeypatch)
288
-
289
- # Force the cache to reject the allocation.
290
- monkeypatch.setattr(s._cache, "alloc", lambda key, n: None)
291
- req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
292
-
293
- # _start_xfer returns False -> request admitted for local recompute.
294
- assert s.ensure_cache_available(req, 0) is True
295
- assert "h1" not in s._in_flight
377
+ assert s.take_unavailable_requests() == set()
378
s.shutdown()
379
380
tests/v1/ec_connector/unit/test_scheduler_nixl_producer.py
+1
@@ -63,6 +63,7 @@ def test_request_finished_producer_emits_params(monkeypatch):
63
64
delay, params = s.request_finished(_Request([_Feature("h1", length=2)]))
65
assert delay is False
66
+ assert entry.evictable
67
assert params == {
68
"h1": {
69
"metadata": {},
tests/v1/ec_connector/unit/test_session.py
+5
-3
@@ -451,14 +451,16 @@ def test_consumer_session_poll_not_ready_nack_goes_to_retryable():
451
assert not results.completed
452
453
454
-def test_consumer_session_take_results_clears_state():
454
+def test_consumer_session_ack_timeout_reports_retry_once():
455
+ """A delayed producer ACK is retryable, and its result is drained once."""
456
s = _make_consumer_session()
457
s.start_xfer("h1", [0, 1], deadline=time.monotonic() - 1)
458
s.poll([], time.monotonic())
459
r1 = s.take_results()
459
- assert "h1" in r1.tombstoned
460
+ assert "h1" in r1.retryable
461
+ assert not r1.tombstoned
462
r2 = s.take_results()
461
- assert not r2.tombstoned # cleared after first take
463
+ assert not r2.retryable # cleared after first take
464
465
466
def test_consumer_session_on_peer_down_cancels_waiting_ack():
vllm/distributed/ec_transfer/ec_connector/cpu/connector.py
+4
@@ -104,6 +104,10 @@ class ECCPUConnector(ECConnectorBase):
104
request, num_computed_tokens
105
)
106
107
+ def take_unavailable_requests(self) -> set[str]:
108
+ assert self.connector_scheduler is not None
109
+ return self.connector_scheduler.take_unavailable_requests()
110
+
111
def update_state_after_alloc(self, request: "Request", index: int) -> None:
112
assert self.connector_scheduler is not None
113
self.connector_scheduler.update_state_after_alloc(request, index)
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/__init__.py
+174
-64
@@ -7,6 +7,7 @@ Owns the mmap region and the embedding cache, and handles the producer
7
for the ECCPUConnector.
8
"""
9
10
+import time
11
from collections.abc import Collection
12
from typing import TYPE_CHECKING, Any
13
@@ -29,12 +30,20 @@ from vllm.logger import init_logger
30
31
if TYPE_CHECKING:
32
from vllm.config import VllmConfig
33
+ from vllm.multimodal.inputs import MultiModalFeatureSpec
34
from vllm.v1.core.sched.output import SchedulerOutput
35
from vllm.v1.outputs import ECConnectorOutput
36
from vllm.v1.request import Request
37
38
logger = init_logger(__name__)
39
40
+# Total remote wait budget per request item, including all retries.
41
+_ADMIT_DEFER_TIMEOUT_S = 60.0
42
+
43
+
44
+class _RemoteUnavailable(Exception):
45
+ """An item cannot be fetched within its request's remote wait budget."""
46
+
47
48
class ECCPUScheduler:
49
"""Scheduler delegate for the ECCPUConnector."""
@@ -92,6 +101,11 @@ class ECCPUScheduler:
101
self._in_flight: set[str] = set()
102
self._tombstones: set[str] = set()
103
self._step_completed: set[str] = set()
104
+ # Per-request clocks survive retries and are cleared on completion.
105
+ self._deferred_since: dict[tuple[str, str], float] = {}
106
+ # Requests needing a remote encoding that will not arrive. Drained by
107
+ # take_unavailable_requests(); the scheduler aborts them.
108
+ self._unrecoverable: set[str] = set()
109
self._peer_host: str | None = None
110
self._peer_port: int | None = None
111
# Model shape for size checks + compat hash; only set by
@@ -117,6 +131,7 @@ class ECCPUScheduler:
131
compute_ec_compatibility_hash,
132
)
133
from vllm.distributed.ec_transfer.ec_connector.cpu.session import (
134
+ PRODUCER_PIN_LEASE_S,
135
ProducerSession,
136
)
137
from vllm.distributed.nixl_utils import NixlWrapper, nixl_agent_config
@@ -136,7 +151,9 @@ class ECCPUScheduler:
151
# its scheduler step, so its reply latency scales with the encoder's
152
# batch size: a deployment whose steps run longer must raise this.
153
self._ack_timeout_s = float(
139
- self._ec_config.get_from_extra_config("consumer_ack_timeout_s", 2.0)
154
+ self._ec_config.get_from_extra_config(
155
+ "consumer_ack_timeout_s", PRODUCER_PIN_LEASE_S
156
+ )
157
)
158
self._compat_hash = compute_ec_compatibility_hash(
159
vllm_version=VLLM_VERSION,
@@ -203,79 +220,172 @@ class ECCPUScheduler:
220
def _nixl_consumer_admit(
221
self, request: "Request", num_computed_tokens: int
222
) -> bool:
206
- """Admit a request only once all its remote encodings are cached.
223
+ """Admit a request once its required remote encodings are ready.
224
+
225
+ Each item has a per-request wait budget that survives transfer retries.
226
+ Unavailable items fall back to local input; without it, the request is
227
+ reported by take_unavailable_requests() for the scheduler to abort.
228
208
- Returns True when every encoding the request needs is either already
209
- ready in the local cache or has no remote source. Any encoding whose
210
- NIXL READ is still in flight (or was just started this step) defers
211
- the request, which the scheduler re-presents on a later step.
229
+ Returns:
230
+ True if no item needs to wait for a remote encoding. False if any
231
+ item is pending or the request must be aborted.
232
"""
213
- params: dict[str, dict[str, Any]] = (
214
- getattr(request, "ec_transfer_params", None) or {}
215
- )
216
- if not params:
233
+ if not request.ec_transfer_params:
234
return True
235
+ now = time.monotonic()
236
pending = False
237
for feature in request.mm_features:
220
- pos = feature.mm_position
221
- if pos.offset + pos.length <= num_computed_tokens:
222
- continue
223
- mm_hash = feature.identifier
224
- entry = self._cache.get(mm_hash)
225
- if entry is not None and entry.ready:
226
- # Local hit: upstream's update_state_after_alloc pins and
227
- # loads it through the same path as a natively cached entry.
228
- continue
229
- if mm_hash in self._in_flight:
230
- pending = True
231
- continue
232
- if mm_hash in self._step_completed:
233
- pending = True
234
- continue
235
- if mm_hash in self._tombstones:
236
- self._tombstones.discard(mm_hash)
237
- continue
238
- if entry is not None:
239
- # Present but not ready and not being fetched: its blocks are
240
- # held by a quarantined/settling DMA and cannot be reused.
241
- # Fall back to local compute this step; never re-alloc a
242
- # mm_hash already in the cache.
243
- continue
244
- info = params.get(mm_hash)
245
- # Absent entirely, or present with only placeholder metadata (no
246
- # cache entry existed on the producer side at request_finished
247
- # time): nothing to fetch, fall back to local compute.
248
- if not info or "peer_host" not in info:
249
- continue
250
- expected = pos.length * self._hidden_dim * self._element_size
251
- if int(info.get("size_bytes", -1)) != expected:
252
- logger.warning(
253
- "EC consumer: size mismatch mm_hash=%s; local encode", mm_hash
254
- )
255
- continue
238
try:
257
- started = self._start_xfer(mm_hash, info, expected)
258
- except Exception:
259
- logger.exception(
260
- "EC consumer: failed to start NIXL xfer mm_hash=%s", mm_hash
261
- )
262
- continue
263
- if not started:
264
- continue
265
- self._in_flight.add(mm_hash)
266
- pending = True
239
+ if not self._admit_item(request, feature, num_computed_tokens, now):
240
+ pending = True
241
+ except _RemoteUnavailable as error:
242
+ if not self._fail_or_fallback(request, feature, str(error)):
243
+ return False
244
return not pending
245
246
+ def _admit_item(
247
+ self,
248
+ request: "Request",
249
+ feature: "MultiModalFeatureSpec",
250
+ num_computed_tokens: int,
251
+ now: float,
252
+ ) -> bool:
253
+ """Check one encoding, starting a remote read if needed.
254
+
255
+ Returns:
256
+ True if already computed, cached, or not remotely sourced. False
257
+ while waiting for a read or cache space, including across retries.
258
+
259
+ Raises:
260
+ _RemoteUnavailable: The remote source is invalid, the read failed,
261
+ or the item's total wait budget expired.
262
+ """
263
+ pos = feature.mm_position
264
+ mm_hash = feature.identifier
265
+ if pos.offset + pos.length <= num_computed_tokens:
266
+ self._deferred_since.pop((request.request_id, mm_hash), None)
267
+ return True
268
+ announced = (request.ec_transfer_params or {}).get(mm_hash)
269
+ # Without a producer address there is nothing to fetch: the request
270
+ # carries what the model needs and the encoder runs locally.
271
+ # `ec_transfer_params` reaches us from the request, so its shape is
272
+ # checked rather than assumed.
273
+ remote: dict[str, Any] | None = (
274
+ announced
275
+ if isinstance(announced, dict) and "peer_host" in announced
276
+ else None
277
+ )
278
+
279
+ entry = self._cache.get(mm_hash)
280
+ if entry is not None and entry.ready:
281
+ # Local hit: upstream's update_state_after_alloc pins and
282
+ # loads it through the same path as a natively cached entry.
283
+ self._deferred_since.pop((request.request_id, mm_hash), None)
284
+ return True
285
+ if remote is None:
286
+ return True
287
+ since = self._deferred_since.setdefault((request.request_id, mm_hash), now)
288
+ if now - since > _ADMIT_DEFER_TIMEOUT_S:
289
+ raise _RemoteUnavailable(f"the remote wait exceeded {now - since:.0f}s")
290
+ if mm_hash in self._in_flight or mm_hash in self._step_completed:
291
+ return False
292
+
293
+ if mm_hash in self._tombstones:
294
+ self._tombstones.discard(mm_hash)
295
+ raise _RemoteUnavailable("the remote read failed")
296
+
297
+ if entry is not None:
298
+ # Present but not ready and not being fetched: its blocks are
299
+ # held by a quarantined/settling DMA and cannot be reused.
300
+ return False
301
+
302
+ expected = pos.length * self._hidden_dim * self._element_size
303
+ try:
304
+ size = int(remote["size_bytes"])
305
+ except (KeyError, TypeError, ValueError) as error:
306
+ raise _RemoteUnavailable("the announced size was unusable") from error
307
+ if size != expected:
308
+ logger.warning(
309
+ "EC consumer: size mismatch mm_hash=%s announced=%d expected=%d",
310
+ mm_hash,
311
+ size,
312
+ expected,
313
+ )
314
+ raise _RemoteUnavailable("the announced size was wrong")
315
+
316
+ try:
317
+ started = self._start_xfer(mm_hash, remote, expected)
318
+ except Exception as error:
319
+ logger.exception(
320
+ "EC consumer: failed to start NIXL xfer mm_hash=%s", mm_hash
321
+ )
322
+ raise _RemoteUnavailable("the read could not be started") from error
323
+ if started:
324
+ self._in_flight.add(mm_hash)
325
+ return False
326
+
327
+ def _fail_or_fallback(
328
+ self, request: "Request", feature: "MultiModalFeatureSpec", why: str
329
+ ) -> bool:
330
+ """Resolve an unavailable remote encoding and clear its wait budget.
331
+
332
+ Returns:
333
+ True if local media or embeddings are available, removing this
334
+ item's remote announcement so subsequent steps do not retry it.
335
+ False if only placeholder metadata (or no input) is available,
336
+ recording the request for take_unavailable_requests() to drain.
337
+ """
338
+ mm_hash = feature.identifier
339
+ self._deferred_since.pop((request.request_id, mm_hash), None)
340
+ data = feature.data
341
+ metadata = (
342
+ self._metadata_resolver.fields_for(feature.modality) if data else set()
343
+ )
344
+ if data and any(
345
+ value.data is not None for key, value in data.items() if key not in metadata
346
+ ):
347
+ # Passthrough embeddings bypass the processor cache. SHM address
348
+ # items therefore refer to media that the worker can encode.
349
+ request.ec_transfer_params = dict(request.ec_transfer_params or {})
350
+ request.ec_transfer_params.pop(mm_hash, None)
351
+ logger.warning(
352
+ "EC consumer: request %s mm_hash=%s: %s; using local input",
353
+ request.request_id,
354
+ mm_hash,
355
+ why,
356
+ )
357
+ return True
358
+ self._unrecoverable.add(request.request_id)
359
+ logger.error(
360
+ "EC consumer: request %s needs remote encoding mm_hash=%s but %s. "
361
+ "No local model input is available; failing the request.",
362
+ request.request_id,
363
+ mm_hash,
364
+ why,
365
+ )
366
+ return False
367
+
368
+ def take_unavailable_requests(self) -> set[str]:
369
+ """Return and clear IDs of requests that cannot obtain their encodings.
370
+
371
+ The scheduler must abort these requests rather than leave them waiting.
372
+ Requests that can fall back to local input are not included, and each
373
+ recorded failure is returned only once.
374
+ """
375
+ if not self._unrecoverable:
376
+ return set()
377
+ failed = self._unrecoverable
378
+ self._unrecoverable = set()
379
+ return failed
380
+
381
def _start_xfer(
382
self, mm_hash: str, info: "dict[str, Any]", size_bytes: int
383
) -> bool:
384
"""Allocate a not-ready cache entry and start a NIXL READ into it.
385
386
Returns True if the transfer was started. Returns False when the
275
- cache cannot accommodate the encoding, in which case the request
276
- falls back to local recomputation.
387
+ cache cannot accommodate the encoding; admission will retry it.
388
"""
278
- import time
389
from math import ceil
390
391
from vllm.distributed.ec_transfer.ec_connector.cpu.session import (
@@ -286,7 +396,7 @@ class ECCPUScheduler:
396
entry = self._cache.alloc(mm_hash, n_blocks)
397
if entry is None:
398
logger.debug(
289
- "EC consumer: cache full for mm_hash=%s (%d blocks); local encode",
399
+ "EC consumer: cache full for mm_hash=%s (%d blocks); deferring",
400
mm_hash,
401
n_blocks,
402
)
@@ -319,8 +429,6 @@ class ECCPUScheduler:
429
return True
430
431
def _poll_step(self) -> None:
322
- import time
323
-
432
now = time.monotonic()
433
all_messages = self._transport.poll()
434
for addr, session in list(self._sessions.items()):
@@ -352,8 +460,7 @@ class ECCPUScheduler:
460
mm_hash,
461
)
462
for mm_hash in r.retryable:
355
- # No tombstone: dropping every trace of the attempt is what lets
356
- # the next admit pass re-issue the read as if it were the first.
463
+ # Release this attempt's blocks, retaining the request wait budget.
464
self._in_flight.discard(mm_hash)
465
self._cache.discard(mm_hash)
466
logger.debug(
@@ -472,6 +579,8 @@ class ECCPUScheduler:
579
def request_finished(
580
self, request: "Request"
581
) -> tuple[bool, "dict[str, Any] | None"]:
582
+ for feature in request.mm_features:
583
+ self._deferred_since.pop((request.request_id, feature.identifier), None)
584
if not (self._nixl_enabled and self._is_producer):
585
return False, None
586
if not request.mm_features:
@@ -520,6 +629,7 @@ class ECCPUScheduler:
629
self._pending_saves.clear()
630
self._pending_loads.clear()
631
self._load_acks.clear()
632
+ self._deferred_since.clear()
633
634
self._is_producer = False
635
self._is_consumer = False
vllm/distributed/ec_transfer/ec_connector/cpu/session.py
+13
-2
@@ -71,7 +71,7 @@ class XferState(enum.Enum):
71
WAITING_ACK = "waiting_ack" # XferReq sent, awaiting XferAck
72
READING = "reading" # NIXL READ in flight
73
DONE = "done" # READ completed; promote to local cache
74
- ACK_TIMEOUT = "ack_timeout" # XferAck never arrived; free blocks + tombstone
74
+ ACK_TIMEOUT = "ack_timeout" # XferAck never arrived; free blocks + retry
75
READ_FAILED = "read_failed" # Unexpected NIXL state; free blocks + tombstone
76
QUARANTINED = "quarantined" # Read timed out, NIXL unabortable; keep blocks
77
SETTLED = "settled" # Quarantined DMA done; safe to free blocks
@@ -500,7 +500,18 @@ class ConsumerSession:
500
if state == XferState.DONE:
501
self._completed.add(mm_hash)
502
del self._xfers[mm_hash]
503
- elif state in (XferState.ACK_TIMEOUT, XferState.READ_FAILED):
503
+ elif state == XferState.ACK_TIMEOUT:
504
+ # The scheduler bounds retries with a per-request deadline.
505
+ logger.warning(
506
+ "EC consumer: no XferAck for mm_hash=%s from %s:%d in time; "
507
+ "will re-request",
508
+ mm_hash,
509
+ self._addr[0],
510
+ self._addr[1],
511
+ )
512
+ self._retryable.add(mm_hash)
513
+ del self._xfers[mm_hash]
514
+ elif state == XferState.READ_FAILED:
515
self._tombstoned.add(mm_hash)
516
del self._xfers[mm_hash]
517
elif state == XferState.QUARANTINED: