[5/N] Share HiSparse host cache across TP ranks (reopens #52760) (#56629)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Signed-off-by: Chao Lei <leichao139636@163.com> Signed-off-by: Lucas Wilkinson <lwilkinson@neuralmagic.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Chao Lei <leichao139636@163.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
Lucas Wilkinson committed
Sep 12, 2026 at 19:48 UTC
e19a3e172ecc1d1ece8ecce9dfd91bf811a814a0
14 files changed
+878
-85
docs/design/hisparse.md
+10
@@ -60,6 +60,16 @@ sees only device pools.
60
The source group has `block_pool_id=None`; device-pool consumers must narrow it
61
before indexing, so host ownership cannot masquerade as a numeric GPU pool.
62
63
+For single-node MP tensor parallelism, every TP worker maps the same pinned host
64
+pool and uses the same block and layer offsets. MLA source KV is replicated
65
+across TP ranks, so this stores one physical copy instead of one copy per rank.
66
+TP rank 0 writes the shared host pool; peers wait on its IPC events before
67
+reading it. Other executor and parallel layouts retain private per-rank pools.
68
+
69
+The shared layout backs the per-replica logical capacity with one physical pool;
70
+the private layout allocates one physical pool per rank. Physical pool size
71
+includes block-stride alignment.
72
+
73
## Code boundary
74
75
```text
tests/v1/core/test_kv_cache_utils.py
+24
-2
@@ -3,6 +3,7 @@
3
import copy
4
import hashlib
5
import importlib
6
+import mmap
7
import subprocess
8
import sys
9
from collections.abc import Callable
@@ -15,6 +16,7 @@ import pytest
16
import torch
17
18
import vllm.v1.core.kv_cache_utils as kv_cache_utils
19
+import vllm.v1.hisparse.runtime as hisparse_runtime_module
20
from vllm.config import (
21
CacheConfig,
22
KVTransferConfig,
@@ -90,7 +92,13 @@ pytestmark = pytest.mark.cpu_test
92
93
94
@pytest.mark.parametrize("gpu_block_size", [32, 64])
93
-def test_hisparse_hma_uses_resolved_gpu_block_size(monkeypatch, gpu_block_size):
95
+@pytest.mark.parametrize("shared_host_pool", [False, True])
96
+def test_hisparse_hma_uses_resolved_gpu_block_size(
97
+ monkeypatch, gpu_block_size, shared_host_pool
98
+):
99
+ monkeypatch.setattr(
100
+ hisparse_runtime_module.current_platform, "is_cuda_alike", lambda: True
101
+ )
102
specs = {
103
"model.layers.0.self_attn": MLAAttentionSpec(
104
block_size=gpu_block_size,
@@ -116,7 +124,15 @@ def test_hisparse_hma_uses_resolved_gpu_block_size(monkeypatch, gpu_block_size):
124
hf_config=SimpleNamespace(index_topk=128),
125
max_model_len=gpu_block_size,
126
),
119
- parallel_config=SimpleNamespace(decode_context_parallel_size=1),
127
+ parallel_config=SimpleNamespace(
128
+ tensor_parallel_size=2 if shared_host_pool else 1,
129
+ pipeline_parallel_size=1,
130
+ prefill_context_parallel_size=1,
131
+ decode_context_parallel_size=1,
132
+ world_size=2 if shared_host_pool else 1,
133
+ distributed_executor_backend="mp",
134
+ nnodes_within_dp=1,
135
+ ),
136
cache_config=SimpleNamespace(
137
num_gpu_blocks_override=7,
138
prefix_cache_retention_interval=None,
@@ -137,6 +153,12 @@ def test_hisparse_hma_uses_resolved_gpu_block_size(monkeypatch, gpu_block_size):
153
assert cache_config.hisparse_host_num_blocks > 7
154
155
host_group, indexer_group, *auxiliary_groups = cache_config.kv_cache_groups
156
+ assert cache_config.hisparse_shared_host_pool is shared_host_pool
157
+ host_page = host_group.kv_cache_spec.page_size_bytes
158
+ alignment = mmap.PAGESIZE if shared_host_pool else 1
159
+ expected_host_stride = (host_page + alignment - 1) // alignment * alignment
160
+ assert cache_config.hisparse_host_block_stride == expected_host_stride
161
+ assert cache_config.hisparse_host_num_blocks == 2**30 // expected_host_stride
162
assert host_group.host_resident
163
assert not any(group.host_resident for group in [indexer_group, *auxiliary_groups])
164
host_layers = set(host_group.layer_names)
tests/v1/kv_connector/unit/offloading_connector/test_config.py
+36
@@ -367,6 +367,42 @@ def test_hisparse_offloads_only_indexer_group():
367
assert [group.group_idx for group in scheduler_config.kv_group_configs] == [1]
368
369
370
+def test_hisparse_partial_group_size_survives_scheduler_flattening():
371
+ """Worker and scheduler representations must produce identical mmap rows."""
372
+ layer_specs = {name: _full_attention_spec() for name in ("indexer.0", "indexer.1")}
373
+ wrapped = UniformTypeKVCacheSpecs.from_specs(layer_specs)
374
+ assert wrapped is not None
375
+ source = KVCacheGroupSpec(
376
+ ["source"],
377
+ _full_attention_spec(),
378
+ host_resident=True,
379
+ role=KVCacheGroupRole.HISPARSE_SOURCE,
380
+ )
381
+ worker_indexer = KVCacheGroupSpec(
382
+ list(layer_specs), wrapped, role=KVCacheGroupRole.HISPARSE_INDEXER
383
+ )
384
+ scheduler_indexer = KVCacheGroupSpec(
385
+ list(layer_specs),
386
+ next(iter(layer_specs.values())),
387
+ role=KVCacheGroupRole.HISPARSE_INDEXER,
388
+ )
389
+
390
+ def make_config(indexer: KVCacheGroupSpec) -> KVCacheConfig:
391
+ return KVCacheConfig(
392
+ num_blocks=4,
393
+ kv_cache_tensors=[],
394
+ kv_cache_groups=[source, indexer],
395
+ hisparse_host_num_blocks=4,
396
+ )
397
+
398
+ config = _make_vllm_config()
399
+ worker = build_offloading_config(config, make_config(worker_indexer))
400
+ scheduler = build_offloading_config(config, make_config(scheduler_indexer))
401
+
402
+ assert worker.worker_kv_bytes_per_block == scheduler.worker_kv_bytes_per_block
403
+ assert worker.worker_kv_bytes_per_block == wrapped.page_size_bytes
404
+
405
+
406
def test_zero_blocks_skips_tensor_layout_validation():
407
kv_cache_config = _make_sizing_kv_cache_config(packed=False)
408
kv_cache_config.num_blocks = 0
tests/v1/kv_offload/cpu/test_shared_offload_region.py
+113
-17
@@ -10,11 +10,12 @@ import os
10
import threading
11
import time
12
import uuid
13
-from unittest.mock import MagicMock
13
+from unittest.mock import MagicMock, call
14
15
import pytest
16
17
from vllm.utils.system_utils import get_mp_context
18
+from vllm.v1.kv_offload.cpu import shared_offload_region as region_module
19
from vllm.v1.kv_offload.cpu.shared_offload_region import (
20
SharedOffloadRegion,
21
_wait_for_file_size,
@@ -192,28 +193,43 @@ def _mp_barrier_construct_and_hold(
193
barrier,
194
fill_value: int,
195
done_queue,
196
+ replicated: bool = False,
197
) -> None:
198
"""Construct with a real cross-process barrier, write, then hold the
199
mapping (no cleanup) until the parent SIGKILLs this process."""
200
try:
201
+ populate_calls = 0
202
+ get_populate_write_fn = region_module._get_populate_write_fn
203
+
204
+ def track_population(mmap_obj):
205
+ nonlocal populate_calls
206
+ populate_calls += 1
207
+ return get_populate_write_fn(mmap_obj)
208
+
209
+ region_module._get_populate_write_fn = track_population
210
region = SharedOffloadRegion(
211
engine_id=engine_id,
212
num_chunks=2,
202
- rank=rank,
203
- kv_bytes_per_chunk=num_workers * PAGE_SIZE,
213
+ rank=0 if replicated else rank,
214
+ kv_bytes_per_chunk=PAGE_SIZE if replicated else num_workers * PAGE_SIZE,
215
cpu_page_size=PAGE_SIZE,
216
barrier=lambda: barrier.wait(30),
217
+ populate_only_on_creator=replicated,
218
)
219
# The constructor's barrier precedes the creator's unlink.
220
barrier.wait(30)
221
t = region.create_next_worker_view(PAGE_SIZE)
210
- t[:, :] = fill_value
222
+ if not replicated or rank == 0:
223
+ t[:, :] = fill_value
224
+ barrier.wait(30)
225
done_queue.put(
226
{
227
"rank": rank,
228
"error": None,
229
"inode": os.fstat(region.fd).st_ino,
230
"path_exists": os.path.exists(region.mmap_path),
231
+ "populate_calls": populate_calls,
232
+ "contents_match": bool((t == (1 if replicated else fill_value)).all()),
233
}
234
)
235
time.sleep(60) # hold the mapping until killed
@@ -752,6 +768,26 @@ def test_cleanup_idempotent(iid):
768
r.cleanup() # must be a no-op
769
770
771
+def test_cleanup_unregisters_every_pinned_chunk(iid, monkeypatch):
772
+ """cleanup() must release every chunk from a split host registration."""
773
+ r = _make_region(iid)
774
+ cudart = MagicMock()
775
+ cudart.cudaHostUnregister.return_value = MagicMock(value=0)
776
+ monkeypatch.setattr(region_module, "current_platform", MagicMock())
777
+ region_module.current_platform.is_cuda_alike.return_value = True
778
+ monkeypatch.setattr(region_module.torch.cuda, "cudart", lambda: cudart)
779
+ r.pinned_addresses = [0x100000, 0x200000, 0x300000]
780
+ r.is_pinned = True
781
+
782
+ r.cleanup()
783
+
784
+ assert cudart.cudaHostUnregister.call_args_list == [
785
+ call(0x300000),
786
+ call(0x200000),
787
+ call(0x100000),
788
+ ]
789
+
790
+
791
def test_cleanup_after_create_next_worker_view_releases_mmap(iid):
792
"""cleanup() must close the mmap even after create_next_worker_view was called.
793
create_next_worker_view returns a view that shares storage with _base; both must be
@@ -811,11 +847,53 @@ def test_wait_for_file_size_timeout(tmp_path):
847
os.close(fd)
848
849
850
+@pytest.mark.skip_global_cleanup
851
+def test_wait_for_file_size_rejects_unlinked_file(tmp_path):
852
+ path = tmp_path / "unlinked.mmap"
853
+ fd = os.open(str(path), os.O_CREAT | os.O_RDWR, 0o600)
854
+ try:
855
+ os.unlink(path)
856
+ with pytest.raises(RuntimeError, match="creator failed"):
857
+ _wait_for_file_size(fd, PAGE_SIZE, timeout=1.0)
858
+ finally:
859
+ os.close(fd)
860
+
861
+
862
# ---------------------------------------------------------------------------
863
# Constructor — capacity validation
864
# ---------------------------------------------------------------------------
865
866
867
+@pytest.mark.skip_global_cleanup
868
+@pytest.mark.skipif(not os.path.isdir("/dev/shm"), reason="requires /dev/shm")
869
+def test_creator_memory_check_runs_only_for_creator(iid):
870
+ checked_sizes: list[int] = []
871
+ creator = SharedOffloadRegion(
872
+ engine_id=iid,
873
+ num_chunks=4,
874
+ rank=0,
875
+ kv_bytes_per_chunk=PAGE_SIZE,
876
+ cpu_page_size=PAGE_SIZE,
877
+ creator_memory_check=checked_sizes.append,
878
+ )
879
+ joiner: SharedOffloadRegion | None = None
880
+ try:
881
+ joiner = SharedOffloadRegion(
882
+ engine_id=iid,
883
+ num_chunks=4,
884
+ rank=0,
885
+ kv_bytes_per_chunk=PAGE_SIZE,
886
+ cpu_page_size=PAGE_SIZE,
887
+ creator_memory_check=checked_sizes.append,
888
+ )
889
+ assert checked_sizes == [4 * PAGE_SIZE]
890
+ finally:
891
+ if joiner is not None:
892
+ joiner.cleanup()
893
+ creator.cleanup()
894
+ _cleanup_file(creator.mmap_path)
895
+
896
+
897
def test_insufficient_space_raises_clear_error(monkeypatch):
898
"""A failed creator capacity check must clean up and give a clear error."""
899
import vllm.v1.kv_offload.cpu.shared_offload_region as region
@@ -916,7 +994,8 @@ def test_barrier_failure_unlinks_creator_and_raises(iid):
994
assert not os.path.exists(path)
995
996
919
-def test_mp_barrier_unlinks_file_and_survives_sigkill(iid):
997
+@pytest.mark.parametrize("replicated", [False, True])
998
+def test_mp_barrier_unlinks_file_and_survives_sigkill(iid, replicated):
999
"""With a real cross-process barrier every worker maps one shared inode,
1000
the name is gone while they run, and SIGKILL leaks nothing."""
1001
num_workers = 2
@@ -927,7 +1006,7 @@ def test_mp_barrier_unlinks_file_and_survives_sigkill(iid):
1006
procs = [
1007
ctx.Process(
1008
target=_mp_barrier_construct_and_hold,
930
- args=(iid, rank, num_workers, barrier, rank + 1, done_queue),
1009
+ args=(iid, rank, num_workers, barrier, rank + 1, done_queue, replicated),
1010
)
1011
for rank in range(num_workers)
1012
]
@@ -939,6 +1018,10 @@ def test_mp_barrier_unlinks_file_and_survives_sigkill(iid):
1018
assert r["error"] is None, f"rank {r['rank']}: {r['error']}"
1019
assert len({r["inode"] for r in results}) == 1, "workers split onto two files"
1020
assert not any(r["path_exists"] for r in results)
1021
+ assert all(r["contents_match"] for r in results)
1022
+ assert sum(r["populate_calls"] for r in results) == (
1023
+ 1 if replicated else num_workers
1024
+ )
1025
assert not os.path.exists(path)
1026
finally:
1027
for p in procs:
@@ -950,23 +1033,36 @@ def test_mp_barrier_unlinks_file_and_survives_sigkill(iid):
1033
assert restarted._creator is True, "restart must be able to create anew"
1034
1035
953
-def test_setup_failure_before_barrier_releases_peers(iid, monkeypatch):
1036
+@pytest.mark.parametrize("failure_phase", ["shm", "memory", "population"])
1037
+def test_setup_failure_before_barrier_releases_peers(iid, monkeypatch, failure_phase):
1038
"""A worker that dies before the rendezvous must still arrive at the
1039
barrier, or its peers block in the collective until it times out."""
956
- import vllm.v1.kv_offload.cpu.shared_offload_region as region
957
-
958
- monkeypatch.setattr(
959
- region,
960
- "check_shm_free_space",
961
- MagicMock(side_effect=RuntimeError("Insufficient space")),
1040
+ failure = MagicMock(side_effect=ValueError("Insufficient space"))
1041
+ memory_check = failure if failure_phase == "memory" else None
1042
+ if failure_phase == "shm":
1043
+ monkeypatch.setattr(region_module, "check_shm_free_space", failure)
1044
+ elif failure_phase == "population":
1045
+ monkeypatch.setattr(region_module, "_get_populate_write_fn", failure)
1046
+ path = f"/dev/shm/vllm_offload_{iid}.mmap"
1047
+ seen_at_barrier = []
1048
+ barrier = MagicMock(
1049
+ side_effect=lambda: seen_at_barrier.append(os.path.exists(path))
1050
)
963
- barrier = MagicMock()
1051
965
- with pytest.raises(RuntimeError, match="Insufficient space"):
966
- _make_region(iid, barrier=barrier)
1052
+ with pytest.raises(ValueError, match="Insufficient space"):
1053
+ SharedOffloadRegion(
1054
+ engine_id=iid,
1055
+ num_chunks=1,
1056
+ rank=0,
1057
+ kv_bytes_per_chunk=PAGE_SIZE,
1058
+ cpu_page_size=PAGE_SIZE,
1059
+ barrier=barrier,
1060
+ creator_memory_check=memory_check,
1061
+ populate_only_on_creator=True,
1062
+ )
1063
1064
barrier.assert_called_once_with()
969
- assert not os.path.exists(f"/dev/shm/vllm_offload_{iid}.mmap")
1065
+ assert seen_at_barrier == [False]
1066
1067
1068
def test_mmap_failure_unlinks_creator_before_releasing_peers(iid, monkeypatch):
tests/v1/worker/test_attn_utils.py
+82
@@ -12,6 +12,7 @@ from types import SimpleNamespace
12
import pytest
13
import torch
14
15
+import vllm.v1.hisparse.binding as attn_utils_module
16
from tests.v1.attention.utils import dense_kv_cache_views
17
from vllm.v1.attention.backend import AttentionBackend, AttentionCGSupport, MultipleOf
18
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
@@ -239,6 +240,87 @@ def test_get_kv_sharing_fast_prefill_eligible_layers(monkeypatch: pytest.MonkeyP
240
assert attn_utils.get_kv_sharing_fast_prefill_eligible_layers(vllm_config) == set()
241
242
243
+class _FakeSharedHostRegion:
244
+ def __init__(self) -> None:
245
+ self.cleanup_calls = 0
246
+ self.base_tensor = torch.empty(1, dtype=torch.int8)
247
+
248
+ def cleanup(self) -> None:
249
+ self.cleanup_calls += 1
250
+
251
+
252
+def test_profiling_cleanup_releases_tp_shared_region_once(monkeypatch):
253
+ """TP-shared profiling pools must use region-aware chunk cleanup."""
254
+ region = _FakeSharedHostRegion()
255
+ runtime = SimpleNamespace(
256
+ _host_cache=object(),
257
+ registered_host_pool=region.base_tensor,
258
+ hot_backing=object(),
259
+ shared_host_region=region,
260
+ )
261
+ forward_context = {
262
+ "layer": SimpleNamespace(
263
+ hisparse_cache=SimpleNamespace(runtime=runtime),
264
+ )
265
+ }
266
+ released = []
267
+
268
+ def release_pinned_state(runtimes, pinned_host_pools, shared_host_region):
269
+ released.append((runtimes, pinned_host_pools, shared_host_region))
270
+
271
+ monkeypatch.setattr(
272
+ attn_utils_module,
273
+ "release_pinned_state",
274
+ release_pinned_state,
275
+ )
276
+
277
+ attn_utils_module.release_hisparse_profiling_cache(forward_context)
278
+
279
+ assert released == [([runtime], [], region)]
280
+
281
+
282
+@pytest.mark.parametrize("failure_phase", ["allocation", "binding", "buffers"])
283
+def test_init_hisparse_rolls_back_shared_region(monkeypatch, failure_phase):
284
+ """A failure after mmap allocation must not leak the shared registration."""
285
+ region = _FakeSharedHostRegion()
286
+ vllm_config = SimpleNamespace(
287
+ cache_config=SimpleNamespace(
288
+ get_resolved_kv_cache_layout=lambda: KVCacheLayout.BLHNC
289
+ ),
290
+ scheduler_config=SimpleNamespace(max_num_seqs=1, max_num_batched_tokens=1),
291
+ )
292
+
293
+ def allocate(*args):
294
+ args[-1].shared_region = region
295
+ if failure_phase == "allocation":
296
+ raise RuntimeError("initialization failed")
297
+ return {}
298
+
299
+ def bind(**kwargs):
300
+ if failure_phase == "binding":
301
+ raise RuntimeError("initialization failed")
302
+ return []
303
+
304
+ def buffers(*args, **kwargs):
305
+ raise RuntimeError("initialization failed")
306
+
307
+ monkeypatch.setattr(attn_utils_module, "allocate_hisparse_kv_caches", allocate)
308
+ monkeypatch.setattr(attn_utils_module, "bind_hisparse_kv_caches", bind)
309
+ monkeypatch.setattr(
310
+ attn_utils_module, "initialize_hisparse_runtime_buffers", buffers
311
+ )
312
+ with pytest.raises(RuntimeError, match="initialization failed"):
313
+ attn_utils_module.init_hisparse_kv_cache(
314
+ SimpleNamespace(),
315
+ torch.device("cpu"),
316
+ [],
317
+ vllm_config,
318
+ {},
319
+ SimpleNamespace(),
320
+ )
321
+ assert region.cleanup_calls == 1
322
+
323
+
324
def test_reshape_padded_kv_cache_strides_by_padded_page():
325
num_blocks = 3
326
spec = FullAttentionSpec(
tests/v1/worker/test_kv_cache_allocation_scope.py
+5
-1
@@ -60,7 +60,11 @@ def test_mrv2_kv_pool_only_wraps_backing_allocation(monkeypatch, hisparse) -> No
60
)
61
]
62
63
- monkeypatch.setattr(hisparse_binding, "HiSparseHostPool", lambda: host_pool)
63
+ monkeypatch.setattr(
64
+ hisparse_binding,
65
+ "HiSparseHostPool",
66
+ lambda vllm_config, kv_cache_config: host_pool,
67
+ )
68
monkeypatch.setattr(hisparse_binding, "allocate_hisparse_kv_caches", allocate)
69
monkeypatch.setattr(hisparse_binding, "bind_hisparse_kv_caches", bind_hisparse)
70
tests/v1/worker/test_utils.py
+253
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import mmap
5
from collections import deque
6
from contextlib import nullcontext
7
from types import SimpleNamespace
@@ -188,6 +189,232 @@ def test_hisparse_submits_layer_mirror_at_replayed_attention_boundary(
189
assert handle.submit_layer_mirror.call_count == expected_calls
190
191
192
+def _hisparse_parallel_config(**overrides):
193
+ values = {
194
+ "tensor_parallel_size": 2,
195
+ "pipeline_parallel_size": 1,
196
+ "prefill_context_parallel_size": 1,
197
+ "decode_context_parallel_size": 1,
198
+ "world_size": 2,
199
+ "distributed_executor_backend": "mp",
200
+ "nnodes_within_dp": 1,
201
+ "data_parallel_index": 3,
202
+ }
203
+ values.update(overrides)
204
+ return SimpleNamespace(**values)
205
+
206
+
207
+def test_hisparse_shares_host_pool_only_for_local_tp(monkeypatch):
208
+ monkeypatch.setattr(
209
+ hisparse_runtime_module.current_platform, "is_cuda_alike", lambda: True
210
+ )
211
+ config = SimpleNamespace(parallel_config=_hisparse_parallel_config())
212
+
213
+ assert hisparse_runtime_module.use_shared_hisparse_host_pool(config)
214
+
215
+ unsupported = (
216
+ {"tensor_parallel_size": 1, "world_size": 1},
217
+ {"pipeline_parallel_size": 2, "world_size": 4},
218
+ {"prefill_context_parallel_size": 2, "world_size": 4},
219
+ {"decode_context_parallel_size": 2},
220
+ {"world_size": 4},
221
+ {"distributed_executor_backend": "ray"},
222
+ {"nnodes_within_dp": 2},
223
+ )
224
+ for overrides in unsupported:
225
+ config.parallel_config = _hisparse_parallel_config(**overrides)
226
+ assert not hisparse_runtime_module.use_shared_hisparse_host_pool(config)
227
+
228
+
229
+@pytest.mark.skip_global_cleanup
230
+def test_hisparse_shared_host_pool_uses_one_replicated_mmap(monkeypatch):
231
+ page = mmap.PAGESIZE
232
+ tp_group = MagicMock()
233
+ monkeypatch.setattr(hisparse_runtime_module, "get_tp_group", lambda: tp_group)
234
+
235
+ class FakeSharedOffloadRegion:
236
+ BLOCK_SIZE_ALIGNMENT = page
237
+
238
+ def __init__(self, **kwargs):
239
+ self.kwargs = kwargs
240
+ self.total_size_bytes = kwargs["num_chunks"] * kwargs["kv_bytes_per_chunk"]
241
+ self.base_tensor = torch.empty(self.total_size_bytes, dtype=torch.int8)
242
+ self.is_pinned = False
243
+ self.pinned_addresses = []
244
+ self.view_sizes = []
245
+ self.offset = 0
246
+
247
+ def create_next_canonical_view(self, size):
248
+ self.view_sizes.append(size)
249
+ view = torch.as_strided(
250
+ self.base_tensor,
251
+ size=(self.kwargs["num_chunks"], size),
252
+ stride=(self.kwargs["kv_bytes_per_chunk"], 1),
253
+ storage_offset=self.offset,
254
+ )
255
+ self.offset += size
256
+ return view
257
+
258
+ def cleanup(self):
259
+ pass
260
+
261
+ monkeypatch.setattr(
262
+ hisparse_runtime_module, "SharedOffloadRegion", FakeSharedOffloadRegion
263
+ )
264
+ registration_ranges = MagicMock(return_value=((0, page), (page, 4 * page)))
265
+ monkeypatch.setattr(
266
+ hisparse_runtime_module,
267
+ "_hisparse_registration_ranges",
268
+ registration_ranges,
269
+ )
270
+ pinned: list[torch.Tensor] = []
271
+
272
+ def pin_tensor(tensor):
273
+ pinned.append(tensor)
274
+
275
+ monkeypatch.setattr(hisparse_runtime_module, "pin_tensor", pin_tensor)
276
+ config = SimpleNamespace(
277
+ instance_id="instance",
278
+ parallel_config=_hisparse_parallel_config(
279
+ tensor_parallel_size=1,
280
+ world_size=1,
281
+ distributed_executor_backend="uni",
282
+ ),
283
+ )
284
+
285
+ pools, private_pools, region = hisparse_runtime_module.allocate_hisparse_host_pools(
286
+ config,
287
+ [24, 40],
288
+ num_blocks=4,
289
+ host_block_stride=page,
290
+ use_shared_host_pool=True,
291
+ )
292
+
293
+ assert region is not None
294
+ assert private_pools == []
295
+ assert region.kwargs == {
296
+ "engine_id": "hisparse_instance_dp3",
297
+ "num_chunks": 1,
298
+ "rank": 0,
299
+ "kv_bytes_per_chunk": 4 * page,
300
+ "cpu_page_size": 64,
301
+ "barrier": tp_group.barrier,
302
+ "creator_memory_check": hisparse_runtime_module.check_hisparse_host_memory,
303
+ "populate_only_on_creator": True,
304
+ }
305
+ assert region.view_sizes == [24, 40]
306
+ assert [pool.shape for pool in pools] == [(24,), (40,)]
307
+ registration_ranges.assert_called_once_with([24, 40], 4, page)
308
+ assert [
309
+ (tensor.data_ptr() - region.base_tensor.data_ptr(), tensor.nbytes)
310
+ for tensor in pinned
311
+ ] == [(0, page), (page, 3 * page)]
312
+ assert region.is_pinned
313
+
314
+
315
+@pytest.mark.parametrize("fail_registration", [None, 1, 2])
316
+def test_shared_host_pool_tracks_successful_registrations(
317
+ monkeypatch, fail_registration
318
+):
319
+ """Only successful registrations may be unregistered on allocation failure."""
320
+ page = mmap.PAGESIZE
321
+ monkeypatch.setattr(hisparse_runtime_module, "get_tp_group", MagicMock())
322
+ backing = torch.empty(3 * page, dtype=torch.uint8)
323
+ region = MagicMock(base_tensor=backing, pinned_addresses=[], is_pinned=False)
324
+ monkeypatch.setattr(
325
+ hisparse_runtime_module, "SharedOffloadRegion", lambda **kw: region
326
+ )
327
+ monkeypatch.setattr(
328
+ hisparse_runtime_module,
329
+ "_hisparse_registration_ranges",
330
+ lambda *args: ((0, page), (page, 3 * page)),
331
+ )
332
+ cudart = MagicMock()
333
+ cudart.cudaHostRegister.side_effect = [
334
+ SimpleNamespace(value=int(fail_registration == i)) for i in (1, 2)
335
+ ]
336
+ monkeypatch.setattr(torch.cuda, "cudart", lambda: cudart)
337
+ config = SimpleNamespace(
338
+ instance_id="test", parallel_config=SimpleNamespace(data_parallel_index=0)
339
+ )
340
+ context = (
341
+ pytest.raises(RuntimeError, match="cudaHostRegister failed")
342
+ if fail_registration
343
+ else nullcontext()
344
+ )
345
+ with context:
346
+ hisparse_runtime_module.allocate_hisparse_host_pools(
347
+ config, [3 * page], 3, page, use_shared_host_pool=True
348
+ )
349
+ successful = 2 if fail_registration is None else fail_registration - 1
350
+ assert region.pinned_addresses == [
351
+ backing.data_ptr() + i * page for i in range(successful)
352
+ ]
353
+ assert region.is_pinned == bool(successful)
354
+ assert region.cleanup.call_count == int(fail_registration is not None)
355
+ attempts = 2 if fail_registration is None else fail_registration
356
+ assert (
357
+ cudart.cudaHostRegister.call_args_list
358
+ == [
359
+ call(backing.data_ptr(), page, 0),
360
+ call(backing.data_ptr() + page, 2 * page, 0),
361
+ ][:attempts]
362
+ )
363
+
364
+
365
+def test_shared_host_pool_registers_layer_spans_in_one_backing(monkeypatch):
366
+ """Aliased tensor configs must not count the shared backing once per view."""
367
+ region = SimpleNamespace(base_tensor=torch.empty(64, dtype=torch.int8))
368
+ allocate = MagicMock(return_value=([], [], region))
369
+ monkeypatch.setattr(
370
+ hisparse_runtime_module, "allocate_hisparse_host_pools", allocate
371
+ )
372
+ config = SimpleNamespace(
373
+ hisparse_shared_host_pool=True,
374
+ hisparse_host_num_blocks=4,
375
+ hisparse_host_block_stride=16,
376
+ kv_cache_tensors=[
377
+ SimpleNamespace(
378
+ host_resident=True,
379
+ layers=["a", "b"],
380
+ offset=0,
381
+ layer_stride=12,
382
+ size=44,
383
+ ),
384
+ SimpleNamespace(
385
+ host_resident=True, layers=["c"], offset=24, layer_stride=20, size=44
386
+ ),
387
+ ],
388
+ )
389
+ vllm_config = SimpleNamespace()
390
+ pool = hisparse_runtime_module.HiSparseHostPool(vllm_config, config)
391
+ backing = pool.allocate(44)
392
+ allocate.assert_called_once_with(
393
+ vllm_config, [12, 12, 20], 4, 16, use_shared_host_pool=True
394
+ )
395
+ assert backing.numel() == 44
396
+ assert backing.data_ptr() == region.base_tensor.data_ptr()
397
+ assert pool.registered is region.base_tensor
398
+
399
+
400
+def test_hisparse_registration_chunks_end_between_host_blocks():
401
+ """Registration seams must not bisect a DMA-addressable host block."""
402
+ page = mmap.PAGESIZE
403
+ ranges = hisparse_runtime_module._hisparse_registration_ranges(
404
+ tensor_sizes=[4 * 3 * page, 4 * 5 * page],
405
+ num_blocks=4,
406
+ host_block_stride=8 * page,
407
+ max_chunk_bytes=10 * page,
408
+ )
409
+
410
+ assert ranges == (
411
+ (0, 9 * page),
412
+ (9 * page, 17 * page),
413
+ (17 * page, 27 * page),
414
+ (27 * page, 32 * page),
415
+ )
416
+
417
+
418
def test_copy_cpu_kv_cache_logical_blocks_ignores_storage_padding():
419
backing = torch.full((10, 2, 3), -1, dtype=torch.float32)
420
cache = backing[1:9]
@@ -931,6 +1158,32 @@ def test_hisparse_cache_handles_join_index_groups_during_construction(monkeypatc
1158
assert streams == []
1159
1160
1161
+def test_hisparse_worker_shutdown_releases_pinned_state(monkeypatch):
1162
+ worker = object.__new__(HiSparseConnectorWorker)
1163
+ worker._initialized = True
1164
+ worker._slot_mapping_staging = None
1165
+ worker.dma_stream = None
1166
+ worker.cache_handles = []
1167
+ worker.pinned_host_pools = []
1168
+ worker.shared_host_region = object()
1169
+ released = False
1170
+
1171
+ def release_pinned_state(runtimes, pinned_host_pools, shared_host_region):
1172
+ nonlocal released
1173
+ assert runtimes == []
1174
+ assert pinned_host_pools == []
1175
+ assert shared_host_region is worker.shared_host_region
1176
+ released = True
1177
+
1178
+ monkeypatch.setattr(
1179
+ hisparse_worker_module, "release_pinned_state", release_pinned_state
1180
+ )
1181
+
1182
+ worker.shutdown()
1183
+
1184
+ assert released
1185
+
1186
+
1187
class _TestReplaySSMMixer(MambaMixer2):
1188
def __init__(self):
1189
torch.nn.Module.__init__(self)
vllm/distributed/kv_transfer/kv_connector/v1/hisparse/worker.py
+24
-2
@@ -147,6 +147,12 @@ def _select_written_row_mirrors(
147
)
148
149
150
+def _is_hisparse_host_writer(
151
+ shared_host_region: SharedOffloadRegion | None,
152
+) -> bool:
153
+ return shared_host_region is None or get_tensor_model_parallel_rank() == 0
154
+
155
+
156
def _create_hisparse_host_events(
157
shared_host_region: SharedOffloadRegion | None,
158
is_host_writer: bool,
@@ -200,15 +206,26 @@ class HiSparseConnectorWorker:
206
raise RuntimeError("HiSparse connector found no hot-cache handles.")
207
hot_backings: dict[int, torch.Tensor] = {}
208
registered_host_pools: dict[int, torch.Tensor] = {}
209
+ shared_host_regions: dict[int, SharedOffloadRegion] = {}
210
for cache in cache_handles:
211
hot_backing = cache.runtime.hot_backing
212
hot_backings[hot_backing.untyped_storage().data_ptr()] = hot_backing
213
registered_pool = cache.runtime.registered_host_pool
214
registered_host_pools[registered_pool.data_ptr()] = registered_pool
215
+ if (region := cache.runtime.shared_host_region) is not None:
216
+ shared_host_regions[id(region)] = region
217
if len(hot_backings) != 1:
218
raise RuntimeError("HiSparse hot tensors must share one GPU backing.")
219
hot_backing = next(iter(hot_backings.values()))
211
- pinned_host_pools = list(registered_host_pools.values())
220
+ if len(shared_host_regions) > 1:
221
+ raise RuntimeError("HiSparse caches must share one host region.")
222
+ shared_host_region = next(iter(shared_host_regions.values()), None)
223
+ pinned_host_pools = (
224
+ []
225
+ if shared_host_region is not None
226
+ else list(registered_host_pools.values())
227
+ )
228
+ is_host_writer = _is_hisparse_host_writer(shared_host_region)
229
230
resident = cache_handles[0].view
231
assert resident is not None
@@ -223,11 +240,14 @@ class HiSparseConnectorWorker:
240
host_num_blocks,
241
hot_backing.device,
242
pinned_host_pools,
243
+ shared_host_region=shared_host_region,
244
+ is_host_writer=is_host_writer,
245
)
246
except Exception:
247
release_pinned_state(
248
[cache.runtime for cache in cache_handles],
249
pinned_host_pools,
250
+ shared_host_region,
251
)
252
raise
253
@@ -866,6 +886,8 @@ class HiSparseConnectorWorker:
886
if self.dma_stream is not None:
887
self.dma_stream.synchronize()
888
release_pinned_state(
869
- [cache.runtime for cache in self.cache_handles], self.pinned_host_pools
889
+ [cache.runtime for cache in self.cache_handles],
890
+ self.pinned_host_pools,
891
+ self.shared_host_region,
892
)
893
self._initialized = False
vllm/distributed/kv_transfer/kv_connector/v1/offloading/config.py
+16
-2
@@ -17,6 +17,7 @@ from vllm.v1.kv_cache_interface import (
17
MLAAttentionSpec,
18
SlidingWindowMLASpec,
19
SlidingWindowSpec,
20
+ UniformTypeKVCacheSpecs,
21
iter_layer_specs,
22
)
23
from vllm.v1.kv_offload.config import (
@@ -29,7 +30,7 @@ from vllm.v1.kv_offload.config import (
30
31
if TYPE_CHECKING:
32
from vllm.config import VllmConfig
32
- from vllm.v1.kv_cache_interface import KVCacheConfig
33
+ from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheGroupSpec
34
35
36
def get_offloading_group_ids(kv_cache_config: "KVCacheConfig") -> tuple[int, ...]:
@@ -42,6 +43,19 @@ def get_offloading_group_ids(kv_cache_config: "KVCacheConfig") -> tuple[int, ...
43
)
44
45
46
+def _group_kv_bytes_per_block(group: "KVCacheGroupSpec") -> int:
47
+ """Return the physical bytes occupied by one block of a cache group.
48
+
49
+ Worker configs may retain ``UniformTypeKVCacheSpecs`` while scheduler
50
+ configs flatten that wrapper to one representative per-layer spec. Keep
51
+ the result invariant across those two representations.
52
+ """
53
+ spec = group.kv_cache_spec
54
+ if isinstance(spec, UniformTypeKVCacheSpecs):
55
+ return spec.page_size_bytes
56
+ return spec.page_size_bytes * len(group.layer_names)
57
+
58
+
59
def build_offloading_config(
60
vllm_config: "VllmConfig",
61
kv_cache_config: "KVCacheConfig",
@@ -133,7 +147,7 @@ def build_offloading_config(
147
worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
148
elif kv_cache_config.num_blocks > 0:
149
worker_kv_bytes_per_block = sum(
136
- group.kv_cache_spec.page_size_bytes for _, group in selected_groups
150
+ _group_kv_bytes_per_block(group) for _, group in selected_groups
151
)
152
153
single_group_spec = (
vllm/v1/hisparse/binding.py
+44
-27
@@ -120,27 +120,32 @@ def init_hisparse_kv_cache(
120
block_tables: "BlockTables",
121
) -> dict[str, torch.Tensor]:
122
"""Allocate and bind HiSparse caches within the caller's allocation context."""
123
- host_pool = HiSparseHostPool()
124
- kv_caches = allocate_hisparse_kv_caches(
125
- kv_cache_config,
126
- device,
127
- vllm_config.cache_config.get_resolved_kv_cache_layout(),
128
- kernel_block_sizes,
129
- host_pool,
130
- )
131
- cache_handles = bind_hisparse_kv_caches(
132
- forward_context=forward_context,
133
- kv_cache_config=kv_cache_config,
134
- kv_caches=kv_caches,
135
- block_tables=block_tables,
136
- host_pool=host_pool,
137
- )
138
- initialize_hisparse_runtime_buffers(
139
- cache_handles,
140
- max_num_reqs=vllm_config.scheduler_config.max_num_seqs,
141
- max_num_batched_tokens=vllm_config.scheduler_config.max_num_batched_tokens,
142
- )
143
- return kv_caches
123
+ host_pool = HiSparseHostPool(vllm_config, kv_cache_config)
124
+ try:
125
+ kv_caches = allocate_hisparse_kv_caches(
126
+ kv_cache_config,
127
+ device,
128
+ vllm_config.cache_config.get_resolved_kv_cache_layout(),
129
+ kernel_block_sizes,
130
+ host_pool,
131
+ )
132
+ cache_handles = bind_hisparse_kv_caches(
133
+ forward_context=forward_context,
134
+ kv_cache_config=kv_cache_config,
135
+ kv_caches=kv_caches,
136
+ block_tables=block_tables,
137
+ host_pool=host_pool,
138
+ )
139
+ initialize_hisparse_runtime_buffers(
140
+ cache_handles,
141
+ max_num_reqs=vllm_config.scheduler_config.max_num_seqs,
142
+ max_num_batched_tokens=vllm_config.scheduler_config.max_num_batched_tokens,
143
+ )
144
+ return kv_caches
145
+ except Exception:
146
+ if host_pool.shared_region is not None:
147
+ host_pool.shared_region.cleanup()
148
+ raise
149
150
151
def _get_hisparse_cache(
@@ -166,13 +171,24 @@ def release_hisparse_profiling_cache(forward_context: dict[str, Any]) -> None:
171
if not runtimes:
172
return
173
169
- registered_pools = list(
170
- {
171
- runtime.registered_host_pool.data_ptr(): runtime.registered_host_pool
172
- for runtime in runtimes.values()
173
- }.values()
174
+ shared_regions = {
175
+ id(runtime.shared_host_region): runtime.shared_host_region
176
+ for runtime in runtimes.values()
177
+ if runtime.shared_host_region is not None
178
+ }
179
+ assert len(shared_regions) <= 1
180
+ shared_region = next(iter(shared_regions.values()), None)
181
+ registered_pools = (
182
+ []
183
+ if shared_region is not None
184
+ else list(
185
+ {
186
+ runtime.registered_host_pool.data_ptr(): runtime.registered_host_pool
187
+ for runtime in runtimes.values()
188
+ }.values()
189
+ )
190
)
175
- release_pinned_state(list(runtimes.values()), registered_pools)
191
+ release_pinned_state(list(runtimes.values()), registered_pools, shared_region)
192
for cache in cache_handles:
193
cache.mirror_staging_cache = None
194
cache.mirror_staging_slots = None
@@ -257,6 +273,7 @@ def bind_hisparse_kv_caches(
273
assert source_cache.untyped_storage().data_ptr() == (
274
host_pool.registered.untyped_storage().data_ptr()
275
)
276
+ cache_handle.runtime.shared_host_region = host_pool.shared_region
277
cache_handle.runtime.bind_source_cache(
278
source_cache,
279
registered_host_pool=host_pool.registered,
vllm/v1/hisparse/layout.py
+16
-3
@@ -9,7 +9,11 @@ from vllm.config import VllmConfig
9
from vllm.config.kv_transfer import hisparse_host_pool_gib
10
from vllm.logger import init_logger
11
from vllm.utils.math_utils import cdiv
12
-from vllm.v1.hisparse.runtime import ResolvedHiSparseConfig
12
+from vllm.v1.hisparse.runtime import (
13
+ ResolvedHiSparseConfig,
14
+ get_hisparse_host_block_stride,
15
+ use_shared_hisparse_host_pool,
16
+)
17
from vllm.v1.kv_cache_interface import (
18
HiSparseHotSpec,
19
HiSparseResidentSpec,
@@ -36,6 +40,8 @@ class HiSparseLayout:
40
source_group: KVCacheGroupSpec
41
device_groups: list[KVCacheGroupSpec]
42
host_num_blocks: int
43
+ host_block_stride: int
44
+ shared_host_pool: bool
45
46
47
def get_hisparse_kv_cache_groups(
@@ -221,9 +227,12 @@ def create_hisparse_layout(
227
regular_groups = groups[1:]
228
gpu_groups = [indexer_group, *resident_groups, *hot_groups, *regular_groups]
229
224
- host_num_blocks = host_budget // sum(
225
- spec.page_size_bytes for spec in source_specs.values()
230
+ shared_host_pool = use_shared_hisparse_host_pool(vllm_config)
231
+ host_block_stride = get_hisparse_host_block_stride(
232
+ sum(spec.page_size_bytes for spec in source_specs.values()),
233
+ use_shared_host_pool=shared_host_pool,
234
)
235
+ host_num_blocks = host_budget // host_block_stride
236
if host_num_blocks <= 0:
237
raise ValueError("HiSparse has no allocatable host blocks.")
238
@@ -231,6 +240,8 @@ def create_hisparse_layout(
240
source_group=source_group,
241
device_groups=gpu_groups,
242
host_num_blocks=host_num_blocks,
243
+ host_block_stride=host_block_stride,
244
+ shared_host_pool=shared_host_pool,
245
)
246
247
@@ -339,6 +350,8 @@ def get_hisparse_kv_cache_config(
350
kv_cache_tensors=kv_cache_tensors,
351
kv_cache_groups=[*host_groups, *device_groups],
352
hisparse_host_num_blocks=hisparse_layout.host_num_blocks,
353
+ hisparse_host_block_stride=hisparse_layout.host_block_stride,
354
+ hisparse_shared_host_pool=hisparse_layout.shared_host_pool,
355
prefix_cache_retention_interval=(
356
vllm_config.cache_config.prefix_cache_retention_interval
357
),
vllm/v1/hisparse/runtime.py
+189
-6
@@ -4,6 +4,8 @@
4
5
from __future__ import annotations
6
7
+import math
8
+import mmap
9
import time
10
from collections.abc import Callable
11
from dataclasses import dataclass
@@ -13,17 +15,22 @@ import psutil
15
import torch
16
17
from vllm.config import CUDAGraphMode, VllmConfig
18
+from vllm.distributed import get_tp_group
19
from vllm.forward_context import get_forward_context
20
from vllm.logger import init_logger
21
from vllm.platforms import current_platform
22
from vllm.utils.math_utils import round_up
23
from vllm.utils.torch_utils import current_stream
24
+from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
25
from vllm.v1.simple_kv_offload.cuda_mem_ops import pin_tensor
26
27
logger = init_logger(__name__)
28
29
+HOST_REGISTER_CHUNK_BYTES = 256 * 2**30
30
+
31
if TYPE_CHECKING:
32
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
33
+ from vllm.v1.kv_cache_interface import KVCacheConfig
34
35
# fp8_ds_mla KV row: 512 B quantized NoPE + 16 B scales + 128 B RoPE.
36
FP8_DS_MLA_ROW_BYTES = 656
@@ -185,22 +192,195 @@ def allocate_pinned_host_pool(size: int) -> tuple[torch.Tensor, torch.Tensor]:
192
class HiSparseHostPool:
193
"""Keep the single host backing and its exact CUDA registration range."""
194
188
- def __init__(self) -> None:
195
+ def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig) -> None:
196
+ self.vllm_config = vllm_config
197
+ self.kv_cache_config = kv_cache_config
198
self.backing: torch.Tensor | None = None
199
self.registered: torch.Tensor | None = None
200
+ self.shared_region: SharedOffloadRegion | None = None
201
202
def allocate(self, size: int) -> torch.Tensor:
203
assert self.backing is None, "HiSparse host pool is already allocated."
194
- check_hisparse_host_memory(size)
195
- self.backing, self.registered = allocate_pinned_host_pool(size)
204
+ config = self.kv_cache_config
205
+ if config.hisparse_shared_host_pool:
206
+ tensor_sizes = [
207
+ size
208
+ for _, size in sorted(
209
+ (tensor.offset + i * tensor.layer_stride, tensor.layer_stride)
210
+ for tensor in config.kv_cache_tensors
211
+ if tensor.host_resident
212
+ for i in range(len(tensor.layers))
213
+ )
214
+ ]
215
+ assert config.hisparse_host_num_blocks is not None
216
+ assert config.hisparse_host_block_stride is not None
217
+ _, _, self.shared_region = allocate_hisparse_host_pools(
218
+ self.vllm_config,
219
+ tensor_sizes,
220
+ config.hisparse_host_num_blocks,
221
+ config.hisparse_host_block_stride,
222
+ use_shared_host_pool=True,
223
+ )
224
+ assert self.shared_region is not None
225
+ self.registered = self.shared_region.base_tensor
226
+ self.backing = self.registered[:size]
227
+ else:
228
+ check_hisparse_host_memory(size)
229
+ self.backing, self.registered = allocate_pinned_host_pool(size)
230
return self.backing
231
232
233
+def use_shared_hisparse_host_pool(vllm_config: VllmConfig) -> bool:
234
+ """Whether replicated MLA host KV can share one local mmap."""
235
+ parallel = vllm_config.parallel_config
236
+ return (
237
+ current_platform.is_cuda_alike()
238
+ and parallel.tensor_parallel_size > 1
239
+ and parallel.pipeline_parallel_size == 1
240
+ and parallel.prefill_context_parallel_size == 1
241
+ and parallel.decode_context_parallel_size == 1
242
+ and parallel.world_size == parallel.tensor_parallel_size
243
+ and parallel.distributed_executor_backend == "mp"
244
+ and parallel.nnodes_within_dp == 1
245
+ )
246
+
247
+
248
+def get_hisparse_host_block_stride(
249
+ page_size_bytes: int, *, use_shared_host_pool: bool
250
+) -> int:
251
+ if use_shared_host_pool:
252
+ return round_up(page_size_bytes, SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT)
253
+ return page_size_bytes
254
+
255
+
256
+def _hisparse_registration_ranges(
257
+ tensor_sizes: list[int],
258
+ num_blocks: int,
259
+ host_block_stride: int,
260
+ max_chunk_bytes: int = HOST_REGISTER_CHUNK_BYTES,
261
+) -> tuple[tuple[int, int], ...]:
262
+ """Split a shared pool without bisecting any tensor's host block.
263
+
264
+ CUDA batch copies reject descriptors spanning adjacent registrations.
265
+ """
266
+ total_size = num_blocks * host_block_stride
267
+ if total_size % mmap.PAGESIZE:
268
+ raise ValueError("HiSparse host pool must be OS-page aligned.")
269
+ if max_chunk_bytes < mmap.PAGESIZE:
270
+ raise ValueError("HiSparse registration chunks must span at least one page.")
271
+
272
+ tensors: list[tuple[int, int, int]] = []
273
+ tensor_start = 0
274
+ for tensor_size in tensor_sizes:
275
+ if tensor_size % num_blocks:
276
+ raise ValueError(
277
+ f"HiSparse host tensor size {tensor_size} is not divisible by "
278
+ f"{num_blocks} blocks."
279
+ )
280
+ tensor_end = tensor_start + tensor_size
281
+ tensors.append((tensor_start, tensor_end, tensor_size // num_blocks))
282
+ tensor_start = tensor_end
283
+ if tensor_start > total_size:
284
+ raise ValueError("HiSparse host tensors exceed the shared host pool.")
285
+
286
+ def last_safe_boundary(lower: int, upper: int) -> int | None:
287
+ best = None
288
+ for start, end, block_bytes in tensors:
289
+ first_block = max(0, (lower - start) // block_bytes + 1)
290
+ last_block = min(num_blocks, (min(upper, end) - start) // block_bytes)
291
+ if first_block > last_block:
292
+ continue
293
+ divisor = math.gcd(block_bytes, mmap.PAGESIZE)
294
+ if start % divisor:
295
+ continue
296
+ period = mmap.PAGESIZE // divisor
297
+ if period == 1:
298
+ residue = 0
299
+ else:
300
+ # Find block boundaries that are also OS-page boundaries.
301
+ residue = (
302
+ (-start // divisor) * pow(block_bytes // divisor, -1, period)
303
+ ) % period
304
+ block = last_block - (last_block - residue) % period
305
+ if block >= first_block:
306
+ candidate = start + block * block_bytes
307
+ best = candidate if best is None else max(best, candidate)
308
+
309
+ if upper >= tensor_start:
310
+ candidate = upper // mmap.PAGESIZE * mmap.PAGESIZE
311
+ if candidate >= tensor_start and candidate > lower:
312
+ best = candidate if best is None else max(best, candidate)
313
+ return best
314
+
315
+ ranges: list[tuple[int, int]] = []
316
+ start = 0
317
+ while total_size - start > max_chunk_bytes:
318
+ end = last_safe_boundary(start, start + max_chunk_bytes)
319
+ if end is None:
320
+ raise ValueError(
321
+ "A HiSparse host block is too large for the registration chunk limit."
322
+ )
323
+ ranges.append((start, end))
324
+ start = end
325
+ ranges.append((start, total_size))
326
+ return tuple(ranges)
327
+
328
+
329
+def allocate_hisparse_host_pools(
330
+ vllm_config: VllmConfig,
331
+ tensor_sizes: list[int],
332
+ num_blocks: int,
333
+ host_block_stride: int,
334
+ *,
335
+ use_shared_host_pool: bool,
336
+) -> tuple[list[torch.Tensor], list[torch.Tensor], SharedOffloadRegion | None]:
337
+ """Allocate private host tensors or one mmap shared by local TP ranks."""
338
+ if not use_shared_host_pool:
339
+ check_hisparse_host_memory(sum(tensor_sizes))
340
+ private_pools = [allocate_pinned_host_pool(size) for size in tensor_sizes]
341
+ return (
342
+ [pool for pool, _ in private_pools],
343
+ [registered for _, registered in private_pools],
344
+ None,
345
+ )
346
+
347
+ region = SharedOffloadRegion(
348
+ engine_id=(
349
+ f"hisparse_{vllm_config.instance_id}_"
350
+ f"dp{vllm_config.parallel_config.data_parallel_index}"
351
+ ),
352
+ num_chunks=1,
353
+ rank=0,
354
+ kv_bytes_per_chunk=num_blocks * host_block_stride,
355
+ cpu_page_size=sum(tensor_sizes),
356
+ barrier=get_tp_group().barrier,
357
+ creator_memory_check=check_hisparse_host_memory,
358
+ populate_only_on_creator=True,
359
+ )
360
+ try:
361
+ for start, end in _hisparse_registration_ranges(
362
+ tensor_sizes, num_blocks, host_block_stride
363
+ ):
364
+ tensor = region.base_tensor[start:end]
365
+ pin_tensor(tensor)
366
+ region.pinned_addresses.append(tensor.data_ptr())
367
+ region.is_pinned = True
368
+ pools = [
369
+ region.create_next_canonical_view(size).view(-1) for size in tensor_sizes
370
+ ]
371
+ except Exception:
372
+ region.cleanup()
373
+ raise
374
+ return pools, [], region
375
+
376
+
377
def release_pinned_state(
200
- runtimes: list[HiSparseRuntime], pinned_host_pools: list[torch.Tensor]
378
+ runtimes: list[HiSparseRuntime],
379
+ pinned_host_pools: list[torch.Tensor],
380
+ shared_host_region: SharedOffloadRegion | None = None,
381
) -> None:
382
"""Synchronize and release registered host KV pools."""
203
- if pinned_host_pools:
383
+ if pinned_host_pools or shared_host_region is not None:
384
try:
385
torch.accelerator.synchronize()
386
except RuntimeError as e:
@@ -208,7 +388,7 @@ def release_pinned_state(
388
"HiSparse: CUDA context unusable at teardown (%s); leaving "
389
"%d host-pool tensors pinned for kernel exit reclaim.",
390
e,
211
- len(pinned_host_pools),
391
+ len(pinned_host_pools) + int(shared_host_region is not None),
392
)
393
return
394
@@ -240,6 +420,8 @@ def release_pinned_state(
420
del runtime._host_cache
421
del runtime.registered_host_pool
422
del runtime.hot_backing
423
+ if shared_host_region is not None:
424
+ shared_host_region.cleanup()
425
426
427
def hisparse_prefill_staging_remap(
@@ -536,6 +718,7 @@ class HiSparseRuntime:
718
self.eager_host_mirror = config.eager_host_mirror
719
self.resident_source_index = -1
720
self.request_state_indices: torch.Tensor | None = None
721
+ self.shared_host_region: SharedOffloadRegion | None = None
722
723
@property
724
def host_cache(self) -> torch.Tensor:
vllm/v1/kv_cache_interface.py
+6
@@ -1435,6 +1435,12 @@ class KVCacheConfig:
1435
hisparse_host_num_blocks: int | None = None
1436
"""Capacity of the dedicated HiSparse host-block manager, when enabled."""
1437
1438
+ hisparse_host_block_stride: int | None = None
1439
+ """Physical bytes between consecutive HiSparse host blocks."""
1440
+
1441
+ hisparse_shared_host_pool: bool = False
1442
+ """Whether local TP ranks share one physical HiSparse host pool."""
1443
+
1444
@cached_property
1445
def transfer_group_ids(self) -> tuple[int, ...]:
1446
"""IDs of cache groups that participate in external KV transfer."""
vllm/v1/kv_offload/cpu/shared_offload_region.py
+60
-25
@@ -1,5 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import contextlib
4
import errno
5
import mmap
6
import os
@@ -27,6 +28,10 @@ def _wait_for_file_size(fd: int, expected_size: int, timeout: float = 30.0) -> N
28
while True:
29
if os.fstat(fd).st_size >= expected_size:
30
return
31
+ if os.fstat(fd).st_nlink == 0:
32
+ raise RuntimeError(
33
+ "Shared offload region creator failed during initialization."
34
+ )
35
if time.monotonic() > deadline:
36
raise TimeoutError(
37
f"Timed out waiting for mmap file to reach {expected_size} bytes"
@@ -75,6 +80,9 @@ class SharedOffloadRegion:
80
given, the path is unlinked once every worker has mapped the file, so
81
the kernel reclaims the memory when the last worker exits, no matter
82
how it exits; mappings taken before the unlink stay valid.
83
+
84
+ Creator-only population pre-faults the entire region before the barrier
85
+ and requires that barrier to keep joiners from using unpopulated pages.
86
"""
87
88
BLOCK_SIZE_ALIGNMENT: int = mmap.PAGESIZE
@@ -87,7 +95,12 @@ class SharedOffloadRegion:
95
kv_bytes_per_chunk: int,
96
cpu_page_size: int,
97
barrier: Callable[[], None] | None = None,
98
+ *,
99
+ creator_memory_check: Callable[[int], None] | None = None,
100
+ populate_only_on_creator: bool = False,
101
) -> None:
102
+ if populate_only_on_creator and barrier is None:
103
+ raise ValueError("Creator-only population requires a barrier.")
104
self.page_size = mmap.PAGESIZE
105
assert kv_bytes_per_chunk % self.page_size == 0
106
@@ -112,25 +125,18 @@ class SharedOffloadRegion:
125
# Joiner path — another worker won O_EXCL. Reopen and wait
126
# for the file to reach expected size.
127
self.fd = os.open(self.mmap_path, os.O_RDWR)
115
- try:
116
- _wait_for_file_size(self.fd, self.total_size_bytes)
117
- except (TimeoutError, OSError):
118
- os.close(self.fd)
119
- raise
128
+ _wait_for_file_size(self.fd, self.total_size_bytes)
129
logger.info("Opened existing mmap file %s", self.mmap_path)
130
else:
131
# Creator path. We won O_EXCL, so we own the file: any
132
# failure here must clean up so concurrent joiners don't
133
# land on a 0-byte stub and spin in _wait_for_file_size
134
# for the full 30 s timeout.
126
- try:
127
- check_shm_free_space(self.total_size_bytes)
128
- os.ftruncate(self.fd, self.total_size_bytes)
129
- except (RuntimeError, OSError):
130
- os.unlink(self.mmap_path)
131
- os.close(self.fd)
132
- raise
135
self._creator = True
136
+ if creator_memory_check is not None:
137
+ creator_memory_check(self.total_size_bytes)
138
+ check_shm_free_space(self.total_size_bytes)
139
+ os.ftruncate(self.fd, self.total_size_bytes)
140
logger.info(
141
"Created mmap file %s (%.2f GB)",
142
self.mmap_path,
@@ -143,10 +149,21 @@ class SharedOffloadRegion:
149
flags=mmap.MAP_SHARED,
150
prot=mmap.PROT_READ | mmap.PROT_WRITE,
151
)
152
+
153
+ if populate_only_on_creator and self._creator:
154
+ populate_write_fn = _get_populate_write_fn(self.mmap_obj)
155
+ populate_write_fn(self.mmap_obj, 0, self.total_size_bytes)
156
except Exception:
157
if self._creator:
148
- os.unlink(self.mmap_path)
158
+ with contextlib.suppress(FileNotFoundError):
159
+ os.unlink(self.mmap_path)
160
self._creator = False
161
+ if hasattr(self, "mmap_obj") and self.mmap_obj is not None:
162
+ self.mmap_obj.close()
163
+ self.mmap_obj = None
164
+ if hasattr(self, "fd") and self.fd is not None:
165
+ os.close(self.fd)
166
+ self.fd = None
167
# Peers block inside the barrier until the collective times out if
168
# we die before reaching it. Arrive anyway so every worker calls
169
# barrier() exactly once and they fail on their own errors instead
@@ -169,16 +186,28 @@ class SharedOffloadRegion:
186
barrier()
187
except Exception:
188
if self._creator:
172
- os.unlink(self.mmap_path)
189
+ with contextlib.suppress(FileNotFoundError):
190
+ os.unlink(self.mmap_path)
191
self._creator = False
192
self.mmap_obj.close()
193
os.close(self.fd)
194
+ self.mmap_obj = None
195
+ self.fd = None
196
raise
197
if self._creator:
198
os.unlink(self.mmap_path)
199
self._creator = False
200
logger.info("Unlinked mmap file %s", self.mmap_path)
201
202
+ self._base = torch.frombuffer(memoryview(self.mmap_obj), dtype=torch.int8)
203
+ self._views: list[torch.Tensor] = []
204
+ self._canonical_offset = 0
205
+ self.is_pinned: bool = False
206
+ self.pinned_addresses: list[int] = []
207
+
208
+ if populate_only_on_creator:
209
+ return
210
+
211
populate_write_fn = _get_populate_write_fn(self.mmap_obj)
212
213
if rank is not None:
@@ -205,10 +234,11 @@ class SharedOffloadRegion:
234
"MADV_POPULATE_WRITE entire region: %.3f s", time.perf_counter() - _t0
235
)
236
208
- self._base = torch.frombuffer(memoryview(self.mmap_obj), dtype=torch.int8)
209
- self._views: list[torch.Tensor] = []
210
- self._canonical_offset = 0
211
- self.is_pinned: bool = False
237
+ @property
238
+ def base_tensor(self) -> torch.Tensor:
239
+ if self._base is None:
240
+ raise RuntimeError("Shared offload region has been released.")
241
+ return self._base
242
243
def create_next_worker_view(self, tensor_page_size: int) -> torch.Tensor:
244
"""Allocate a strided int8 view for this worker, one canonical tensor.
@@ -311,13 +341,18 @@ class SharedOffloadRegion:
341
if self.is_pinned and self._base is not None:
342
if current_platform.is_cuda_alike():
343
base_ptr = self._base.data_ptr()
314
- result = torch.cuda.cudart().cudaHostUnregister(base_ptr)
315
- if result.value != 0:
316
- logger.warning(
317
- "cudaHostUnregister failed for rank=%d (code=%d)",
318
- self.rank,
319
- result,
320
- )
344
+ addresses = self.pinned_addresses or [base_ptr]
345
+ for address in reversed(addresses):
346
+ result = torch.cuda.cudart().cudaHostUnregister(address)
347
+ if result.value != 0:
348
+ logger.warning(
349
+ "cudaHostUnregister failed for rank=%d, "
350
+ "address=%#x (code=%d)",
351
+ self.rank,
352
+ address,
353
+ result.value,
354
+ )
355
+ self.pinned_addresses.clear()
356
self.is_pinned = False
357
# Release views before _base: each view holds a _base reference and a
358
# direct StorageImpl reference. Freeing views first lets both refcounts