@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 7e612a0f06
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[KV Offloading] Implement `reset_cache` for `TieringOffloadingManager` (#44541)

Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Ronen Schaffer committed Jun 15, 2026 at 21:42 UTC 7e612a0f06ad9e31b4609726266fea3cfb0883fe
10 files changed +255 -22
tests/v1/kv_connector/unit/test_offloading_connector.py
+5 -11
@@ -111,7 +111,7 @@ class MockSubscriber:
111 self.sub.close()
112
113
114 -def _wait_for_prefix_cache_reset(llm: LLM, reset_connector: bool = False) -> None:
114 +def _wait_for_prefix_cache_reset(llm: LLM) -> None:
115 """Wait for async offload transfers to finish so prefix cache can reset.
116
117 The GPU-to-CPU offload runs on a CUDA stream asynchronously. While blocks
@@ -119,14 +119,10 @@ def _wait_for_prefix_cache_reset(llm: LLM, reset_connector: bool = False) -> Non
119 ``False``. Between retries we send a dummy single-token prefill to force
120 the engine to step, which polls the worker for completed transfers and
121 frees GPU blocks.
122 -
123 - Args:
124 - llm: The LLM instance to reset.
125 - reset_connector: If True, also reset the KV connector state.
122 """
123 _dummy_params = SamplingParams(max_tokens=1)
124 deadline = time.monotonic() + _RESET_CACHE_TIMEOUT
129 - while not llm.reset_prefix_cache(reset_connector=reset_connector):
125 + while not llm.reset_prefix_cache():
126 if time.monotonic() > deadline:
127 raise TimeoutError(
128 "reset_prefix_cache did not succeed within "
@@ -141,9 +137,7 @@ def _wait_for_prefix_cache_reset(llm: LLM, reset_connector: bool = False) -> Non
137 )
138
139
144 -def _latency_test(
145 - llm: LLM, subscriber: MockSubscriber | None, reset_connector: bool = False
146 -):
140 +def _latency_test(llm: LLM, subscriber: MockSubscriber | None):
141 sampling_params = SamplingParams(max_tokens=1)
142
143 num_times_cpu_better_than_cold = 0
@@ -173,7 +167,7 @@ def _latency_test(
167
168 # Wait for the async CPU offload to finish, then reset prefix cache
169 # so the next generate() must reload from CPU rather than GPU.
176 - _wait_for_prefix_cache_reset(llm, reset_connector=reset_connector)
170 + _wait_for_prefix_cache_reset(llm)
171
172 # Verify CPU stored events arrived (offload is done before we
173 # attempt to load from CPU).
@@ -549,7 +543,7 @@ def test_fs_tiering_offloading(tmp_path) -> None:
543 topic=kv_events_config.topic,
544 )
545 try:
552 - _latency_test(llm, subscriber, reset_connector=True)
546 + _latency_test(llm, subscriber)
547 _accuracy_test(llm, subscriber)
548 finally:
549 subscriber.close()
tests/v1/kv_offload/tiering/test_fs_tier.py
+22
@@ -10,6 +10,7 @@ data integrity throughout the process.
10
11 import mmap
12 import os
13 +import threading
14 import time
15 from unittest.mock import MagicMock
16
@@ -22,6 +23,7 @@ from vllm.v1.kv_offload.tiering.base import JobMetadata
23 from vllm.v1.kv_offload.tiering.fs.manager import (
24 FileSystemTierManager,
25 )
26 +from vllm.v1.kv_offload.tiering.fs.thread_pool import DualQueueThreadPool
27
28 # ---------------------------------------------------------------------------
29 # Helpers
@@ -296,3 +298,23 @@ def test_store_load_data_integrity(fs_tier):
298 assert torch.allclose(tensor[bid], expected[i]), (
299 f"Block {bid} data mismatch after store+load"
300 )
301 +
302 +
303 +def test_wait_idle_blocks_until_tasks_complete():
304 + """wait_idle must not return while a task is still in flight."""
305 + pool = DualQueueThreadPool(n_read_threads=1, n_write_threads=1)
306 + gate = threading.Event()
307 + pool.enqueue_store(job_id=1, n_tasks=1, tasks=[lambda: gate.wait(timeout=5.0)])
308 +
309 + waiter = threading.Thread(target=pool.wait_idle)
310 + waiter.start()
311 + try:
312 + waiter.join(timeout=0.2)
313 + assert waiter.is_alive(), "wait_idle returned before task completed"
314 + gate.set()
315 + waiter.join(timeout=5.0)
316 + assert not waiter.is_alive(), "wait_idle did not unblock"
317 + finally:
318 + gate.set()
319 + pool.shutdown(wait=True)
320 + waiter.join(timeout=5.0)
tests/v1/kv_offload/tiering/test_obj_tier.py
+27
@@ -290,6 +290,33 @@ class TestMockObjTierBasic:
290 assert len(results) == 1
291 assert results[0].success
292
293 + def test_drain_jobs_polls_until_transfers_complete(self):
294 + """drain_jobs must keep polling check_xfer_state until every
295 + in-flight transfer finishes. A buggy implementation that only
296 + polled once would return with _transfers still populated.
297 + """
298 + call_count = [0]
299 + original = self.agent.check_xfer_state
300 +
301 + def delayed(h):
302 + call_count[0] += 1
303 + # Stay in PROC for the first 2 polls, then DONE.
304 + return "PROC" if call_count[0] < 3 else original(h)
305 +
306 + self.agent.check_xfer_state = delayed
307 +
308 + self.tier.submit_store(make_job(1, [key(1)], [0]))
309 + assert self.tier._transfers # in flight
310 +
311 + self.tier.drain_jobs()
312 +
313 + assert not self.tier._transfers # fully drained
314 + assert call_count[0] >= 3 # polled past the initial PROC responses
315 + # Result is buffered for the next get_finished_jobs() call.
316 + results = list(self.tier.get_finished_jobs())
317 + assert len(results) == 1
318 + assert results[0].success
319 +
320
321 class TestMockObjTierMultiBlock:
322 def test_store_multiple_blocks(self):
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+79
@@ -490,6 +490,85 @@ class TestTieringOffloadingManager:
490 # tier2 (block-level) does not get existing blocks here.
491 self.secondary_tier2.submit_store.assert_not_called()
492
493 + def test_reset_cache_clears_all_state(self, manager_setup):
494 + """reset_cache wipes every kind of orchestrator state and resets
495 + primary tier; pending submissions are dropped without being sent
496 + to the secondary tier."""
497 + # Cascade — populates primary blocks and leaves cascade jobs
498 + # in _transfer_jobs (the synchronous example tier has already
499 + # queued completions); reset_cache's drain loop will pick them up.
500 + blocks = to_keys(range(3))
501 + self.manager.prepare_store(blocks, _CTX)
502 + self.manager.complete_store(blocks, _CTX, success=True)
503 + assert self.manager._transfer_jobs
504 +
505 + # Pending promotion submission (deferred — no on_schedule_end after
506 + # the lookup that staged it).
507 + promo_block = to_keys([99])[0]
508 + self.secondary_tier1.blocks[promo_block] = True
509 + assert self.manager.lookup(promo_block, ReqContext(req_id="pending")) is None
510 + assert self.manager._pending_load_submissions
511 +
512 + # Request-level tier registration.
513 + self.secondary_tier1.on_new_request = (
514 + lambda req_context: RequestOffloadingContext(
515 + policy=OffloadPolicy.REQUEST_LEVEL
516 + )
517 + )
518 + self.manager.on_new_request(ReqContext(req_id="rl"))
519 + assert self.manager._request_level_tiers
520 +
521 + # Mark this step as already polled (reset_cache must clear it).
522 + self.manager._processed_jobs_this_step = True
523 +
524 + # Spy: pending submission must NOT reach the tier.
525 + self.secondary_tier1.submit_load = MagicMock(
526 + wraps=self.secondary_tier1.submit_load
527 + )
528 +
529 + self.manager.reset_cache()
530 +
531 + # Orchestrator state cleared.
532 + assert self.manager._transfer_jobs == {}
533 + assert self.manager._pending_load_submissions == {}
534 + assert self.manager._request_level_tiers == {}
535 + assert self.manager._processed_jobs_this_step is False
536 +
537 + # Primary tier reset to a fresh state.
538 + assert self.primary_tier._num_allocated_blocks == 0
539 + assert self.primary_tier._free_list == []
540 + for block in blocks:
541 + assert self.primary_tier.lookup(block, _CTX) is False
542 +
543 + # Pending submission was dropped, not submitted.
544 + self.secondary_tier1.submit_load.assert_not_called()
545 +
546 + def test_reset_cache_drains_all_tiers(self, manager_setup):
547 + """reset_cache must drain each secondary tier before resetting
548 + the primary tier so no tier I/O is touching primary memory.
549 + Without the drain, an in-flight transfer could write into, or
550 + read junk from, a primary slot that the post-reset path has
551 + reallocated.
552 + """
553 + self.secondary_tier1.drain_jobs = MagicMock(
554 + wraps=self.secondary_tier1.drain_jobs
555 + )
556 + self.secondary_tier2.drain_jobs = MagicMock(
557 + wraps=self.secondary_tier2.drain_jobs
558 + )
559 +
560 + # Drive a cascade so a job lands in _transfer_jobs.
561 + blocks = to_keys(range(3))
562 + self.manager.prepare_store(blocks, _CTX)
563 + self.manager.complete_store(blocks, _CTX, success=True)
564 + assert self.manager._transfer_jobs
565 +
566 + self.manager.reset_cache()
567 +
568 + self.secondary_tier1.drain_jobs.assert_called_once()
569 + self.secondary_tier2.drain_jobs.assert_called_once()
570 + assert self.manager._transfer_jobs == {}
571 +
572
573 class TestTieringOffloadingWithoutSecondaryTiers:
574 """Test TieringOffloadingManager with no secondary tiers (backward compat)."""
vllm/v1/kv_offload/tiering/base.py
+17
@@ -193,6 +193,23 @@ class SecondaryTierManager(ABC):
193 """
194 return
195
196 + @abstractmethod
197 + def drain_jobs(self) -> None:
198 + """Block until every submitted load/store job has completed or failed.
199 +
200 + After this returns, no tier I/O is touching the primary memoryview,
201 + and every submitted job's result is available from `get_finished_jobs()`
202 + (yielded by a prior call or queued for the next one). Used by
203 + `TieringOffloadingManager.reset_cache` to release primary slots
204 + without racing with in-flight transfers.
205 +
206 + Implementations must not abort a mid-flight transfer: a partial copy
207 + would corrupt either the primary memoryview or the secondary backing
208 + store. Queued (not-yet-started) transfers may be cancelled, but their
209 + failure result must still appear in `get_finished_jobs()`.
210 + """
211 + pass
212 +
213 def shutdown(self) -> None:
214 """Release resources held by this tier (threads, connections, etc.)."""
215 return
vllm/v1/kv_offload/tiering/example/manager.py
+6
@@ -142,6 +142,12 @@ class ExampleSecondaryTierManager(SecondaryTierManager):
142 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
143 return RequestOffloadingContext()
144
145 + @override
146 + def drain_jobs(self) -> None:
147 + """Synchronous tier — submit_*() returns only after the operation
148 + completes, so there is nothing to wait for."""
149 + return
150 +
151 def get_num_blocks(self) -> int:
152 """Get the number of blocks currently stored in this tier."""
153 return len(self.blocks)
vllm/v1/kv_offload/tiering/fs/manager.py
+4
@@ -179,6 +179,10 @@ class FileSystemTierManager(SecondaryTierManager):
179 )
180
181 @override
182 + def drain_jobs(self) -> None:
183 + """Block until all in-flight transfers in the threadpool finish."""
184 + self._pool.wait_idle()
185 +
186 def on_request_finished(self, req_context: ReqContext) -> None:
187 self._lookup_manager.cleanup(req_context.req_id)
188
vllm/v1/kv_offload/tiering/fs/thread_pool.py
+23 -1
@@ -68,6 +68,7 @@ class DualQueueThreadPool:
68 self._stop = False
69 self._threads: list[threading.Thread] = []
70 self._finished_q: deque[tuple[JobId, bool]] = deque()
71 + self._inflight_jobs = 0 # guarded by _condition
72
73 for i in range(n_read_threads):
74 t = threading.Thread(
@@ -98,6 +99,7 @@ class DualQueueThreadPool:
99 """Enqueue load tasks for a job (high-priority for load-priority threads)."""
100 state = JobState(job_id, n_tasks)
101 with self._condition:
102 + self._inflight_jobs += 1
103 for fn in tasks:
104 self._load_q.append((fn, state))
105 self._condition.notify(n_tasks)
@@ -111,21 +113,38 @@ class DualQueueThreadPool:
113 """Enqueue store tasks for a job (high-priority for store-priority threads)."""
114 state = JobState(job_id, n_tasks)
115 with self._condition:
116 + self._inflight_jobs += 1
117 for fn in tasks:
118 self._store_q.append((fn, state))
119 self._condition.notify(n_tasks)
120
121 def get_finished(self) -> list[tuple[JobId, bool]]:
122 + # No lock needed: deque is thread-safe for concurrent append/popleft,
123 + # and the manager is the sole popper.
124 jobs = []
125 while self._finished_q:
126 jobs.append(self._finished_q.popleft())
127 return jobs
128
129 + def wait_idle(self) -> None:
130 + """Block until there are no in-flight jobs.
131 +
132 + After this returns, every submitted job has had its last task
133 + finish, so no worker thread is still copying data. Note:
134 + completed jobs may still be sitting in ``_finished_q`` waiting
135 + for ``get_finished()`` to drain them.
136 + """
137 + with self._condition:
138 + self._condition.wait_for(lambda: self._inflight_jobs == 0)
139 +
140 def shutdown(self, wait: bool = True) -> None:
141 with self._condition:
142 self._stop = True
143 self._load_q.clear()
144 self._store_q.clear()
145 + # Cancelled tasks will not decrement _inflight_jobs; reset it so a
146 + # subsequent wait_idle() returns instead of hanging.
147 + self._inflight_jobs = 0
148 self._condition.notify_all()
149 if wait:
150 for t in self._threads:
@@ -155,4 +174,7 @@ class DualQueueThreadPool:
174 job_finished, success = state.task_done(False)
175
176 if job_finished:
158 - self._finished_q.append((state.job_id, success))
177 + with self._condition:
178 + self._finished_q.append((state.job_id, success))
179 + self._inflight_jobs -= 1
180 + self._condition.notify_all()
vllm/v1/kv_offload/tiering/manager.py
+29
@@ -590,6 +590,35 @@ class TieringOffloadingManager(OffloadingManager):
590
591 yield from self.primary_tier.take_events()
592
593 + @override
594 + def reset_cache(self) -> None:
595 + """Drop all tracked state in the orchestrator and primary tier.
596 +
597 + Called during sleep, weight update, or resume. Each secondary tier
598 + drains its in-flight transfers via drain_jobs() so no tier I/O is
599 + touching primary memory before the primary tier is reset. A stuck
600 + tier will block here visibly — preferable to silent corruption
601 + from reusing primary slots while a transfer is mid-copy.
602 +
603 + Secondary tiers are intentionally not reset: persistent stores
604 + (FS, network) keep their data across resets.
605 + """
606 + for tier in self.secondary_tiers:
607 + tier.drain_jobs()
608 + # All tier I/O has stopped; consume their completion notifications
609 + # so manager bookkeeping is consistent before the primary reset.
610 + self._process_finished_jobs()
611 +
612 + # Deferred promotion submissions reserve primary slots that the
613 + # reset below invalidates; their submit_load() has not yet been
614 + # called so no tier I/O is touching that memory.
615 + self._pending_load_submissions.clear()
616 +
617 + self.primary_tier.reset_cache()
618 +
619 + self._request_level_tiers.clear()
620 + self._processed_jobs_this_step = False
621 +
622 @override
623 def shutdown(self) -> None:
624 """Shutdown all tiers and release resources."""
vllm/v1/kv_offload/tiering/obj/manager.py
+43 -10
@@ -3,6 +3,7 @@
3 """Object store secondary tier implementation."""
4
5 import ctypes
6 +import time
7 from collections.abc import Iterable
8 from typing import TYPE_CHECKING, NamedTuple
9
@@ -104,7 +105,10 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
105 params = {**obj_config.to_nixl_params(), "num_threads": str(io_threads)}
106 self._agent.create_backend("OBJ", params)
107 self._transfers: dict[int, TransferEntry] = {}
107 - self._failed_jobs: list[JobResult] = []
108 + # Buffered results awaiting the next get_finished_jobs() call:
109 + # submission-time failures + poll-time completions accumulated
110 + # during drain_jobs().
111 + self._pending_results: list[JobResult] = []
112 self._primary_reg = None
113 self._block_size_bytes: int = 0
114 root_dir = f"{prefix}/" if prefix else ""
@@ -182,14 +186,14 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
186 files_desc = self._agent.register_memory(nixl_files, "OBJ")
187 if files_desc is None:
188 logger.warning("register_memory (OBJ) failed for job %d", job_id)
185 - self._failed_jobs.append(JobResult(job_id=job_id, success=False))
189 + self._pending_results.append(JobResult(job_id=job_id, success=False))
190 return
191
192 obj_handle = self._agent.prep_xfer_dlist("ObjAgent", files_desc.trim())
193 if not obj_handle:
194 logger.warning("prep_xfer_dlist (OBJ) failed for job %d", job_id)
195 self._agent.deregister_memory(files_desc)
192 - self._failed_jobs.append(JobResult(job_id=job_id, success=False))
196 + self._pending_results.append(JobResult(job_id=job_id, success=False))
197 return
198
199 xfer_handle = self._agent.make_prepped_xfer(
@@ -203,7 +207,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
207 logger.warning("make_prepped_xfer failed for job %d", job_id)
208 self._agent.release_dlist_handle(obj_handle)
209 self._agent.deregister_memory(files_desc)
206 - self._failed_jobs.append(JobResult(job_id=job_id, success=False))
210 + self._pending_results.append(JobResult(job_id=job_id, success=False))
211 return
212
213 state = self._agent.transfer(xfer_handle)
@@ -212,7 +216,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
216 self._agent.release_dlist_handle(obj_handle)
217 self._agent.deregister_memory(files_desc)
218 self._agent.release_xfer_handle(xfer_handle)
215 - self._failed_jobs.append(JobResult(job_id=job_id, success=False))
219 + self._pending_results.append(JobResult(job_id=job_id, success=False))
220 return
221
222 self._transfers[job_id] = TransferEntry(xfer_handle, files_desc, obj_handle)
@@ -241,10 +245,9 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
245 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
246 return RequestOffloadingContext()
247
244 - def get_finished_jobs(self) -> Iterable[JobResult]:
245 - """Poll in-flight transfers; return completed (job_id, success) pairs."""
246 - results: list[JobResult] = self._failed_jobs
247 - self._failed_jobs = []
248 + def _poll_active_transfers(self) -> None:
249 + """Poll all in-flight transfers once; move newly-completed (success or
250 + failure) into ``_pending_results`` and release their NIXL handles."""
251 for job_id, entry in list(self._transfers.items()):
252 try:
253 state = self._agent.check_xfer_state(entry.xfer_handle)
@@ -263,9 +266,39 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
266 self._agent.release_xfer_handle(entry.xfer_handle)
267 self._agent.release_dlist_handle(entry.obj_handle)
268 self._agent.deregister_memory(entry.files_desc)
266 - results.append(JobResult(job_id=job_id, success=success))
269 + self._pending_results.append(JobResult(job_id=job_id, success=success))
270 +
271 + def get_finished_jobs(self) -> Iterable[JobResult]:
272 + """Poll in-flight transfers; return completed (job_id, success) pairs."""
273 + self._poll_active_transfers()
274 + results = self._pending_results
275 + self._pending_results = []
276 return results
277
278 + def drain_jobs(self) -> None:
279 + """Block until every submitted transfer has completed or failed.
280 +
281 + nixl exposes only ``check_xfer_state`` (poll-based), so this loops
282 + until ``_transfers`` is empty. Results accumulate in
283 + ``_pending_results`` and are surfaced by the next
284 + ``get_finished_jobs()`` call.
285 + """
286 + start = time.monotonic()
287 + warned = False
288 + while self._transfers:
289 + self._poll_active_transfers()
290 + if not self._transfers:
291 + break
292 + if not warned and time.monotonic() - start > 5.0:
293 + logger.warning(
294 + "ObjectStoreSecondaryTierManager.drain_jobs: still "
295 + "draining after 5s (%d transfers in flight); a stuck "
296 + "transfer will block the engine.",
297 + len(self._transfers),
298 + )
299 + warned = True
300 + time.sleep(0.001)
301 +
302 def shutdown(self) -> None:
303 self._lookup_manager.shutdown()
304 for job_id, entry in self._transfers.items():