main
py 476 lines 17.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections import OrderedDict
4 from collections.abc import Collection, Iterable
5 from dataclasses import dataclass, field
6
7 from typing_extensions import override
8
9 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
10 OffloadingConnectorStats,
11 )
12 from vllm.v1.kv_offload.base import (
13 LoadStoreSpec,
14 LookupResult,
15 Medium,
16 OffloadingEvent,
17 OffloadingManager,
18 OffloadKey,
19 PrepareStoreOutput,
20 ReqContext,
21 RequestOffloadingContext,
22 get_offload_group_idx,
23 )
24 from vllm.v1.kv_offload.cpu.common import (
25 CPULoadStoreSpec,
26 CPUOffloadingMetrics,
27 )
28 from vllm.v1.kv_offload.cpu.policies.base import CachePolicy, ChunkStatus
29 from vllm.v1.kv_offload.cpu.policies.factory import CachePolicyFactory
30
31
32 @dataclass(slots=True)
33 class _RequestCacheAccess:
34 """Cache keys observed by one request, grouped in prefix order."""
35
36 owner: object
37 cache_generation: int
38 key_groups: dict[int, list[OffloadKey]] = field(default_factory=dict)
39 seen_keys: set[OffloadKey] = field(default_factory=set)
40 inserted_keys: set[OffloadKey] = field(default_factory=set)
41 reused_keys: set[OffloadKey] = field(default_factory=set)
42 store_miss_keys: set[OffloadKey] = field(default_factory=set)
43 finished: bool = False
44
45
46 class CPUOffloadingManager(OffloadingManager):
47 """An OffloadingManager with a pluggable CachePolicy, resolved by name via
48 CachePolicyFactory (built in: "lru", "arc"; external policies can either
49 register their own or be loaded out-of-tree via cache_policy_module_path).
50
51 The manager owns all shared logic: ref-counting, event emission,
52 chunk pool management, and the prepare_store/complete_store skeletons.
53 Policy-specific chunk organization and eviction decisions are delegated
54 to the CachePolicy implementation.
55 """
56
57 def __init__(
58 self,
59 num_chunks: int,
60 cache_policy: str = "lru",
61 cache_policy_module_path: str | None = None,
62 enable_events: bool = False,
63 store_threshold: int = 1,
64 max_tracker_size: int = 64_000,
65 ):
66 self.medium: Medium = Medium.CPU
67 self._num_chunks: int = num_chunks
68 self._num_allocated_chunks: int = 0
69 self._free_list: list[int] = []
70 self.events: list[OffloadingEvent] | None = [] if enable_events else None
71 policy_cls = CachePolicyFactory.get_cache_policy_cls(
72 cache_policy, cache_policy_module_path
73 )
74 self._policy: CachePolicy = policy_cls(cache_capacity=num_chunks)
75 # Track the number of chunks in the cache that are evictable. i.e. ref_cnt 0.
76 self._num_evictable_cache_chunks: int = 0
77 # Track chunks with an in-flight store (ref_cnt -1, not yet completed).
78 self._num_write_pending_chunks: int = 0
79
80 self.store_threshold: int = store_threshold
81 self.max_tracker_size: int = max_tracker_size
82 self.stores_skipped_in_current_batch: int = 0
83 self.allocation_sizes_in_current_batch: list[int] = []
84 self._cache_generation = 0
85
86 # Number of chunk references. It is ordered so can evict the LRU entry in O(1).
87 self.counts: OrderedDict[OffloadKey, int] | None = (
88 OrderedDict() if store_threshold >= 2 else None
89 )
90
91 # --- chunk pool ---
92
93 def _get_num_free_chunks(self) -> int:
94 return len(self._free_list) + self._num_chunks - self._num_allocated_chunks
95
96 def _allocate_chunks(self, keys: list[OffloadKey]) -> list[ChunkStatus]:
97 num_fresh = min(len(keys), self._num_chunks - self._num_allocated_chunks)
98 num_reused = len(keys) - num_fresh
99 assert len(self._free_list) >= num_reused
100
101 # allocate fresh chunks
102 chunks: list[ChunkStatus] = []
103 for _ in range(num_fresh):
104 chunks.append(ChunkStatus(self._num_allocated_chunks))
105 self._num_allocated_chunks += 1
106
107 # allocate reused chunks
108 for _ in range(num_reused):
109 chunks.append(ChunkStatus(self._free_list.pop()))
110 return chunks
111
112 def _free_chunk(self, chunk: ChunkStatus) -> None:
113 self._free_list.append(chunk.chunk_id)
114
115 def _get_load_store_spec(
116 self,
117 keys: Iterable[OffloadKey],
118 chunks: Iterable[ChunkStatus],
119 ) -> CPULoadStoreSpec:
120 return CPULoadStoreSpec([chunk.chunk_id for chunk in chunks])
121
122 def _record_accesses(self, keys: Collection[OffloadKey]) -> None:
123 """Record an offer without evicting its tracked candidates."""
124 assert self.counts is not None
125 protected: set[OffloadKey] = set()
126 for key in keys:
127 if key in self.counts:
128 self.counts.move_to_end(key)
129 self.counts[key] += 1
130 protected.add(key)
131
132 num_unprotected = len(self.counts) - len(protected)
133 for key in keys:
134 if key in self.counts:
135 continue
136 if len(self.counts) >= self.max_tracker_size:
137 if num_unprotected == 0:
138 continue
139 self.counts.popitem(last=False)
140 num_unprotected -= 1
141 self.counts[key] = 1
142
143 def _get_request_cache_access(self, req_context: ReqContext) -> _RequestCacheAccess:
144 state = req_context.get_state(_RequestCacheAccess)
145 if (
146 state is None
147 or state.owner is not self
148 or state.cache_generation != self._cache_generation
149 ):
150 state = _RequestCacheAccess(
151 owner=self, cache_generation=self._cache_generation
152 )
153 req_context.set_state(state)
154 return state
155
156 def _record_request_cache_access(
157 self,
158 keys: Iterable[OffloadKey],
159 req_context: ReqContext,
160 inserted_keys: Iterable[OffloadKey] = (),
161 reused_keys: Iterable[OffloadKey] = (),
162 ) -> None:
163 state = self._get_request_cache_access(req_context)
164 for key in keys:
165 if key in state.seen_keys:
166 continue
167 assert not state.finished, (
168 "New cache keys observed after request finalization"
169 )
170 group_idx = get_offload_group_idx(key)
171 state.key_groups.setdefault(group_idx, []).append(key)
172 state.seen_keys.add(key)
173 state.inserted_keys.update(inserted_keys)
174 # Re-reading a chunk inserted by this request is an internal transfer
175 # (for example, a tiering cascade), not a second cache access.
176 state.reused_keys.update(
177 key for key in reused_keys if key not in state.inserted_keys
178 )
179
180 # --- OffloadingManager interface ---
181
182 @override
183 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
184 self._get_request_cache_access(req_context)
185 return RequestOffloadingContext()
186
187 @override
188 def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
189 chunk = self._policy.get(key)
190 if chunk is None:
191 return LookupResult.MISS
192 if not chunk.is_ready:
193 return LookupResult.HIT_PENDING
194 return LookupResult.HIT
195
196 @override
197 def prepare_load(
198 self,
199 keys: Collection[OffloadKey],
200 req_context: ReqContext,
201 ) -> LoadStoreSpec:
202 return self._prepare_load(keys, req_context, record_access=True)
203
204 def _prepare_load(
205 self,
206 keys: Collection[OffloadKey],
207 req_context: ReqContext,
208 *,
209 record_access: bool,
210 ) -> LoadStoreSpec:
211 chunks = []
212 for key in keys:
213 chunk = self._policy.get(key)
214 assert chunk is not None, f"Chunk {key!r} not found in cache"
215 assert chunk.is_ready, f"Chunk {key!r} is not ready for reading"
216 if chunk.ref_cnt == 0:
217 self._policy.mark_non_evictable(key)
218 self._num_evictable_cache_chunks -= 1 # ref_cnt 0 -> 1
219 assert self._num_evictable_cache_chunks >= 0
220 chunk.ref_cnt += 1
221 chunks.append(chunk)
222 if record_access:
223 self._record_request_cache_access(keys, req_context, reused_keys=keys)
224 return self._get_load_store_spec(keys, chunks)
225
226 @override
227 def touch(self, keys: Collection[OffloadKey], req_context: ReqContext) -> None:
228 self._policy.touch(keys, req_context)
229
230 @override
231 def complete_load(
232 self, keys: Collection[OffloadKey], req_context: ReqContext
233 ) -> None:
234 for key in keys:
235 chunk = self._policy.get(key)
236 assert chunk is not None, f"Chunk {key!r} not found"
237 assert chunk.ref_cnt > 0, f"Chunk {key!r} ref_cnt is already 0"
238 chunk.ref_cnt -= 1
239 if chunk.ref_cnt == 0:
240 self._num_evictable_cache_chunks += 1 # ref_cnt 1 -> 0
241 self._policy.mark_evictable(key)
242
243 @override
244 def prepare_store(
245 self,
246 keys: Collection[OffloadKey],
247 req_context: ReqContext,
248 ) -> PrepareStoreOutput | None:
249 keys = list(keys)
250 if self.counts is not None:
251 self._record_accesses(keys)
252 # Partition the original offer before admission filtering. Ready
253 # resident chunks are request reuses even if their tracker entry was
254 # aged out; the threshold applies only to new store candidates.
255 keys_to_store: list[OffloadKey] = []
256 ready_existing_keys: list[OffloadKey] = []
257 for key in keys:
258 chunk = self._policy.get(key)
259 if chunk is None:
260 keys_to_store.append(key)
261 else:
262 if chunk.is_ready:
263 ready_existing_keys.append(key)
264
265 if self.counts is not None:
266 num_store_candidates = len(keys_to_store)
267 keys_to_store = [
268 key
269 for key in keys_to_store
270 if self.counts.get(key, 0) >= self.store_threshold
271 ]
272 self.stores_skipped_in_current_batch += num_store_candidates - len(
273 keys_to_store
274 )
275
276 state = self._get_request_cache_access(req_context)
277 new_store_misses = [
278 key for key in keys_to_store if key not in state.store_miss_keys
279 ]
280 if new_store_misses:
281 # ARC learns from B1/B2 before insert() removes the ghost entry.
282 # Deduplication makes this one policy observation per request.
283 self._policy.on_store_miss(new_store_misses, req_context)
284 state.store_miss_keys.update(new_store_misses)
285
286 recorded_keys = set(keys_to_store)
287 recorded_keys.update(ready_existing_keys)
288 # Record once before any allocation-related early return. Store
289 # candidates are classified as insertions only after allocation succeeds.
290 self._record_request_cache_access(
291 (key for key in keys if key in recorded_keys),
292 req_context,
293 reused_keys=ready_existing_keys,
294 )
295
296 if not keys_to_store:
297 return PrepareStoreOutput(
298 keys_to_store=[],
299 store_spec=self._get_load_store_spec([], []),
300 evicted_keys=[],
301 )
302
303 self.allocation_sizes_in_current_batch.append(len(keys_to_store))
304 num_chunks_to_evict = len(keys_to_store) - self._get_num_free_chunks()
305
306 to_evict: list[OffloadKey] = []
307 if num_chunks_to_evict > 0:
308 if num_chunks_to_evict > self._num_evictable_cache_chunks:
309 # Eviction will fail.
310 return None
311 # There is a still a chance for eviction failure as some of the
312 # idle chunks might be in the protected list.
313
314 # Chunks from the original input are excluded from eviction candidates:
315 # a chunk that was already stored must remain in the cache after this call.
316 protected = set(keys)
317 evicted = self._policy.evict(num_chunks_to_evict, protected)
318 if evicted is None:
319 return None
320
321 # cache-policy removes only idle chunks.
322 self._num_evictable_cache_chunks -= len(evicted)
323 assert self._num_evictable_cache_chunks >= 0
324
325 for key, chunk in evicted:
326 self._free_chunk(chunk)
327 to_evict.append(key)
328
329 if to_evict and self.events is not None:
330 self.events.append(
331 OffloadingEvent(
332 keys=to_evict,
333 medium=self.medium,
334 removed=True,
335 )
336 )
337
338 chunks = self._allocate_chunks(keys_to_store)
339 assert len(chunks) == len(keys_to_store), (
340 "Chunk pool did not allocate the expected number of chunks"
341 )
342
343 for key, chunk in zip(keys_to_store, chunks):
344 self._policy.insert(key, chunk)
345 self._num_write_pending_chunks += len(keys_to_store)
346 state.inserted_keys.update(keys_to_store)
347
348 # build store specs for allocated chunks
349 store_spec = self._get_load_store_spec(keys_to_store, chunks)
350
351 return PrepareStoreOutput(
352 keys_to_store=keys_to_store,
353 store_spec=store_spec,
354 evicted_keys=to_evict,
355 )
356
357 @override
358 def complete_store(
359 self,
360 keys: Collection[OffloadKey],
361 req_context: ReqContext,
362 success: bool = True,
363 ) -> None:
364 stored_keys: list[OffloadKey] = []
365
366 if success:
367 for key in keys:
368 chunk = self._policy.get(key)
369 if chunk is not None and not chunk.is_ready:
370 chunk.ref_cnt = 0
371 self._num_write_pending_chunks -= 1
372 self._num_evictable_cache_chunks += 1
373 self._policy.mark_evictable(key)
374 stored_keys.append(key)
375 else:
376 for key in keys:
377 chunk = self._policy.get(key)
378 if chunk is not None and not chunk.is_ready:
379 self._num_write_pending_chunks -= 1
380 self._policy.remove(key)
381 self._free_chunk(chunk)
382
383 if stored_keys and self.events is not None:
384 self.events.append(
385 OffloadingEvent(
386 keys=stored_keys,
387 medium=self.medium,
388 removed=False,
389 )
390 )
391
392 @override
393 def on_request_finished(self, req_context: ReqContext) -> None:
394 state = req_context.get_state(_RequestCacheAccess)
395 if (
396 state is None
397 or state.owner is not self
398 or state.cache_generation != self._cache_generation
399 or state.finished
400 ):
401 return
402 state.finished = True
403 key_groups = []
404 for group_idx in sorted(state.key_groups):
405 keys = state.key_groups[group_idx]
406 positions = {
407 key: position
408 for key in keys
409 if (position := req_context.get_offload_key_position(key)) is not None
410 }
411 if len(positions) == len(keys):
412 keys = sorted(keys, key=positions.__getitem__)
413 key_groups.append(tuple(keys))
414 self._policy.on_request_finished(
415 tuple(key_groups),
416 state.inserted_keys - state.reused_keys,
417 state.reused_keys,
418 req_context,
419 )
420
421 @override
422 def reset_cache(self) -> None:
423 # Clear ALL chunks unconditionally. The scheduler's _stale_job_threshold
424 # guarantees that complete_load / complete_store are never called for
425 # pre-reset jobs, so no lazy cleanup is needed. The scheduler also
426 # flushes in-flight load job IDs to the workers before any new stores
427 # can begin, preventing a cross-direction data race on reused offload chunk IDs.
428 self._policy.clear()
429 self._cache_generation += 1
430 self._num_evictable_cache_chunks = 0
431 self._num_write_pending_chunks = 0
432
433 self._free_list.clear()
434 self._num_allocated_chunks = 0
435
436 @override
437 def take_events(self) -> Iterable[OffloadingEvent]:
438 if self.events is not None:
439 yield from self.events
440 self.events.clear()
441
442 def get_stats(self) -> OffloadingConnectorStats | None:
443 stats = OffloadingConnectorStats()
444
445 # Compute cache usage.
446 num_used = (
447 self._num_allocated_chunks
448 - len(self._free_list)
449 - self._num_evictable_cache_chunks
450 )
451 usage = num_used / self._num_chunks if self._num_chunks > 0 else 0.0
452 stats.set_gauge(CPUOffloadingMetrics.CPU_CACHE_USAGE_PERC, usage)
453
454 for allocation_size in self.allocation_sizes_in_current_batch:
455 stats.observe_histogram(
456 CPUOffloadingMetrics.CPU_ALLOCATION_SIZE, allocation_size
457 )
458 self.allocation_sizes_in_current_batch.clear()
459
460 write_usage = (
461 self._num_write_pending_chunks / self._num_chunks
462 if self._num_chunks > 0
463 else 0.0
464 )
465 read_usage = max(usage - write_usage, 0.0)
466 stats.set_gauge(CPUOffloadingMetrics.CPU_CACHE_WRITE_USAGE_PERC, write_usage)
467 stats.set_gauge(CPUOffloadingMetrics.CPU_CACHE_READ_USAGE_PERC, read_usage)
468
469 if self.store_threshold >= 2:
470 stats.increase_counter(
471 CPUOffloadingMetrics.STORES_SKIPPED,
472 self.stores_skipped_in_current_batch,
473 )
474 self.stores_skipped_in_current_batch = 0
475
476 return stats