[kv_offload] Emit tier-owned BlockStored events from FS/OBJ secondary tiers (#47923)
Signed-off-by: Change72 <changg@nvidia.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
Chang Guo committed
Jul 9, 2026 at 20:17 UTC
2d814a00820daec7082599bea75ae1d0959a346c
6 files changed
+433
-9
docs/features/kv_offloading_usage.md
+28
@@ -81,6 +81,8 @@ vllm serve <model> \
81
82
Each entry in `secondary_tiers` is a dict with a required `type` field plus tier-specific fields.
83
84
+The filesystem and object-store tiers can publish hash-only `BlockStored` KV events for blocks they successfully store, tagged with a stable per-tier `medium` (`FS` for the filesystem tier, `OBJ` for the object-store tier). Set `enable_kv_events: true` in the tier's entry to opt in; events are published only when KV cache events are also enabled globally via `--kv-events-config`.
85
+
86
### Filesystem (FS)
87
88
The filesystem tier (`type: "fs"`) writes blocks to a directory on local storage.
@@ -91,6 +93,7 @@ The filesystem tier (`type: "fs"`) writes blocks to a directory on local storage
93
| `root_dir` | yes | — | Base directory; vLLM creates subdirectories beneath it (see [On-Disk Layout](#on-disk-layout)). |
94
| `n_read_threads` | no | `16` | Read-priority I/O threads (load path). |
95
| `n_write_threads` | no | `16` | Write-priority I/O threads (store path). |
96
+| `enable_kv_events` | no | `false` | Publish `BlockStored` KV events (medium `FS`) for successfully stored blocks. Requires KV cache events to be enabled globally. |
97
98
Each thread group prefers its own queue but pulls from the other when its primary queue is empty, so a write-heavy or read-heavy burst won't leave the off-priority queue waiting. Size the totals to your storage's effective concurrency.
99
@@ -120,6 +123,31 @@ To enable KV cache sharing between multiple vLLM instances using the same `root_
123
PYTHONHASHSEED=0 vllm serve ...
124
```
125
126
+### Object Store (OBJ)
127
+
128
+The object-store tier (`type: "obj"`) offloads blocks to an S3-compatible object store through the NIXL OBJ backend.
129
+
130
+| Key | Required | Default | Notes |
131
+| --- | --- | --- | --- |
132
+| `type` | yes | — | Must be `obj`. |
133
+| `store_config` | yes | — | Object store connection parameters (see below). |
134
+| `prefix` | no | `""` | Key prefix prepended to all object keys. |
135
+| `io_threads` | no | `4` | Number of NIXL OBJ backend I/O threads. |
136
+| `enable_kv_events` | no | `false` | Publish `BlockStored` KV events (medium `OBJ`) for successfully stored blocks. Requires KV cache events to be enabled globally. |
137
+
138
+`store_config` fields:
139
+
140
+| Key | Required | Default | Notes |
141
+| --- | --- | --- | --- |
142
+| `bucket` | yes | — | Bucket name. |
143
+| `endpoint_override` | yes | — | Object store endpoint host; the URL scheme is set separately via `scheme`. |
144
+| `scheme` | no | `http` | `http` or `https`. |
145
+| `access_key`, `secret_key`, `session_token` | no | `""` | Explicit credentials. When left empty, the NIXL OBJ plugin falls back to the AWS SDK default credential provider chain (IAM roles, environment variables, credential files), which enables workload-identity auth on Kubernetes. |
146
+| `region` | no | `""` | Bucket region, if the endpoint requires one. |
147
+| `ca_bundle` | no | `""` | CA bundle path for TLS verification. |
148
+
149
+Object keys follow the same run-configuration digest scheme as the filesystem tier (see [On-Disk Layout](#on-disk-layout)) and are stored under the optional `prefix`. The [Cross-Process Sharing](#cross-process-sharing) requirement (`PYTHONHASHSEED`) applies to shared buckets as well, so instances sharing a bucket produce identical keys for identical content. At startup the tier probes object store connectivity and fails fast with a configuration error if the bucket is unreachable.
150
+
151
### P2P (Including P/D)
152
153
The P2P tier (`type: "p2p"`) shares completed KV blocks between vLLM instances over RDMA via NIXL. Each instance binds a control socket on `host:port` and exchanges blocks directly with peers — no shared filesystem required.
tests/v1/kv_offload/tiering/test_fs_tier.py
+196
@@ -18,8 +18,10 @@ import numpy as np
18
import pytest
19
import torch
20
21
+from vllm.distributed.kv_events import MEDIUM_FS
22
from vllm.v1.kv_offload.base import (
23
LookupResult,
24
+ OffloadingEvent,
25
OffloadKey,
26
ReqContext,
27
ScheduleEndContext,
@@ -58,6 +60,16 @@ _MOCK_OFFLOADING_SPEC.kv_cache_config = _MOCK_KV_CACHE_CONFIG
60
_MOCK_OFFLOADING_SPEC.block_size_factor = 1
61
62
63
+def _make_offloading_spec(enable_kv_cache_events: bool) -> MagicMock:
64
+ """Mock spec with an explicit global KV events flag."""
65
+ spec = MagicMock()
66
+ spec.vllm_config = _MOCK_VLLM_CONFIG
67
+ spec.kv_cache_config = _MOCK_KV_CACHE_CONFIG
68
+ spec.block_size_factor = 1
69
+ spec.kv_events_config.enable_kv_cache_events = enable_kv_cache_events
70
+ return spec
71
+
72
+
73
def key(n: int) -> OffloadKey:
74
return make_offload_key(n.to_bytes(8, "big"), 0)
75
@@ -164,6 +176,23 @@ def fs_tier(tmp_path):
176
tier.shutdown()
177
178
179
+@pytest.fixture
180
+def fs_tier_with_events(tmp_path):
181
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
182
+ mock_view = memoryview(tensor.numpy())
183
+ tier = FileSystemTierManager(
184
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=True),
185
+ primary_kv_view=mock_view,
186
+ tier_type="fs",
187
+ root_dir=str(tmp_path),
188
+ n_read_threads=4,
189
+ n_write_threads=4,
190
+ enable_kv_events=True,
191
+ )
192
+ yield tier
193
+ tier.shutdown()
194
+
195
+
196
# ---------------------------------------------------------------------------
197
# Tests
198
# ---------------------------------------------------------------------------
@@ -396,3 +425,170 @@ def test_batch_lookup_dispatch(fs_tier, monkeypatch, use_c_ext):
425
426
results = lookup_and_wait(tier, [key(1), key(2)])
427
assert results == [LookupResult.HIT, LookupResult.MISS]
428
+
429
+
430
+# ---------------------------------------------------------------------------
431
+# KV events
432
+# ---------------------------------------------------------------------------
433
+
434
+
435
+def test_successful_store_emits_stored_event(fs_tier_with_events):
436
+ """A completed store job emits one stored event with the job's keys."""
437
+ tier = fs_tier_with_events
438
+ keys = [key(1), key(2)]
439
+ tier.submit_store(make_job(1, keys, [0, 1]))
440
+ assert all(r.success for r in drain(tier))
441
+
442
+ events = list(tier.take_events())
443
+ assert len(events) == 1
444
+ assert events[0].keys == keys
445
+ # Literal medium pins the wire contract, not just the constant choice.
446
+ assert events[0].medium == "FS"
447
+ assert not events[0].removed
448
+ # take_events drains the buffer.
449
+ assert list(tier.take_events()) == []
450
+
451
+
452
+def test_load_job_emits_no_event(fs_tier_with_events):
453
+ tier = fs_tier_with_events
454
+ tier.submit_store(make_job(1, [key(1)], [0]))
455
+ results = drain(tier)
456
+ assert len(results) == 1
457
+ assert results[0].success
458
+ list(tier.take_events())
459
+
460
+ tier.submit_load(make_job(2, [key(1)], [1], is_promotion=True))
461
+ results = drain(tier)
462
+ assert len(results) == 1
463
+ assert results[0].success
464
+ assert list(tier.take_events()) == []
465
+
466
+
467
+def test_mixed_job_results_emit_event_only_for_successful_job(
468
+ fs_tier_with_events, monkeypatch
469
+):
470
+ """With a failed and a successful store job in flight, exactly one event
471
+ is emitted and its keys belong to the successful job."""
472
+ import vllm.v1.kv_offload.tiering.fs.manager as mgr_mod
473
+
474
+ tier = fs_tier_with_events
475
+ failing_path = tier.file_mapper.get_file_name(key(1))
476
+ original_store_block = mgr_mod.store_block
477
+
478
+ def flaky_store_block(dest_path, *args, **kwargs):
479
+ if dest_path == failing_path:
480
+ raise OSError("injected store failure")
481
+ return original_store_block(dest_path, *args, **kwargs)
482
+
483
+ monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
484
+
485
+ tier.submit_store(make_job(1, [key(1)], [0]))
486
+ tier.submit_store(make_job(2, [key(2)], [1]))
487
+ results = drain(tier)
488
+ assert len(results) == 2
489
+ by_id = {r.job_id: r for r in results}
490
+ assert not by_id[1].success
491
+ assert by_id[2].success
492
+
493
+ events = list(tier.take_events())
494
+ assert len(events) == 1
495
+ assert events[0].keys == [key(2)]
496
+
497
+
498
+def test_partially_failed_store_emits_no_event(fs_tier_with_events, monkeypatch):
499
+ """A store job with any failed block emits no event for the whole job."""
500
+ import vllm.v1.kv_offload.tiering.fs.manager as mgr_mod
501
+
502
+ tier = fs_tier_with_events
503
+ failing_path = tier.file_mapper.get_file_name(key(2))
504
+ original_store_block = mgr_mod.store_block
505
+
506
+ def flaky_store_block(dest_path, *args, **kwargs):
507
+ if dest_path == failing_path:
508
+ raise OSError("injected store failure")
509
+ return original_store_block(dest_path, *args, **kwargs)
510
+
511
+ monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
512
+
513
+ tier.submit_store(make_job(1, [key(1), key(2)], [0, 1]))
514
+ results = drain(tier)
515
+ assert len(results) == 1
516
+ assert not results[0].success
517
+ assert list(tier.take_events()) == []
518
+ assert tier._store_job_keys == {}
519
+
520
+
521
+def test_events_disabled_by_default(fs_tier):
522
+ tier, _ = fs_tier
523
+ tier.submit_store(make_job(1, [key(1)], [0]))
524
+ results = drain(tier)
525
+ assert len(results) == 1
526
+ assert results[0].success
527
+ assert tier.events is None
528
+ assert tier._store_job_keys == {}
529
+ assert list(tier.take_events()) == []
530
+
531
+
532
+def test_events_require_global_kv_events_flag(tmp_path):
533
+ """Tier-level opt-in alone is not enough; the global flag gates events."""
534
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
535
+ tier = FileSystemTierManager(
536
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=False),
537
+ primary_kv_view=memoryview(tensor.numpy()),
538
+ tier_type="fs",
539
+ root_dir=str(tmp_path),
540
+ enable_kv_events=True,
541
+ )
542
+ try:
543
+ assert tier.events is None
544
+ tier.submit_store(make_job(1, [key(1)], [0]))
545
+ results = drain(tier)
546
+ assert len(results) == 1
547
+ assert results[0].success
548
+ assert list(tier.take_events()) == []
549
+ assert tier._store_job_keys == {}
550
+ finally:
551
+ tier.shutdown()
552
+
553
+
554
+def test_cascade_store_emits_fs_event_through_tiering_manager(tmp_path):
555
+ """A GPU->CPU->fs cascade surfaces the tier-owned FS stored event via the
556
+ TieringOffloadingManager's aggregated take_events()."""
557
+ from vllm.v1.kv_offload.tiering.manager import (
558
+ CPUPrimaryTierOffloadingManager,
559
+ TieringOffloadingManager,
560
+ )
561
+
562
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
563
+ view = memoryview(tensor.numpy())
564
+ mock_region = MagicMock()
565
+ mock_region.create_kv_memoryview.return_value = view
566
+ primary = CPUPrimaryTierOffloadingManager(num_blocks=4, mmap_region=mock_region)
567
+ tier = FileSystemTierManager(
568
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=True),
569
+ primary_kv_view=primary.get_kv_memoryview(),
570
+ tier_type="fs",
571
+ root_dir=str(tmp_path),
572
+ enable_kv_events=True,
573
+ )
574
+ manager = TieringOffloadingManager(primary_tier=primary, secondary_tiers=[tier])
575
+ try:
576
+ keys = [key(1), key(2)]
577
+ manager.on_new_request(_CTX)
578
+ assert manager.prepare_store(keys, _CTX) is not None
579
+ manager.complete_store(keys, _CTX) # cascades to the fs tier
580
+
581
+ events: list[OffloadingEvent] = []
582
+ ctx = ScheduleEndContext(new_req_ids=[], preempted_req_ids=())
583
+ deadline = time.monotonic() + 5.0
584
+ while time.monotonic() < deadline and not events:
585
+ manager.on_schedule_end(ctx)
586
+ events.extend(manager.take_events())
587
+ time.sleep(0.01)
588
+
589
+ fs_events = [e for e in events if e.medium == MEDIUM_FS]
590
+ assert len(fs_events) == 1
591
+ assert set(fs_events[0].keys) == set(keys)
592
+ assert not fs_events[0].removed
593
+ finally:
594
+ tier.shutdown()
tests/v1/kv_offload/tiering/test_obj_tier.py
+107
-1
@@ -179,8 +179,19 @@ class MockNixlAgent:
179
# ---------------------------------------------------------------------------
180
181
182
+def _make_events_spec(enable_kv_cache_events: bool) -> SimpleNamespace:
183
+ """Offloading spec stub with an explicit global KV events flag."""
184
+ return SimpleNamespace(
185
+ vllm_config=_make_vllm_config(),
186
+ kv_cache_config=SimpleNamespace(kv_cache_groups=[]),
187
+ kv_events_config=SimpleNamespace(enable_kv_cache_events=enable_kv_cache_events),
188
+ )
189
+
190
+
191
def _make_tier(
192
num_blocks: int = 4,
193
+ offloading_spec: SimpleNamespace = _OFFLOADING_SPEC,
194
+ **tier_kwargs,
195
) -> tuple[ObjectStoreSecondaryTierManager, MockNixlAgent]:
196
"""Create a tier backed by a fresh MockNixlAgent."""
197
mock_agent = MockNixlAgent()
@@ -194,11 +205,12 @@ def _make_tier(
205
),
206
):
207
tier = ObjectStoreSecondaryTierManager(
197
- offloading_spec=_OFFLOADING_SPEC,
208
+ offloading_spec=offloading_spec,
209
primary_kv_view=view,
210
tier_type="obj",
211
store_config=_STORE_CONFIG,
212
prefix=_RUN_PREFIX,
213
+ **tier_kwargs,
214
)
215
return tier, mock_agent
216
@@ -421,6 +433,100 @@ class TestMockObjTierShutdown:
433
tier.shutdown() # must not raise
434
435
436
+class TestObjTierKVEvents:
437
+ def setup_method(self):
438
+ self.tier, self.agent = _make_tier(
439
+ offloading_spec=_make_events_spec(enable_kv_cache_events=True),
440
+ enable_kv_events=True,
441
+ )
442
+
443
+ def test_successful_store_emits_stored_event(self):
444
+ """A completed store transfer emits one stored event with the job's keys."""
445
+ keys = [key(1), key(2)]
446
+ self.tier.submit_store(make_job(1, keys, [0, 1]))
447
+ assert all(r.success for r in drain(self.tier))
448
+
449
+ events = list(self.tier.take_events())
450
+ assert len(events) == 1
451
+ assert events[0].keys == keys
452
+ # Literal medium pins the wire contract, not just the constant choice.
453
+ assert events[0].medium == "OBJ"
454
+ assert not events[0].removed
455
+ # take_events drains the buffer.
456
+ assert list(self.tier.take_events()) == []
457
+
458
+ def test_mixed_job_results_emit_event_only_for_successful_job(self):
459
+ """With a failed and a successful store job resolving in the same
460
+ poll, exactly one event is emitted and its keys belong to the
461
+ successful job."""
462
+ original = self.agent.check_xfer_state
463
+ self.agent.check_xfer_state = lambda h: "ERR" if h._id == 0 else original(h)
464
+ self.tier.submit_store(make_job(1, [key(1)], [0])) # handle 0: fails
465
+ self.tier.submit_store(make_job(2, [key(2)], [1])) # handle 1: succeeds
466
+ results = drain(self.tier)
467
+ by_id = {r.job_id: r for r in results}
468
+ assert not by_id[1].success
469
+ assert by_id[2].success
470
+
471
+ events = list(self.tier.take_events())
472
+ assert len(events) == 1
473
+ assert events[0].keys == [key(2)]
474
+ assert self.tier._store_job_keys == {}
475
+
476
+ def test_load_job_emits_no_event(self):
477
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
478
+ results = drain(self.tier)
479
+ assert len(results) == 1
480
+ assert results[0].success
481
+ list(self.tier.take_events())
482
+
483
+ self.tier.submit_load(make_job(2, [key(1)], [0]))
484
+ results = drain(self.tier)
485
+ assert len(results) == 1
486
+ assert results[0].success
487
+ assert list(self.tier.take_events()) == []
488
+
489
+ def test_failed_transfer_emits_no_event(self):
490
+ self.agent.check_xfer_state = lambda h: "ERR"
491
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
492
+ results = drain(self.tier)
493
+ assert not results[0].success
494
+ assert list(self.tier.take_events()) == []
495
+ assert self.tier._store_job_keys == {}
496
+
497
+ def test_submission_failure_emits_no_event(self):
498
+ self.agent.make_prepped_xfer = lambda *a, **k: None
499
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
500
+ results = list(self.tier.get_finished_jobs())
501
+ assert not results[0].success
502
+ assert list(self.tier.take_events()) == []
503
+ assert self.tier._store_job_keys == {}
504
+
505
+ def test_events_disabled_by_default(self):
506
+ tier, _ = _make_tier()
507
+ tier.submit_store(make_job(1, [key(1)], [0]))
508
+ results = drain(tier)
509
+ assert len(results) == 1
510
+ assert results[0].success
511
+ assert tier.events is None
512
+ assert tier._store_job_keys == {}
513
+ assert list(tier.take_events()) == []
514
+
515
+ def test_events_require_global_kv_events_flag(self):
516
+ """Tier-level opt-in alone is not enough; the global flag gates events."""
517
+ tier, _ = _make_tier(
518
+ offloading_spec=_make_events_spec(enable_kv_cache_events=False),
519
+ enable_kv_events=True,
520
+ )
521
+ tier.submit_store(make_job(1, [key(1)], [0]))
522
+ results = drain(tier)
523
+ assert len(results) == 1
524
+ assert results[0].success
525
+ assert tier.events is None
526
+ assert tier._store_job_keys == {}
527
+ assert list(tier.take_events()) == []
528
+
529
+
530
class TestObjStoreConfig:
531
def test_explicit_credentials_included(self):
532
cfg = ObjStoreConfig(
vllm/distributed/kv_events.py
+2
@@ -44,6 +44,8 @@ class KVCacheEvent(
44
45
MEDIUM_GPU = "GPU"
46
MEDIUM_CPU = "CPU"
47
+MEDIUM_FS = "FS"
48
+MEDIUM_OBJ = "OBJ"
49
50
51
class BlockStored(KVCacheEvent):
vllm/v1/kv_offload/tiering/fs/manager.py
+47
-6
@@ -19,7 +19,7 @@ import functools
19
import json
20
import os
21
from collections.abc import Iterable
22
-from typing import TYPE_CHECKING
22
+from typing import TYPE_CHECKING, ClassVar
23
24
try:
25
from vllm.fs_io_C import batch_lookup as batch_lookup_C
@@ -30,11 +30,18 @@ except ImportError:
30
31
from typing_extensions import override
32
33
+from vllm.distributed.kv_events import MEDIUM_FS
34
from vllm.logger import init_logger
34
-from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
35
+from vllm.v1.kv_offload.base import (
36
+ LookupResult,
37
+ OffloadingEvent,
38
+ OffloadKey,
39
+ ReqContext,
40
+)
41
from vllm.v1.kv_offload.file_mapper import FileMapper
42
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
43
from vllm.v1.kv_offload.tiering.base import (
44
+ JobId,
45
JobMetadata,
46
JobResult,
47
RequestOffloadingContext,
@@ -92,6 +99,8 @@ class FileSystemTierManager(SecondaryTierManager):
99
content.
100
"""
101
102
+ medium: ClassVar[str] = MEDIUM_FS
103
+
104
def __init__(
105
self,
106
offloading_spec: "OffloadingSpec",
@@ -100,6 +109,7 @@ class FileSystemTierManager(SecondaryTierManager):
109
root_dir: str,
110
n_read_threads: int = 16,
111
n_write_threads: int = 16,
112
+ enable_kv_events: bool = False,
113
):
114
"""
115
Args:
@@ -110,9 +120,26 @@ class FileSystemTierManager(SecondaryTierManager):
120
root_dir: Root directory for block files.
121
n_read_threads: Number of read-priority I/O threads.
122
n_write_threads: Number of write-priority I/O threads.
123
+ enable_kv_events: Emit BlockStored KV events for blocks
124
+ successfully stored to this tier. Effective only when KV
125
+ cache events are enabled globally (kv_events_config).
126
"""
127
super().__init__(offloading_spec, primary_kv_view, tier_type)
128
129
+ self.events: list[OffloadingEvent] | None = None
130
+ if enable_kv_events:
131
+ if offloading_spec.kv_events_config.enable_kv_cache_events:
132
+ self.events = []
133
+ else:
134
+ logger.warning(
135
+ "enable_kv_events is set on secondary tier '%s' but KV "
136
+ "cache events are disabled globally; the tier will not "
137
+ "emit events.",
138
+ tier_type,
139
+ )
140
+ # Keys of in-flight store jobs, tracked only when events are enabled.
141
+ self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
142
+
143
# Extract block size from primary view
144
assert primary_kv_view.strides is not None, (
145
"primary_kv_view.strides cannot be None"
@@ -157,6 +184,8 @@ class FileSystemTierManager(SecondaryTierManager):
184
185
@override
186
def submit_store(self, job_metadata: JobMetadata) -> None:
187
+ if self.events is not None:
188
+ self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
189
tasks = (
190
functools.partial(
191
store_block,
@@ -188,10 +217,22 @@ class FileSystemTierManager(SecondaryTierManager):
217
"""
218
Collect completed jobs from the finished-jobs queue.
219
"""
191
- return (
192
- JobResult(job_id=job_id, success=success)
193
- for job_id, success in self._pool.get_finished()
194
- )
220
+ results = []
221
+ for job_id, success in self._pool.get_finished():
222
+ if self.events is not None:
223
+ keys = self._store_job_keys.pop(job_id, None)
224
+ if success and keys:
225
+ self.events.append(
226
+ OffloadingEvent(keys=keys, medium=self.medium, removed=False)
227
+ )
228
+ results.append(JobResult(job_id=job_id, success=success))
229
+ return results
230
+
231
+ @override
232
+ def take_events(self) -> Iterable[OffloadingEvent]:
233
+ if self.events is not None:
234
+ yield from self.events
235
+ self.events.clear()
236
237
@override
238
def drain_jobs(self) -> None:
vllm/v1/kv_offload/tiering/obj/manager.py
+53
-2
@@ -5,15 +5,22 @@
5
import ctypes
6
import time
7
from collections.abc import Iterable
8
-from typing import TYPE_CHECKING, NamedTuple
8
+from typing import TYPE_CHECKING, ClassVar, NamedTuple
9
10
+from vllm.distributed.kv_events import MEDIUM_OBJ
11
from vllm.distributed.nixl_utils import NixlWrapper as nixl_agent
12
from vllm.distributed.nixl_utils import nixl_agent_config
13
from vllm.logger import init_logger
13
-from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
14
+from vllm.v1.kv_offload.base import (
15
+ LookupResult,
16
+ OffloadingEvent,
17
+ OffloadKey,
18
+ ReqContext,
19
+)
20
from vllm.v1.kv_offload.file_mapper import FileMapper
21
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
22
from vllm.v1.kv_offload.tiering.base import (
23
+ JobId,
24
JobMetadata,
25
JobResult,
26
RequestOffloadingContext,
@@ -90,6 +97,8 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
97
primary tier. Object keys are formed as ``{prefix}/{hash_shard}/{hash}.bin``.
98
"""
99
100
+ medium: ClassVar[str] = MEDIUM_OBJ
101
+
102
def __init__(
103
self,
104
offloading_spec: "OffloadingSpec",
@@ -98,8 +107,36 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
107
store_config: dict,
108
prefix: str = "",
109
io_threads: int = 4,
110
+ enable_kv_events: bool = False,
111
):
112
+ """
113
+ Args:
114
+ offloading_spec: Offloading configuration.
115
+ primary_kv_view: Memoryview of the primary tier's CPU KV cache.
116
+ tier_type: Tier type identifier, set by SecondaryTierFactory.
117
+ store_config: Object store connection parameters (see ObjStoreConfig).
118
+ prefix: Key prefix prepended to all object keys.
119
+ io_threads: Number of NIXL I/O threads.
120
+ enable_kv_events: Emit BlockStored KV events for blocks
121
+ successfully stored to this tier. Effective only when KV
122
+ cache events are enabled globally (kv_events_config).
123
+ """
124
super().__init__(offloading_spec, primary_kv_view, tier_type)
125
+
126
+ self.events: list[OffloadingEvent] | None = None
127
+ if enable_kv_events:
128
+ if offloading_spec.kv_events_config.enable_kv_cache_events:
129
+ self.events = []
130
+ else:
131
+ logger.warning(
132
+ "enable_kv_events is set on secondary tier '%s' but KV "
133
+ "cache events are disabled globally; the tier will not "
134
+ "emit events.",
135
+ tier_type,
136
+ )
137
+ # Keys of in-flight store jobs, tracked only when events are enabled.
138
+ self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
139
+
140
agent_config = nixl_agent_config(backends=[])
141
self._agent = nixl_agent("ObjAgent", agent_config)
142
obj_config = ObjStoreConfig(**store_config)
@@ -231,6 +268,8 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
268
return LookupResult.HIT if result else LookupResult.MISS
269
270
def submit_store(self, job_metadata: JobMetadata) -> None:
271
+ if self.events is not None:
272
+ self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
273
obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys)
274
self._submit_transfer(
275
job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_WRITE
@@ -279,8 +318,20 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
318
self._poll_active_transfers()
319
results = self._pending_results
320
self._pending_results = []
321
+ if self.events is not None:
322
+ for result in results:
323
+ keys = self._store_job_keys.pop(result.job_id, None)
324
+ if result.success and keys:
325
+ self.events.append(
326
+ OffloadingEvent(keys=keys, medium=self.medium, removed=False)
327
+ )
328
return results
329
330
+ def take_events(self) -> Iterable[OffloadingEvent]:
331
+ if self.events is not None:
332
+ yield from self.events
333
+ self.events.clear()
334
+
335
def drain_jobs(self) -> None:
336
"""Block until every submitted transfer has completed or failed.
337