main
py 487 lines 18.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Consumer-side reservation lifecycle and destination-memory ownership.
4
5 Reservations make remote writes idempotent and ensure cancellation or expiry
6 cannot free a destination while Mooncake may still be writing into it.
7 """
8
9 from __future__ import annotations
10
11 import threading
12 import time
13 import uuid
14 from collections import OrderedDict
15 from dataclasses import dataclass
16 from enum import Enum, auto
17
18 import torch
19
20 from vllm.distributed.ec_transfer.ec_connector.mooncake.memory import (
21 ConsumerMemoryPool,
22 MemoryAllocation,
23 ResidentLease,
24 )
25
26
27 class ConsumerReservationState(Enum):
28 """Lifecycle of one Consumer destination reservation."""
29
30 WRITING = auto()
31 READY = auto()
32 CANCEL_PENDING = auto()
33 EXPIRE_PENDING = auto()
34 CANCELLED = auto()
35 EXPIRED = auto()
36
37
38 _ALLOWED_TRANSITIONS = {
39 ConsumerReservationState.WRITING: {
40 ConsumerReservationState.READY,
41 ConsumerReservationState.CANCEL_PENDING,
42 ConsumerReservationState.EXPIRE_PENDING,
43 ConsumerReservationState.CANCELLED,
44 },
45 ConsumerReservationState.READY: {
46 ConsumerReservationState.CANCELLED,
47 ConsumerReservationState.EXPIRED,
48 },
49 ConsumerReservationState.CANCEL_PENDING: {ConsumerReservationState.CANCELLED},
50 ConsumerReservationState.EXPIRE_PENDING: {
51 ConsumerReservationState.CANCELLED,
52 ConsumerReservationState.EXPIRED,
53 },
54 ConsumerReservationState.CANCELLED: set(),
55 ConsumerReservationState.EXPIRED: set(),
56 }
57
58 _ACTIVE_STATES = {
59 ConsumerReservationState.WRITING,
60 ConsumerReservationState.READY,
61 ConsumerReservationState.CANCEL_PENDING,
62 ConsumerReservationState.EXPIRE_PENDING,
63 }
64 _DEFERRED_STATES = {
65 ConsumerReservationState.CANCEL_PENDING,
66 ConsumerReservationState.EXPIRE_PENDING,
67 }
68 _WRITER_OWNED_STATES = {
69 ConsumerReservationState.WRITING,
70 ConsumerReservationState.CANCEL_PENDING,
71 ConsumerReservationState.EXPIRE_PENDING,
72 }
73
74
75 @dataclass
76 class ConsumerReservation:
77 """Own the identity and destination allocation of one remote push."""
78
79 transfer_id: str
80 mm_hash: str
81 reservation_id: str
82 state: ConsumerReservationState
83 shape: tuple[int, ...] = ()
84 dtype: str = ""
85 allocation: MemoryAllocation | None = None
86 lease: ResidentLease[MemoryAllocation] | None = None
87 expires_at: float = 0
88 writer_id: str = ""
89
90
91 class ConsumerReservationManager:
92 """Own reservation transitions and destination allocation releases.
93
94 A remote writer owns a ``WRITING`` allocation. Cancellation and expiry are
95 therefore deferred until completion, unless refresh explicitly abandons
96 that writer before replacing its reservation.
97 """
98
99 def __init__(
100 self,
101 memory: ConsumerMemoryPool,
102 lease_ttl: float,
103 tombstone_limit: int,
104 ) -> None:
105 self._memory = memory
106 self._lease_ttl = lease_ttl
107 self._tombstone_limit = tombstone_limit
108 self._records: dict[str, ConsumerReservation] = {}
109 self._active_ids: dict[str, None] = {}
110 self._tombstones: OrderedDict[str, None] = OrderedDict()
111 self._writers: dict[str, ConsumerReservation] = {}
112 self._followers: dict[str, dict[str, None]] = {}
113 self._ready: list[str] = []
114 self._condition = threading.Condition(memory.lock)
115 self._shutting_down = False
116
117 def reserve(
118 self,
119 transfer_id: str,
120 mm_hash: str,
121 nbytes: int,
122 shape: tuple[int, ...],
123 dtype_name: str,
124 dtype: torch.dtype,
125 ) -> tuple[ConsumerReservation | None, bool]:
126 with self._condition:
127 if self._shutting_down:
128 raise RuntimeError("Consumer reservation manager is shutting down")
129 existing = self._records.get(transfer_id)
130 if (
131 existing is not None
132 and existing.state is ConsumerReservationState.CANCELLED
133 ):
134 return existing, False
135 if (
136 existing is not None
137 and existing.state is ConsumerReservationState.EXPIRED
138 ):
139 self._remove(transfer_id)
140 existing = None
141 if (
142 existing is not None
143 and existing.state is ConsumerReservationState.EXPIRE_PENDING
144 ):
145 raise RuntimeError(
146 f"Transfer {transfer_id!r} still has an active writer"
147 )
148 if existing is not None:
149 if (
150 existing.mm_hash != mm_hash
151 or existing.shape != shape
152 or existing.dtype != dtype_name
153 ):
154 raise ValueError("conflicting reservation for transfer_id")
155 if existing.state not in _ACTIVE_STATES:
156 raise RuntimeError(
157 f"Cannot reserve transfer {transfer_id!r} in "
158 f"{existing.state.name}"
159 )
160 if existing.state is ConsumerReservationState.WRITING:
161 existing.expires_at = time.monotonic() + self._lease_ttl
162 return existing, False
163
164 lease = self._memory.acquire_cached(mm_hash, shape, dtype)
165 now = time.monotonic()
166 if lease is not None:
167 record = ConsumerReservation(
168 transfer_id,
169 mm_hash,
170 uuid.uuid4().hex,
171 ConsumerReservationState.READY,
172 shape,
173 dtype_name,
174 lease.value,
175 lease,
176 now + self._lease_ttl,
177 )
178 self._insert(record)
179 return record, False
180 writer = self._writers.get(mm_hash)
181 if writer is not None and writer.state is ConsumerReservationState.WRITING:
182 if writer.shape != shape or writer.dtype != dtype_name:
183 raise ValueError("conflicting in-flight tensor for mm_hash")
184 record = ConsumerReservation(
185 transfer_id,
186 mm_hash,
187 uuid.uuid4().hex,
188 ConsumerReservationState.WRITING,
189 shape,
190 dtype_name,
191 writer.allocation,
192 expires_at=now + self._lease_ttl,
193 writer_id=writer.transfer_id,
194 )
195 self._insert(record)
196 self._followers.setdefault(writer.transfer_id, {})[transfer_id] = None
197 return record, False
198 allocation = self._memory.try_allocate(nbytes, shape, dtype)
199 if allocation is None:
200 self._expire_locked(time.monotonic())
201 allocation = self._memory.try_allocate(nbytes, shape, dtype)
202 if allocation is None:
203 allocation = self._memory.reclaim_and_allocate(nbytes, shape, dtype)
204 if allocation is None:
205 return None, False
206 record = ConsumerReservation(
207 transfer_id,
208 mm_hash,
209 uuid.uuid4().hex,
210 ConsumerReservationState.WRITING,
211 shape,
212 dtype_name,
213 allocation,
214 None,
215 now + self._lease_ttl,
216 )
217 self._insert(record)
218 self._writers[mm_hash] = record
219 return record, True
220
221 def status(self, transfer_id: str) -> ConsumerReservation | None:
222 with self._memory.lock:
223 record = self._records.get(transfer_id)
224 if record is None or record.state not in _ACTIVE_STATES:
225 return None
226 return record
227
228 def complete(self, transfer_id: str, reservation_id: str) -> tuple[bool, bool]:
229 with self._condition:
230 try:
231 record = self._records.get(transfer_id)
232 if record is None or record.reservation_id != reservation_id:
233 return False, False
234 if record.state is ConsumerReservationState.READY:
235 return True, False
236 if record.writer_id:
237 return False, False
238 if record.state in _WRITER_OWNED_STATES:
239 self._publish_followers(record)
240 if record.state in _DEFERRED_STATES:
241 terminal = (
242 ConsumerReservationState.CANCELLED
243 if record.state is ConsumerReservationState.CANCEL_PENDING
244 else ConsumerReservationState.EXPIRED
245 )
246 self._terminate(record, terminal)
247 return True, False
248 if record.state is not ConsumerReservationState.WRITING:
249 return False, False
250 self._transition(record, ConsumerReservationState.READY)
251 record.expires_at = time.monotonic() + self._lease_ttl
252 return True, True
253 finally:
254 self._condition.notify_all()
255
256 def _publish_followers(self, writer: ConsumerReservation) -> None:
257 if self._writers.get(writer.mm_hash) is writer:
258 self._writers.pop(writer.mm_hash)
259 followers = self._followers.pop(writer.transfer_id, {})
260 if not followers:
261 return
262 assert writer.allocation is not None
263 allocation = self._memory.publish(writer.mm_hash, writer.allocation, pin=False)
264 for record in [writer, *(self._records[key] for key in followers)]:
265 record.allocation = allocation
266 record.lease = self._memory.acquire_cached(
267 record.mm_hash, record.shape, allocation.tensor.dtype
268 )
269 assert record.lease is not None
270 if record is not writer:
271 record.writer_id = ""
272 self._transition(record, ConsumerReservationState.READY)
273 record.expires_at = time.monotonic() + self._lease_ttl
274 self._ready.append(record.transfer_id)
275
276 def drain_ready(self) -> list[str]:
277 with self._memory.lock:
278 ready, self._ready = self._ready, []
279 return ready
280
281 def begin_shutdown(self) -> None:
282 """Stop new reservations and cancel everything without a remote writer."""
283 with self._condition:
284 if self._shutting_down:
285 return
286 self._shutting_down = True
287 for transfer_id in list(self._active_ids):
288 record = self._records[transfer_id]
289 if record.writer_id or record.state is ConsumerReservationState.READY:
290 self._terminate(record, ConsumerReservationState.CANCELLED)
291 elif record.state is ConsumerReservationState.WRITING:
292 self._defer(record, ConsumerReservationState.CANCEL_PENDING)
293 self._condition.notify_all()
294
295 def wait_for_writers(self, timeout: float) -> bool:
296 """Wait until no reservation is still owned by a remote writer."""
297
298 def writers_finished() -> bool:
299 return not any(
300 self._records[transfer_id].state in _WRITER_OWNED_STATES
301 for transfer_id in self._active_ids
302 )
303
304 with self._condition:
305 return self._condition.wait_for(writers_finished, timeout=max(0, timeout))
306
307 def cancel(
308 self,
309 transfer_id: str,
310 reservation_id: str,
311 abandon: bool = False,
312 refresh: bool = False,
313 ) -> bool:
314 with self._memory.lock:
315 record = self._records.get(transfer_id)
316 if (
317 record is not None
318 and reservation_id
319 and record.reservation_id != reservation_id
320 ):
321 return False
322 if record is None:
323 record = ConsumerReservation(
324 transfer_id,
325 "",
326 "",
327 ConsumerReservationState.CANCELLED,
328 )
329 self._insert(record)
330 self._set_tombstone_deadline(record)
331 self._reap_tombstones(time.monotonic())
332 return True
333 if record.state is ConsumerReservationState.CANCELLED:
334 self._set_tombstone_deadline(record)
335 self._reap_tombstones(time.monotonic())
336 return True
337 if refresh:
338 if not abandon or record.state not in {
339 ConsumerReservationState.WRITING,
340 ConsumerReservationState.EXPIRE_PENDING,
341 }:
342 return False
343 if record.state is ConsumerReservationState.WRITING:
344 self._transition(record, ConsumerReservationState.EXPIRE_PENDING)
345 self._terminate(record, ConsumerReservationState.EXPIRED)
346 self._condition.notify_all()
347 self._reap_tombstones(time.monotonic())
348 return True
349 if record.writer_id:
350 self._terminate(record, ConsumerReservationState.CANCELLED)
351 return True
352 if record.state in _DEFERRED_STATES and not abandon:
353 return True
354 if record.state is ConsumerReservationState.WRITING and not abandon:
355 self._defer(record, ConsumerReservationState.CANCEL_PENDING)
356 return True
357 if record.state not in _ACTIVE_STATES:
358 return False
359 self._terminate(record, ConsumerReservationState.CANCELLED)
360 if self._shutting_down:
361 self._condition.notify_all()
362 self._reap_tombstones(time.monotonic())
363 return True
364
365 def take(self, transfer_id: str, mm_hash: str) -> MemoryAllocation:
366 with self._memory.lock:
367 record = self._records.get(transfer_id)
368 if (
369 record is None
370 or record.state is not ConsumerReservationState.READY
371 or record.mm_hash != mm_hash
372 or record.allocation is None
373 ):
374 raise RuntimeError(
375 f"Pushed EC tensor is not ready for mm_hash={mm_hash}"
376 )
377 allocation = self._memory.publish(mm_hash, record.allocation, record.lease)
378 record.allocation = None
379 record.lease = None
380 self._remove(transfer_id)
381 return allocation
382
383 def expire(self) -> int:
384 with self._memory.lock:
385 return self._expire_locked(time.monotonic())
386
387 def retire_stale(
388 self, encoder_cache: dict[str, torch.Tensor], freed: list[str] | None = None
389 ) -> None:
390 with self._memory.lock:
391 self._memory.retire_stale(encoder_cache, freed=freed)
392
393 def _expire_locked(self, now: float) -> int:
394 expired = 0
395 for transfer_id in list(self._active_ids):
396 record = self._records[transfer_id]
397 if record.expires_at > now:
398 continue
399 if record.state is ConsumerReservationState.READY:
400 self._terminate(record, ConsumerReservationState.EXPIRED)
401 expired += 1
402 elif record.state is ConsumerReservationState.WRITING:
403 if record.writer_id:
404 self._terminate(record, ConsumerReservationState.CANCELLED)
405 else:
406 self._defer(record, ConsumerReservationState.EXPIRE_PENDING)
407 self._reap_tombstones(now)
408 return expired
409
410 def _defer(
411 self, record: ConsumerReservation, state: ConsumerReservationState
412 ) -> None:
413 """Keep the destination until the writer completes or abandons it."""
414 self._transition(record, state)
415 record.expires_at = float("inf")
416
417 def _terminate(
418 self, record: ConsumerReservation, state: ConsumerReservationState
419 ) -> None:
420 self._transition(record, state)
421 self._release(record)
422 self._set_tombstone_deadline(record)
423
424 def _release(self, record: ConsumerReservation) -> None:
425 if record.writer_id:
426 followers = self._followers.get(record.writer_id, {})
427 followers.pop(record.transfer_id, None)
428 record.allocation = None
429 return
430 if self._writers.get(record.mm_hash) is record:
431 self._writers.pop(record.mm_hash)
432 for transfer_id in list(self._followers.pop(record.transfer_id, {})):
433 self._terminate(
434 self._records[transfer_id], ConsumerReservationState.CANCELLED
435 )
436 allocation = record.allocation
437 if allocation is None:
438 return
439 if record.lease is not None:
440 self._memory.release_cached(record.lease)
441 else:
442 self._memory.free(allocation)
443 record.allocation = None
444 record.lease = None
445
446 def _set_tombstone_deadline(self, record: ConsumerReservation) -> None:
447 record.expires_at = time.monotonic() + self._lease_ttl
448 self._tombstones[record.transfer_id] = None
449 self._tombstones.move_to_end(record.transfer_id)
450
451 def _reap_tombstones(self, now: float) -> int:
452 dropped = 0
453 while self._tombstones:
454 transfer_id = next(iter(self._tombstones))
455 record = self._records[transfer_id]
456 if (
457 record.expires_at > now
458 and len(self._tombstones) <= self._tombstone_limit
459 ):
460 break
461 self._remove(transfer_id)
462 dropped += 1
463 return dropped
464
465 def _insert(self, record: ConsumerReservation) -> None:
466 self._records[record.transfer_id] = record
467 if record.state in _ACTIVE_STATES:
468 self._active_ids[record.transfer_id] = None
469
470 def _remove(self, transfer_id: str) -> None:
471 self._records.pop(transfer_id, None)
472 self._active_ids.pop(transfer_id, None)
473 self._tombstones.pop(transfer_id, None)
474
475 def _transition(
476 self, record: ConsumerReservation, state: ConsumerReservationState
477 ) -> None:
478 if state not in _ALLOWED_TRANSITIONS[record.state]:
479 raise RuntimeError(
480 f"Cannot transition {record.transfer_id!r} from "
481 f"{record.state.name} to {state.name}"
482 )
483 record.state = state
484 if state in _ACTIVE_STATES:
485 self._active_ids[record.transfer_id] = None
486 else:
487 self._active_ids.pop(record.transfer_id, None)