A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 10, 09:00 UTC
[KVConnector] OffloadingConnector: Fix bug in handling of preemptions (#29870)
Signed-off-by: Or Ozeri <oro@il.ibm.com>
Or Ozeri committed
Jan 11, 2026 at 10:05 UTC
4c16ba617f76b342dd0e62deba1f96ed6cee74fa
7 files changed
+255
-64
tests/v1/kv_connector/unit/test_offloading_connector.py
+175
-64
@@ -64,8 +64,11 @@ class MockLoadStoreSpec(LoadStoreSpec):
64
65
class MockOffloadingHandler(OffloadingHandler):
66
def __init__(self):
67
+ self.transfer_specs: dict[int, TransferSpec] = {}
68
self.completed_transfers: list[TransferResult] = []
68
- self.completed_specs: list[TransferSpec] = []
69
+ self.waiting_jobs: set[int] = set()
70
+ self.completed_jobs: list[int] = []
71
+ self.flushed_jobs: set[int] = set()
72
73
def get_finished(self) -> list[TransferResult]:
74
finished = self.completed_transfers
@@ -73,10 +76,21 @@ class MockOffloadingHandler(OffloadingHandler):
76
return finished
77
78
def transfer_async(self, job_id: int, spec: TransferSpec) -> bool:
76
- self.completed_specs.append(spec)
77
- self.completed_transfers.append((job_id, True))
79
+ self.transfer_specs[job_id] = spec
80
+ self.waiting_jobs.add(job_id)
81
return True
82
83
+ def complete_jobs(self, job_ids: set[int]) -> None:
84
+ for job_id in job_ids:
85
+ if job_id in self.waiting_jobs:
86
+ self.waiting_jobs.remove(job_id)
87
+ self.completed_jobs.append(job_id)
88
+ self.completed_transfers.append((job_id, True))
89
+
90
+ def wait(self, job_ids: set[int]) -> None:
91
+ self.flushed_jobs |= job_ids
92
+ self.complete_jobs(job_ids)
93
+
94
95
class MockOffloadingSpec(OffloadingSpec):
96
def __init__(self, vllm_config: VllmConfig):
@@ -98,9 +112,22 @@ class MockOffloadingSpec(OffloadingSpec):
112
yield GPULoadStoreSpec, MockLoadStoreSpec, self.handler
113
yield MockLoadStoreSpec, GPULoadStoreSpec, self.handler
114
115
+ def complete_transfers(self):
116
+ self.handler.complete_jobs(self.handler.waiting_jobs.copy())
117
+
118
def get_completed_transfers(self) -> list[TransferSpec]:
102
- specs = self.handler.completed_specs
103
- self.handler.completed_specs = []
119
+ specs = [
120
+ self.handler.transfer_specs[job_id]
121
+ for job_id in self.handler.completed_jobs
122
+ ]
123
+ self.handler.completed_jobs.clear()
124
+ return specs
125
+
126
+ def get_flushed_transfers(self):
127
+ specs = [
128
+ self.handler.transfer_specs[job_id] for job_id in self.handler.flushed_jobs
129
+ ]
130
+ self.handler.flushed_jobs.clear()
131
return specs
132
133
@@ -170,12 +197,9 @@ class RequestRunner:
197
# mapping (offloading address) -> gpu_block_index
198
self.offloaded: dict[Any, int] = {}
199
173
- self.pending_loads_count: int = 0
174
- self.pending_stores_count: int = 0
175
- self.unsubmitted_stores_count = 0
176
-
200
self.completed_loads: list[TransferSummary] = []
201
self.completed_stores: list[TransferSummary] = []
202
+ self.flushed_gpu_block_indexes: set[int] = set()
203
204
# maps {block_id: block_offset}
205
self.gpu_block_index: dict[int, int] = {}
@@ -202,54 +226,60 @@ class RequestRunner:
226
227
self.scheduler.add_request(req)
228
205
- def _wait_for_transfers(self):
229
+ def _parse_transfers(self):
230
+ for transfer_spec in self.offloading_spec.get_flushed_transfers():
231
+ src_spec, dst_spec = transfer_spec
232
+ assert isinstance(src_spec, GPULoadStoreSpec)
233
+
234
+ for block_id in src_spec.block_ids:
235
+ self.flushed_gpu_block_indexes.add(
236
+ self.gpu_block_index[block_id.item()]
237
+ )
238
+
239
block_size_factor = self.offloaded_block_size // self.gpu_block_size
240
208
- while self.pending_loads_count or self.pending_stores_count:
209
- for transfer_spec in self.offloading_spec.get_completed_transfers():
210
- src_spec, dst_spec = transfer_spec
211
-
212
- if isinstance(src_spec, GPULoadStoreSpec):
213
- store = True
214
- gpu_spec = src_spec
215
- offload_spec = dst_spec
216
- else:
217
- store = False
218
- gpu_spec = dst_spec
219
- offload_spec = src_spec
220
-
221
- assert isinstance(offload_spec, MockLoadStoreSpec)
222
- assert isinstance(gpu_spec, GPULoadStoreSpec)
223
-
224
- gpu_block_indices: list[int] = []
225
- for block_id in gpu_spec.block_ids:
226
- gpu_block_indices.append(self.gpu_block_index[block_id.item()])
227
-
228
- # list of (block_hash, sub_block_offset)
229
- offload_addresses: list[Any] = []
230
- for block_hash in offload_spec.block_hashes:
231
- for sub_block_idx in range(block_size_factor):
232
- offload_addresses.append((block_hash, sub_block_idx))
233
-
234
- if store:
235
- assert len(gpu_block_indices) == len(offload_addresses)
236
-
237
- self.completed_stores.append(
238
- TransferSummary(gpu_block_indices, offload_addresses)
239
- )
240
- self.pending_stores_count -= 1
241
- else:
242
- remainder_sub_block_count = len(offload_addresses) - len(
243
- gpu_block_indices
244
- )
245
- assert remainder_sub_block_count >= 0
246
- assert remainder_sub_block_count < block_size_factor
247
- offload_addresses = offload_addresses[remainder_sub_block_count:]
248
-
249
- self.completed_loads.append(
250
- TransferSummary(gpu_block_indices, offload_addresses)
251
- )
252
- self.pending_loads_count -= 1
241
+ for transfer_spec in self.offloading_spec.get_completed_transfers():
242
+ src_spec, dst_spec = transfer_spec
243
+
244
+ if isinstance(src_spec, GPULoadStoreSpec):
245
+ store = True
246
+ gpu_spec = src_spec
247
+ offload_spec = dst_spec
248
+ else:
249
+ store = False
250
+ gpu_spec = dst_spec
251
+ offload_spec = src_spec
252
+
253
+ assert isinstance(offload_spec, MockLoadStoreSpec)
254
+ assert isinstance(gpu_spec, GPULoadStoreSpec)
255
+
256
+ gpu_block_indices: list[int] = []
257
+ for block_id in gpu_spec.block_ids:
258
+ gpu_block_indices.append(self.gpu_block_index[block_id.item()])
259
+
260
+ # list of (block_hash, sub_block_offset)
261
+ offload_addresses: list[Any] = []
262
+ for block_hash in offload_spec.block_hashes:
263
+ for sub_block_idx in range(block_size_factor):
264
+ offload_addresses.append((block_hash, sub_block_idx))
265
+
266
+ if store:
267
+ assert len(gpu_block_indices) == len(offload_addresses)
268
+
269
+ self.completed_stores.append(
270
+ TransferSummary(gpu_block_indices, offload_addresses)
271
+ )
272
+ else:
273
+ remainder_sub_block_count = len(offload_addresses) - len(
274
+ gpu_block_indices
275
+ )
276
+ assert remainder_sub_block_count >= 0
277
+ assert remainder_sub_block_count < block_size_factor
278
+ offload_addresses = offload_addresses[remainder_sub_block_count:]
279
+
280
+ self.completed_loads.append(
281
+ TransferSummary(gpu_block_indices, offload_addresses)
282
+ )
283
284
def _update_gpu_block_idx(self):
285
for blocks in self.scheduler.kv_cache_manager.coordinator.single_type_managers[
@@ -258,18 +288,19 @@ class RequestRunner:
288
for block_idx, block in enumerate(blocks):
289
self.gpu_block_index[block.block_id] = block_idx
290
261
- def _run(self, decoded_tokens: list[int]):
291
+ def _run(self, decoded_tokens: list[int], complete_transfers: bool):
292
"""
293
Runs multiple engine (scheduler + worker) steps.
294
Assumes a single request is running.
295
296
Args:
297
decoded_tokens: the tokens to yield at each step.
298
+ complete_transfers: complete transfers immediately
299
"""
300
301
tokens_iter = iter(decoded_tokens)
302
token_id = next(tokens_iter, None)
272
- while token_id is not None:
303
+ while True:
304
assert self.scheduler.requests
305
306
scheduler_output = self.scheduler.schedule()
@@ -279,10 +310,10 @@ class RequestRunner:
310
assert kv_connector_metadata is not None
311
assert isinstance(kv_connector_metadata, OffloadingConnectorMetadata)
312
282
- self.pending_loads_count += len(kv_connector_metadata.reqs_to_load)
283
-
284
- self.pending_stores_count += self.unsubmitted_stores_count
285
- self.unsubmitted_stores_count = len(kv_connector_metadata.reqs_to_store)
313
+ if scheduler_output.preempted_req_ids:
314
+ self.worker_connector.handle_preemptions(
315
+ scheduler_output.preempted_req_ids
316
+ )
317
318
self.worker_connector.bind_connector_metadata(kv_connector_metadata)
319
self.worker_connector.start_load_kv(self._dummy_ctx)
@@ -290,6 +321,9 @@ class RequestRunner:
321
if scheduler_output.total_num_scheduled_tokens > 0:
322
self.worker_connector.wait_for_save()
323
324
+ if complete_transfers:
325
+ self.offloading_spec.complete_transfers()
326
+
327
finished_sending, finished_recving = self.worker_connector.get_finished(
328
scheduler_output.finished_req_ids
329
)
@@ -300,7 +334,7 @@ class RequestRunner:
334
reqs=self.scheduler.running,
335
finished_sending=finished_sending,
336
finished_recving=finished_recving,
303
- token_id=token_id,
337
+ token_id=token_id or 0,
338
)
339
340
if self.scheduler.running:
@@ -308,7 +342,10 @@ class RequestRunner:
342
343
self.scheduler.update_from_output(scheduler_output, model_runner_output)
344
311
- self._wait_for_transfers()
345
+ if token_id is None:
346
+ break
347
+
348
+ self._parse_transfers()
349
350
# run one more step to update finished stored
351
if EOS_TOKEN_ID in decoded_tokens:
@@ -333,8 +370,10 @@ class RequestRunner:
370
def run(
371
self,
372
decoded_tokens: list[int],
373
+ complete_transfers: bool = True,
374
expected_stored_gpu_block_indexes: tuple[int, ...] = (),
375
expected_loaded_gpu_block_indexes: tuple[int, ...] = (),
376
+ expected_flushed_gpu_block_indexes: tuple[int, ...] = (),
377
):
378
"""
379
Runs multiple engine (scheduler + worker) steps.
@@ -342,14 +381,17 @@ class RequestRunner:
381
382
Args:
383
decoded_tokens: the tokens to yield at each step.
384
+ complete_transfers: complete transfers immediately
385
expected_stored_gpu_block_indexes: GPU block indexes
386
that are expected to be written during the run.
387
expected_loaded_gpu_block_indexes: GPU block indexes
388
that are expected to be loaded during the run.
389
+ expected_flushed_gpu_block_indexes: GPU block indexes
390
+ that are expected to be flushed during the run.
391
"""
392
393
self.manager.reset_mock()
352
- self._run(decoded_tokens)
394
+ self._run(decoded_tokens, complete_transfers)
395
396
loaded_gpu_block_indexes: set[int] = set()
397
for transfer in self.completed_loads:
@@ -373,6 +415,9 @@ class RequestRunner:
415
assert set(expected_stored_gpu_block_indexes) == stored_gpu_block_indexes
416
self.completed_stores.clear()
417
418
+ assert set(expected_flushed_gpu_block_indexes) == self.flushed_gpu_block_indexes
419
+ self.flushed_gpu_block_indexes.clear()
420
+
421
422
@pytest.fixture
423
def request_runner():
@@ -539,3 +584,69 @@ def test_offloading_connector(request_runner):
584
assert isinstance(event, BlockRemoved)
585
assert event.block_hashes == to_hashes([4, 5, 6])
586
assert event.medium == "B"
587
+
588
+
589
+def test_request_preemption(request_runner):
590
+ offloaded_block_size = 12
591
+ gpu_block_size = 4
592
+ num_gpu_blocks = 100
593
+
594
+ runner = request_runner(
595
+ offloaded_block_size=offloaded_block_size,
596
+ gpu_block_size=gpu_block_size,
597
+ num_gpu_blocks=num_gpu_blocks,
598
+ )
599
+
600
+ free_block_queue = runner.scheduler.kv_cache_manager.block_pool.free_block_queue
601
+ num_free_blocks_empty = free_block_queue.num_free_blocks
602
+
603
+ # 2 blocks, store all, without flushing
604
+ # blocks = [0, 1, 2], [3, 4, 5]
605
+ runner.new_request(token_ids=[0] * offloaded_block_size * 2)
606
+ runner.manager.prepare_store.side_effect = (
607
+ lambda block_hashes: generate_store_output(block_hashes)
608
+ )
609
+ runner.run(
610
+ decoded_tokens=[0],
611
+ complete_transfers=False,
612
+ )
613
+
614
+ # decode 2 more blocks - 1 gpu block, storing [6, 7, 8] (no flush)
615
+ runner.manager.prepare_store.side_effect = (
616
+ lambda block_hashes: generate_store_output(block_hashes)
617
+ )
618
+ runner.run(
619
+ decoded_tokens=[0] * (2 * offloaded_block_size - gpu_block_size),
620
+ complete_transfers=False,
621
+ )
622
+
623
+ # simulate KV cache running out of space
624
+ free_block_queue.num_free_blocks = 0
625
+
626
+ # request should be preempted now
627
+ runner.run(
628
+ decoded_tokens=[],
629
+ complete_transfers=False,
630
+ expected_flushed_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8),
631
+ expected_stored_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8),
632
+ )
633
+
634
+ # restore KV cache space and reset GPU prefix cache
635
+ free_block_queue.num_free_blocks = num_free_blocks_empty
636
+ runner.scheduler.reset_prefix_cache()
637
+
638
+ # request should now return from preemption
639
+ # re-load [0, ..., 8] from the CPU and store [9, 10, 11]
640
+ runner.manager.lookup.return_value = 3
641
+ runner.manager.prepare_store.side_effect = (
642
+ lambda block_hashes: generate_store_output(block_hashes)
643
+ )
644
+ runner.run(
645
+ decoded_tokens=[0] * gpu_block_size,
646
+ expected_loaded_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8),
647
+ )
648
+
649
+ runner.run(
650
+ decoded_tokens=[EOS_TOKEN_ID],
651
+ expected_stored_gpu_block_indexes=(9, 10, 11),
652
+ )
tests/v1/kv_offload/test_worker.py
+12
@@ -63,6 +63,12 @@ class OffloadingHandler1To2(OffloadingHandler):
63
del self.transfers[job_id]
64
return finished
65
66
+ def wait(self, job_ids: set[int]) -> None:
67
+ for job_id in job_ids:
68
+ spec = self.transfers.get(job_id)
69
+ if spec:
70
+ assert spec.finished
71
+
72
73
class OffloadingHandler2To1(OffloadingHandler):
74
def __init__(self):
@@ -84,6 +90,12 @@ class OffloadingHandler2To1(OffloadingHandler):
90
del self.transfers[job_id]
91
return finished
92
93
+ def wait(self, job_ids: set[int]) -> None:
94
+ for job_id in job_ids:
95
+ spec = self.transfers.get(job_id)
96
+ if spec:
97
+ assert spec.finished
98
+
99
100
def test_offloading_worker():
101
"""
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+10
@@ -25,6 +25,9 @@ The class provides the following primitives:
25
26
Worker-side: runs in each worker, loads/saves KV cache to/from
27
the Connector based on the metadata.
28
+ handle_preemptions() - called if there are preempted requests,
29
+ before their blocks are overwritten
30
+
31
start_load_kv() - starts loading all KVs (maybe async)
32
wait_for_layer_load() - blocks until layer i load is done
33
@@ -262,6 +265,13 @@ class KVConnectorBase_V1(ABC):
265
"""
266
return
267
268
+ def handle_preemptions(self, preempted_req_ids: set[str]):
269
+ """
270
+ Handle preempted requests BEFORE their blocks are overwritten.
271
+ Needed for connectors which use async saves (e.g., OffloadingConnector)
272
+ """
273
+ return
274
+
275
@abstractmethod
276
def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
277
"""
vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py
+24
@@ -75,6 +75,10 @@ class OffloadingConnector(KVConnectorBase_V1):
75
assert self.connector_worker is not None
76
self.connector_worker.register_cross_layers_kv_cache(kv_cache, attn_backend)
77
78
+ def handle_preemptions(self, preempted_req_ids: set[str]):
79
+ assert self.connector_worker is not None
80
+ self.connector_worker.handle_preemptions(preempted_req_ids)
81
+
82
def start_load_kv(self, forward_context: "ForwardContext", **kwargs) -> None:
83
assert self.connector_worker is not None
84
assert isinstance(self._connector_metadata, OffloadingConnectorMetadata)
@@ -348,6 +352,15 @@ class OffloadingConnectorScheduler:
352
reqs_to_store=self._get_reqs_to_store(scheduler_output),
353
)
354
self._reqs_to_load = {}
355
+
356
+ # NOTE (orozery): we should move this logic to update_connector_output
357
+ # once KVConnectorOutput allows us to report completed transfers
358
+ for req_id in scheduler_output.preempted_req_ids or ():
359
+ block_hashes = self._reqs_being_stored.get(req_id)
360
+ if block_hashes:
361
+ self.manager.complete_store(block_hashes)
362
+ block_hashes.clear()
363
+
364
return meta
365
366
def update_connector_output(self, connector_output: KVConnectorOutput):
@@ -466,6 +479,17 @@ class OffloadingConnectorWorker:
479
attn_backends = {cross_layer_name: attn_backend}
480
self._register_handlers(kv_caches, attn_backends)
481
482
+ def handle_preemptions(self, preempted_req_ids: set[str]):
483
+ for job_id, transfer_spec in self._unsubmitted_store_jobs:
484
+ success = self.worker.transfer_async(job_id, transfer_spec)
485
+ assert success
486
+ self._unsubmitted_store_jobs.clear()
487
+
488
+ for req_id in preempted_req_ids:
489
+ job_ids = self._store_jobs.get(req_id)
490
+ if job_ids:
491
+ self.worker.wait(job_ids)
492
+
493
def start_kv_transfers(self, metadata: OffloadingConnectorMetadata):
494
for job_id, transfer_spec in self._unsubmitted_store_jobs:
495
success = self.worker.transfer_async(job_id, transfer_spec)
vllm/v1/kv_offload/worker/cpu_gpu.py
+10
@@ -96,6 +96,8 @@ class SingleDirectionOffloadingHandler(OffloadingHandler):
96
assert len(src_tensors) > 0
97
self.gpu_to_cpu: bool = self.src_tensors[0].is_cuda
98
99
+ # job_id -> event
100
+ self._transfer_events: dict[int, torch.Event] = {}
101
# queue of transfers (job_id, stream, event)
102
self._transfers: deque[tuple[int, torch.cuda.Stream, torch.Event]] = deque()
103
# list of CUDA streams available for re-use
@@ -152,6 +154,7 @@ class SingleDirectionOffloadingHandler(OffloadingHandler):
154
ops.swap_blocks(src_tensor, dst_tensor, src_to_dst_tensor)
155
event.record(stream)
156
157
+ self._transfer_events[job_id] = event
158
self._transfers.append((job_id, stream, event))
159
160
# success
@@ -164,8 +167,15 @@ class SingleDirectionOffloadingHandler(OffloadingHandler):
167
results.append((job_id, True))
168
self._stream_pool.append(stream)
169
self._event_pool.append(event)
170
+ del self._transfer_events[job_id]
171
return results
172
173
+ def wait(self, job_ids: set[int]):
174
+ for job_id in job_ids:
175
+ event = self._transfer_events.get(job_id)
176
+ if event is not None:
177
+ event.synchronize()
178
+
179
180
class CpuGpuOffloadingHandlers:
181
def __init__(
vllm/v1/kv_offload/worker/worker.py
+19
@@ -53,6 +53,15 @@ class OffloadingHandler(ABC):
53
"""
54
pass
55
56
+ @abstractmethod
57
+ def wait(self, job_ids: set[int]) -> None:
58
+ """
59
+ Wait for jobs to finish (blocking).
60
+
61
+ Args:
62
+ job_ids: The set of job IDs to wait for.
63
+ """
64
+
65
66
class OffloadingWorker:
67
"""
@@ -142,3 +151,13 @@ class OffloadingWorker:
151
for handler in self.handlers:
152
finished.extend(handler.get_finished())
153
return finished
154
+
155
+ def wait(self, job_ids: set[int]) -> None:
156
+ """
157
+ Wait for jobs to finish (blocking).
158
+
159
+ Args:
160
+ job_ids: The set of job IDs to wait for.
161
+ """
162
+ for handler in self.handlers:
163
+ handler.wait(job_ids)
vllm/v1/worker/gpu_model_runner.py
+5
@@ -3112,6 +3112,11 @@ class GPUModelRunner(
3112
"after execute_model() returns None."
3113
)
3114
3115
+ if scheduler_output.preempted_req_ids and has_kv_transfer_group():
3116
+ get_kv_transfer_group().handle_preemptions(
3117
+ scheduler_output.preempted_req_ids
3118
+ )
3119
+
3120
num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
3121
with (
3122
record_function_or_nullcontext("gpu_model_runner: preprocess"),