main
py 418 lines 15.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Producer-side push lifecycle, futures, and source-tensor ownership.
4
5 This module records when a push may advance or release its source. Worker
6 orchestration performs the actual control exchanges and Mooncake writes.
7 """
8
9 from __future__ import annotations
10
11 import threading
12 from collections import OrderedDict
13 from collections.abc import Callable
14 from concurrent.futures import Future, ThreadPoolExecutor
15 from contextlib import suppress
16 from dataclasses import dataclass, field
17 from enum import Enum, auto
18 from typing import Any
19
20 import torch
21
22 from vllm.distributed.ec_transfer.ec_connector.mooncake.metadata import (
23 ECMooncakePushSpec,
24 )
25
26
27 def _same_destination(
28 existing: ECMooncakePushSpec, incoming: ECMooncakePushSpec
29 ) -> bool:
30 """Whether two specs describe one push, ignoring which request asked.
31
32 The same encoding requested twice shares a transfer id legitimately; only
33 a different payload or destination is a collision.
34 """
35 return (
36 existing.mm_hash == incoming.mm_hash
37 and existing.nbytes == incoming.nbytes
38 and existing.shape == incoming.shape
39 and existing.dtype == incoming.dtype
40 and existing.consumer_zmq == incoming.consumer_zmq
41 )
42
43
44 class ProducerPushState(Enum):
45 """Lifecycle of one Producer push from reservation to terminal state."""
46
47 # The reservation reply and source tensor may arrive in either order.
48 WAITING_INPUTS = auto()
49 WRITING = auto()
50 NOTIFYING = auto()
51 DONE = auto()
52 CANCEL_PENDING = auto()
53 CANCELLED = auto()
54 FAILED = auto()
55
56
57 @dataclass
58 class ProducerPushRecord:
59 """Collect all asynchronous state for one Producer push."""
60
61 spec: ECMooncakePushSpec
62 state: ProducerPushState
63 reservation_future: Future[list[dict[str, Any]]]
64 reservations: list[dict[str, Any]] = field(default_factory=list)
65 shard_futures: list[Future[Any]] = field(default_factory=list)
66 source_tensor: torch.Tensor | None = None
67 source_event: torch.Event | None = None
68 batch_future: Future[None] | None = None
69 error: str | None = None
70
71
72 _ALLOWED_TRANSITIONS = {
73 ProducerPushState.WAITING_INPUTS: {
74 ProducerPushState.WRITING,
75 ProducerPushState.CANCEL_PENDING,
76 ProducerPushState.FAILED,
77 },
78 ProducerPushState.WRITING: {
79 ProducerPushState.NOTIFYING,
80 ProducerPushState.FAILED,
81 },
82 ProducerPushState.NOTIFYING: {
83 ProducerPushState.DONE,
84 ProducerPushState.FAILED,
85 },
86 ProducerPushState.CANCEL_PENDING: {
87 ProducerPushState.CANCELLED,
88 ProducerPushState.FAILED,
89 },
90 ProducerPushState.DONE: set(),
91 ProducerPushState.CANCELLED: set(),
92 ProducerPushState.FAILED: set(),
93 }
94
95 _TERMINAL_STATES = {
96 ProducerPushState.DONE,
97 ProducerPushState.CANCELLED,
98 ProducerPushState.FAILED,
99 }
100 _TERMINAL_LIMIT = 1 << 16
101
102
103 class ProducerPushManager:
104 """Own Producer push records, transitions, and source tensor leases.
105
106 Terminal records are retained for duplicate-metadata idempotency. Separate
107 ordered indexes keep hot polling paths from scanning those tombstones.
108 """
109
110 def __init__(self, wake: Callable[[], None] = lambda: None) -> None:
111 self._records: OrderedDict[str, ProducerPushRecord] = OrderedDict()
112 self._active_ids: OrderedDict[str, None] = OrderedDict()
113 self._reapable_terminal_ids: OrderedDict[str, None] = OrderedDict()
114 self._unreported_ids: OrderedDict[str, None] = OrderedDict()
115 self._batch_ids: OrderedDict[str, None] = OrderedDict()
116 self._source_waiters: dict[str, OrderedDict[str, None]] = {}
117 self._lock = threading.RLock()
118 self._wake = wake
119
120 def reserve(
121 self,
122 spec: ECMooncakePushSpec,
123 submit: Callable[[], Future[list[dict[str, Any]]]],
124 ) -> tuple[ProducerPushRecord, bool]:
125 with self._lock:
126 existing = self._records.get(spec.transfer_id)
127 if existing is not None:
128 if not _same_destination(existing.spec, spec):
129 raise ValueError(
130 f"Conflicting EC destination for transfer_id={spec.transfer_id}"
131 )
132 return existing, False
133 record = ProducerPushRecord(
134 spec=spec,
135 state=ProducerPushState.WAITING_INPUTS,
136 reservation_future=submit(),
137 )
138 self._records[spec.transfer_id] = record
139 self._active_ids[spec.transfer_id] = None
140 self._source_waiters.setdefault(spec.mm_hash, OrderedDict())[
141 spec.transfer_id
142 ] = None
143 record.reservation_future.add_done_callback(
144 lambda future: self._reservation_done(record, future)
145 )
146 return record, True
147
148 def bind_source(
149 self,
150 mm_hash: str,
151 tensor: torch.Tensor,
152 ready_event: torch.Event | None,
153 ) -> None:
154 with self._lock:
155 waiters = self._source_waiters.pop(mm_hash, OrderedDict())
156 for transfer_id in waiters:
157 record = self._records[transfer_id]
158 if (
159 record.source_tensor is not None
160 or record.state is not ProducerPushState.WAITING_INPUTS
161 ):
162 continue
163 record.source_tensor = tensor
164 record.source_event = ready_event
165 self._wake()
166
167 def submit_batches(
168 self,
169 executor: ThreadPoolExecutor,
170 run_batch: Callable[[list[ProducerPushRecord]], None],
171 *,
172 wait: bool = False,
173 ) -> bool:
174 pending_event = False
175 with self._lock:
176 grouped: dict[str, list[ProducerPushRecord]] = {}
177 for transfer_id in list(self._active_ids):
178 record = self._records[transfer_id]
179 if (
180 record.source_tensor is not None
181 and record.batch_future is None
182 and record.state is ProducerPushState.WAITING_INPUTS
183 ):
184 if not record.reservation_future.done():
185 continue
186 if record.source_event is not None:
187 if wait:
188 record.source_event.synchronize()
189 elif not record.source_event.query():
190 pending_event = True
191 continue
192 grouped.setdefault(record.spec.consumer_zmq, []).append(record)
193 batches = list(grouped.values())
194 for records in batches:
195 future = executor.submit(run_batch, records)
196 for record in records:
197 record.batch_future = future
198 self._batch_ids[record.spec.transfer_id] = None
199 return pending_event
200
201 def resolve_reservations(self, record: ProducerPushRecord) -> list[dict[str, Any]]:
202 results = record.reservation_future.result()
203 with self._lock:
204 record.reservations = results
205 return list(results)
206
207 def finish_reservations(
208 self,
209 outcomes: list[tuple[ProducerPushRecord, list[dict[str, Any]] | Exception]],
210 ) -> None:
211 with self._lock:
212 for record, result in outcomes:
213 if isinstance(result, Exception):
214 record.reservation_future.set_exception(result)
215 else:
216 record.reservation_future.set_result(result)
217
218 def _reservation_done(
219 self,
220 record: ProducerPushRecord,
221 future: Future[list[dict[str, Any]]],
222 ) -> None:
223 try:
224 future.result()
225 except Exception as exc:
226 with self._lock:
227 self._set_error(record, exc)
228 if (
229 record.state is ProducerPushState.WAITING_INPUTS
230 and record.source_tensor is None
231 ):
232 self._transition(record, ProducerPushState.FAILED)
233 finally:
234 self._wake()
235
236 def settle_all(self, records: list[ProducerPushRecord]) -> None:
237 for record in records:
238 with suppress(Exception):
239 record.reservation_future.result()
240
241 def replace_reservations(
242 self,
243 record: ProducerPushRecord,
244 reservations: list[dict[str, Any]],
245 ) -> None:
246 with self._lock:
247 record.reservations = reservations
248
249 def track_shard_futures(
250 self,
251 records: list[ProducerPushRecord],
252 futures: list[Future[Any]],
253 ) -> None:
254 with self._lock:
255 for record in records:
256 record.shard_futures.extend(futures)
257
258 def begin_writing(self, record: ProducerPushRecord) -> None:
259 with self._lock:
260 if record.source_tensor is None:
261 raise RuntimeError(
262 f"Producer push {record.spec.transfer_id!r} has no source tensor"
263 )
264 self._transition(record, ProducerPushState.WRITING)
265
266 def begin_notifying(self, records: list[ProducerPushRecord]) -> None:
267 with self._lock:
268 for record in records:
269 self._transition(record, ProducerPushState.NOTIFYING)
270
271 def complete(self, records: list[ProducerPushRecord]) -> None:
272 with self._lock:
273 self._check_sources_releasable(records)
274 for record in records:
275 self._transition(record, ProducerPushState.DONE)
276 self._release_source(record)
277
278 def fail(self, records: list[ProducerPushRecord], error: Exception) -> None:
279 with self._lock:
280 self._check_sources_releasable(records)
281 for record in records:
282 if record.state not in _TERMINAL_STATES:
283 self._set_error(record, error)
284 self._transition(record, ProducerPushState.FAILED)
285 self._release_source(record)
286
287 def cancel_requests(self, request_ids: set[str] | None) -> list[ProducerPushRecord]:
288 """Cancel source-less waiters for requests, or all waiters at shutdown."""
289 cancelled = []
290 with self._lock:
291 for transfer_id in list(self._active_ids):
292 record = self._records[transfer_id]
293 if (
294 (
295 request_ids is not None
296 and record.spec.request_id not in request_ids
297 )
298 or record.source_tensor is not None
299 or record.batch_future is not None
300 ):
301 continue
302 if record.state is not ProducerPushState.WAITING_INPUTS:
303 continue
304 self._transition(record, ProducerPushState.CANCEL_PENDING)
305 cancelled.append(record)
306 return cancelled
307
308 def finish_cancel(self, record: ProducerPushRecord) -> None:
309 with self._lock:
310 if record.state is ProducerPushState.CANCEL_PENDING:
311 self._transition(record, ProducerPushState.CANCELLED)
312
313 def submit_cancel(
314 self,
315 record: ProducerPushRecord,
316 executor: ThreadPoolExecutor,
317 run_cancel: Callable[[ProducerPushRecord], None],
318 ) -> None:
319 with self._lock:
320 future = executor.submit(run_cancel, record)
321 record.batch_future = future
322 transfer_id = record.spec.transfer_id
323 self._batch_ids[transfer_id] = None
324 if record.state in _TERMINAL_STATES:
325 self._reapable_terminal_ids.pop(transfer_id, None)
326
327 def poll(self) -> list[tuple[str, str]]:
328 failures = []
329 with self._lock:
330 for transfer_id in list(self._batch_ids):
331 record = self._records[transfer_id]
332 future = record.batch_future
333 assert future is not None
334 if not future.done():
335 continue
336 try:
337 future.result()
338 except Exception as exc:
339 self._set_error(record, exc)
340 self._batch_ids.pop(transfer_id)
341 self._mark_reapable(record)
342 for transfer_id in list(self._unreported_ids):
343 if transfer_id in self._batch_ids:
344 continue
345 record = self._records[transfer_id]
346 assert record.error is not None
347 failures.append((record.spec.mm_hash, record.error))
348 self._unreported_ids.pop(transfer_id)
349 self._mark_reapable(record)
350 self._reap_terminals()
351 return failures
352
353 @property
354 def pending(self) -> bool:
355 with self._lock:
356 return bool(self._active_ids or self._batch_ids)
357
358 def _release_source(self, record: ProducerPushRecord) -> None:
359 record.source_tensor = None
360 record.source_event = None
361
362 def _set_error(self, record: ProducerPushRecord, error: BaseException) -> None:
363 if record.error is None:
364 record.error = str(error)
365 if record.state in _TERMINAL_STATES:
366 transfer_id = record.spec.transfer_id
367 self._unreported_ids[transfer_id] = None
368 self._reapable_terminal_ids.pop(transfer_id, None)
369
370 def _reap_terminals(self) -> None:
371 while len(self._reapable_terminal_ids) > _TERMINAL_LIMIT:
372 transfer_id, _ = self._reapable_terminal_ids.popitem(last=False)
373 self._records.pop(transfer_id)
374
375 def _mark_reapable(self, record: ProducerPushRecord) -> None:
376 transfer_id = record.spec.transfer_id
377 if (
378 record.state in _TERMINAL_STATES
379 and transfer_id not in self._batch_ids
380 and transfer_id not in self._unreported_ids
381 ):
382 self._reapable_terminal_ids[transfer_id] = None
383
384 def _drop_source_waiter(self, record: ProducerPushRecord) -> None:
385 waiters = self._source_waiters.get(record.spec.mm_hash)
386 if waiters is None:
387 return
388 waiters.pop(record.spec.transfer_id, None)
389 if not waiters:
390 self._source_waiters.pop(record.spec.mm_hash)
391
392 @staticmethod
393 def _check_sources_releasable(records: list[ProducerPushRecord]) -> None:
394 for record in records:
395 futures = [record.reservation_future, *record.shard_futures]
396 if record.source_tensor is not None and not all(
397 future.done() for future in futures
398 ):
399 raise RuntimeError(
400 f"Producer push {record.spec.transfer_id!r} released its "
401 "source too early"
402 )
403
404 def _transition(self, record: ProducerPushRecord, state: ProducerPushState) -> None:
405 if state not in _ALLOWED_TRANSITIONS[record.state]:
406 raise RuntimeError(
407 f"Cannot transition producer push {record.spec.transfer_id!r} from "
408 f"{record.state.name} to {state.name}"
409 )
410 record.state = state
411 if state is not ProducerPushState.WAITING_INPUTS:
412 self._drop_source_waiter(record)
413 if state in _TERMINAL_STATES:
414 transfer_id = record.spec.transfer_id
415 self._active_ids.pop(transfer_id, None)
416 if record.error is not None:
417 self._unreported_ids[transfer_id] = None
418 self._mark_reapable(record)