Skip to content

Commit a0d6289

Browse files
committed
[py_connector] stage transfers from a bounded pool, not per-task allocs
Per-task torch.empty staging made the connector's VRAM footprint unbounded and racing the engine for its own allocation: at high load on low-headroom GPUs (doc085 @ gpu-mem-util 0.92) the 98 MiB per load task allocations OOMed the engine where origin/main -- whose gather kernel writes a pre-allocated pinned host pool directly -- ran fine. Replace the dynamic allocs with one _StagingPool per transfer group (save and load share it): a fixed, configurable HBM + pinned reservation (staging_pool_blocks, default 128, validated >= the largest task batch) handed out as contiguous runs, with blocking acquire as backpressure when exhausted. Bulk D2H/H2D and kernel views are unchanged -- only the buffer source differs. Exception paths drain the stream before the slots go back so a failed task cannot leave enqueued work against a reused view. This also restores origin/main's no-dynamic-allocation invariant the revert of _PinnedBudget (7bd0413) dropped, without reintroducing the connector-level lifetime governance that #280's deadline chain owns.
1 parent 67e1a98 commit a0d6289

3 files changed

Lines changed: 264 additions & 72 deletions

File tree

kv_cache_manager/py_connector/test/test_data_transfer_results.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
"""
99

1010
import threading
11+
import time
1112
import unittest
13+
from concurrent.futures import ThreadPoolExecutor
1214
from unittest.mock import MagicMock
1315

16+
import torch
17+
1418
from kv_cache_manager.py_connector.test import vllm_stubs # noqa: F401 (stubs)
1519
from kv_cache_manager.py_connector.vllm.data_transfer import (
1620
DataTransferManager, MultiResult)
@@ -313,3 +317,64 @@ def test_load_all_groups_abstain_counts_as_failure(self):
313317

314318
if __name__ == "__main__":
315319
unittest.main()
320+
321+
322+
class TestStagingPool(unittest.TestCase):
323+
"""_StagingPool: contiguous-run slot management with backpressure.
324+
325+
The pool replaced per-task torch.empty staging (VRAM OOM under load);
326+
these tests pin the run bookkeeping: exact fit, fragmentation and
327+
re-merge on release, blocking acquire, and the capacity guard.
328+
"""
329+
330+
def _pool(self, max_blocks=8, block_bytes=16):
331+
from kv_cache_manager.py_connector.vllm.data_transfer import _StagingPool
332+
return _StagingPool(torch.device("cpu"), block_bytes, max_blocks)
333+
334+
def test_roundtrip_and_merge(self):
335+
pool = self._pool()
336+
a = pool.acquire(3)
337+
b = pool.acquire(5) # exact fit of the remainder
338+
self.assertEqual((a, b), (0, 3))
339+
pool.release(a, 3)
340+
pool.release(b, 5) # neighbours must merge back to one run
341+
self.assertEqual(pool._runs, [[0, 8]])
342+
self.assertEqual(pool.acquire(8), 0) # full capacity usable again
343+
344+
def test_fragmentation_blocks_then_merge_wakes(self):
345+
pool = self._pool()
346+
a, b, c = pool.acquire(2), pool.acquire(2), pool.acquire(2)
347+
self.assertEqual((a, b, c), (0, 2, 4))
348+
pool.release(a, 2) # free: [0,2) and [6,8)
349+
pool.release(c, 2)
350+
# 5 free blocks in total but no contiguous run of 5: blocks.
351+
with ThreadPoolExecutor(max_workers=1) as ex:
352+
fut = ex.submit(pool.acquire, 5)
353+
time.sleep(0.2)
354+
self.assertFalse(fut.done(), "fragmented pool must block")
355+
pool.release(b, 2) # glues [0,8) back together
356+
self.assertEqual(fut.result(timeout=5), 0)
357+
358+
def test_blocking_acquire_wakes_on_release(self):
359+
pool = self._pool(max_blocks=4)
360+
held = pool.acquire(3)
361+
with ThreadPoolExecutor(max_workers=1) as ex:
362+
fut = ex.submit(pool.acquire, 3)
363+
time.sleep(0.2)
364+
self.assertFalse(fut.done(), "acquire must block while exhausted")
365+
pool.release(held, 3)
366+
self.assertEqual(fut.result(timeout=5), 0)
367+
368+
def test_oversized_acquire_raises(self):
369+
pool = self._pool(max_blocks=4)
370+
with self.assertRaises(ValueError):
371+
pool.acquire(5)
372+
373+
def test_views_slice_the_same_run(self):
374+
pool = self._pool(max_blocks=8, block_bytes=16)
375+
start = pool.acquire(3)
376+
cpu, gpu = pool.cpu_view(start, 3), pool.gpu_view(start, 3)
377+
self.assertEqual(cpu.numel(), 48)
378+
self.assertEqual(gpu.numel(), 48)
379+
cpu.zero_()
380+
self.assertTrue(bool((pool._cpu[0:48] == 0).all()))

kv_cache_manager/py_connector/vllm/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ class TairKvCacheConnectorExtraConfig(BaseModel):
3636
block_per_save_task: int = 128
3737
block_per_load_task: int = 128
3838

39+
# --- Staging buffers ---
40+
# Pre-allocated contiguous staging slots per transfer group (shared by
41+
# save and load). GPU side is a *fixed* HBM reservation of
42+
# staging_pool_blocks * per_block_bytes per group; per-task dynamic
43+
# allocation instead competes with the engine's own memory and OOMs it
44+
# under load on low-headroom GPUs. An exhausted pool blocks the task
45+
# (backpressure). Must be >= max(block_per_save_task, block_per_load_task)
46+
# because one task stages its whole batch contiguously; shrink both
47+
# together on small cards.
48+
staging_pool_blocks: int = 128
49+
3950
# --- Manager queries ---
4051
async_get_cache_location: bool = True
4152

kv_cache_manager/py_connector/vllm/data_transfer.py

Lines changed: 188 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,83 @@ def _get_device_module(device=None):
4343
return torch.cuda
4444

4545

46+
class _StagingPool:
47+
"""Bounded, pre-allocated staging slots for one TransferGroup.
48+
49+
Save and load share one pool. Replaces per-task ``torch.empty``: the GPU
50+
side becomes a fixed, configurable HBM reservation instead of unbounded
51+
dynamic allocation that competes with the engine (VRAM OOM under load on
52+
low-headroom GPUs), and an exhausted pool blocks the acquiring task --
53+
backpressure -- instead of failing. Slots are handed out as *contiguous*
54+
runs because the bulk D2H/H2D copies and the kernel views need one piece
55+
of memory.
56+
"""
57+
58+
def __init__(self, device, per_block_bytes: int, max_blocks: int):
59+
if max_blocks <= 0:
60+
raise ValueError("staging pool must have at least one block slot")
61+
self.block_bytes = per_block_bytes
62+
self.max_blocks = max_blocks
63+
self._cond = threading.Condition()
64+
total = max_blocks * per_block_bytes
65+
# Pinned host memory needs a CUDA context; on other devices (tests,
66+
# CPU-only runs) fall back to pageable memory.
67+
self._cpu = torch.empty(total, dtype=torch.uint8, device="cpu",
68+
pin_memory=(device.type == "cuda"))
69+
self._gpu = torch.empty(total, dtype=torch.uint8, device=device)
70+
# Free runs as [start, start+len) block ranges, kept sorted by start.
71+
self._runs = [[0, max_blocks]]
72+
73+
def acquire(self, n: int) -> int:
74+
"""Block until a contiguous run of ``n`` block slots is free; return
75+
its starting block index."""
76+
if n <= 0:
77+
raise ValueError("must acquire at least one block slot")
78+
if n > self.max_blocks:
79+
raise ValueError(
80+
f"staging task of {n} blocks exceeds the pool capacity "
81+
f"{self.max_blocks}; raise staging_pool_blocks or shrink "
82+
f"block_per_save_task/block_per_load_task")
83+
with self._cond:
84+
while True:
85+
for i, (start, length) in enumerate(self._runs):
86+
if length >= n:
87+
rest = length - n
88+
if rest:
89+
self._runs[i] = [start + n, rest]
90+
else:
91+
self._runs.pop(i)
92+
return start
93+
self._cond.wait()
94+
95+
def release(self, start: int, n: int) -> None:
96+
with self._cond:
97+
pos, run = 0, [start, n]
98+
for pos, (s, _len) in enumerate(self._runs):
99+
if s > start:
100+
break
101+
else:
102+
pos = len(self._runs)
103+
self._runs.insert(pos, run)
104+
# Merge with the neighbours the release just glued together.
105+
merged = []
106+
for s, l in self._runs:
107+
if merged and merged[-1][0] + merged[-1][1] == s:
108+
merged[-1][1] += l
109+
else:
110+
merged.append([s, l])
111+
self._runs = merged
112+
self._cond.notify_all()
113+
114+
def cpu_view(self, start: int, n: int) -> torch.Tensor:
115+
b = self.block_bytes
116+
return self._cpu[start * b:(start + n) * b]
117+
118+
def gpu_view(self, start: int, n: int) -> torch.Tensor:
119+
b = self.block_bytes
120+
return self._gpu[start * b:(start + n) * b]
121+
122+
46123
class MultiResult:
47124
"""Collect the per-block success flags of several async tasks and fire a
48125
callback once every task has reported. Each result is a list[bool] aligned
@@ -79,6 +156,27 @@ def __init__(self, kvcache_info: KVCacheInfo, manager_block_size: int,
79156
self._save_stream = self._device_mod.Stream()
80157
self._load_stream = self._device_mod.Stream()
81158

159+
pool_blocks = extra_config.staging_pool_blocks
160+
need = max(extra_config.block_per_save_task,
161+
extra_config.block_per_load_task)
162+
if pool_blocks < need:
163+
raise ValueError(
164+
f"staging_pool_blocks={pool_blocks} is smaller than the "
165+
f"largest task batch ({need}); one task stages its whole "
166+
f"batch contiguously, so the pool must cover it")
167+
# One pool per group: block shapes differ between attention and state
168+
# groups. The GPU side is the HBM this connector permanently reserves.
169+
self._pools = {
170+
g.spec_name: _StagingPool(self._device, g.per_block_bytes, pool_blocks)
171+
for g in kvcache_info.groups}
172+
for name, pool in self._pools.items():
173+
logger.info("staging pool %s: %d blocks x %d bytes "
174+
"(pinned %.1f MiB + GPU %.1f MiB)",
175+
name, pool.max_blocks,
176+
pool.block_bytes,
177+
pool.max_blocks * pool.block_bytes / 2**20,
178+
pool.max_blocks * pool.block_bytes / 2**20)
179+
82180
def _init_worker():
83181
self._device_mod.set_device(self._device)
84182

@@ -223,43 +321,52 @@ def _save_valid_blocks(self, group, remote_uris,
223321
assert all(uri is not None for uri in uris), \
224322
f"group {group.spec_name}: save batch contains a block without a " \
225323
f"location; _save_dispositions must have failed it"
226-
cpu_buffer = torch.empty(len(valid) * group.per_block_bytes, dtype=torch.uint8,
227-
device="cpu", pin_memory=True)
324+
pool = self._pools[group.spec_name]
228325
with self._device_mod.stream(self._save_stream):
229326
ready_event.wait()
230-
gpu_buffer = torch.empty(len(valid) * group.per_block_bytes,
231-
dtype=torch.uint8, device=self._device)
232-
if isinstance(group, AttentionTransferGroup):
233-
view = gpu_buffer.view(self._info.dtype).view(
234-
len(valid), group.num_kv_ptrs,
235-
self._manager_block_size, group.per_token_dim)
236-
batch_gather_scatter_helper.batch_gather_kv_caches(
237-
group.kvcache_ptr_tensor_gpu, view,
238-
[block_token_indices[i] for i in valid],
239-
list(range(len(valid))), self._manager_block_size,
240-
group.per_token_dim,
241-
block_stride=group.block_stride,
242-
local_block_size=group.kernel_block_size)
243-
else:
244-
for out_i, i in enumerate(valid):
245-
for layer_idx in range(group.layer_num):
246-
dst = (out_i * group.layer_num + layer_idx) * group.page_size_bytes
247-
gpu_buffer[dst:dst + group.page_size_bytes].copy_(
248-
group.block_view_tensors[layer_idx][block_ids[i]])
249-
cpu_buffer.copy_(gpu_buffer, non_blocking=True)
250-
done = self._device_mod.Event()
251-
done.record(self._save_stream)
252-
done.synchronize()
253-
254-
buffers = self._make_block_buffers(
255-
cpu_buffer.data_ptr(), group.per_block_bytes, len(valid))
256-
result = self._transfer_client.SaveKvCaches(uris, buffers)
257-
ok = (result[0] == kvcm_py_client.ClientErrorCode.ER_OK)
258-
if not ok:
259-
logger.warning("save task failed group=%s uris=%d result=%s",
260-
group.spec_name, len(uris), result)
261-
for i in valid:
262-
ok_mask[i] = ok
327+
start = pool.acquire(len(valid))
328+
try:
329+
cpu_buffer = pool.cpu_view(start, len(valid))
330+
gpu_buffer = pool.gpu_view(start, len(valid))
331+
with self._device_mod.stream(self._save_stream):
332+
if isinstance(group, AttentionTransferGroup):
333+
view = gpu_buffer.view(self._info.dtype).view(
334+
len(valid), group.num_kv_ptrs,
335+
self._manager_block_size, group.per_token_dim)
336+
batch_gather_scatter_helper.batch_gather_kv_caches(
337+
group.kvcache_ptr_tensor_gpu, view,
338+
[block_token_indices[i] for i in valid],
339+
list(range(len(valid))), self._manager_block_size,
340+
group.per_token_dim,
341+
block_stride=group.block_stride,
342+
local_block_size=group.kernel_block_size)
343+
else:
344+
for out_i, i in enumerate(valid):
345+
for layer_idx in range(group.layer_num):
346+
dst = (out_i * group.layer_num + layer_idx) * group.page_size_bytes
347+
gpu_buffer[dst:dst + group.page_size_bytes].copy_(
348+
group.block_view_tensors[layer_idx][block_ids[i]])
349+
cpu_buffer.copy_(gpu_buffer, non_blocking=True)
350+
done = self._device_mod.Event()
351+
done.record(self._save_stream)
352+
done.synchronize()
353+
354+
buffers = self._make_block_buffers(
355+
cpu_buffer.data_ptr(), group.per_block_bytes, len(valid))
356+
result = self._transfer_client.SaveKvCaches(uris, buffers)
357+
ok = (result[0] == kvcm_py_client.ClientErrorCode.ER_OK)
358+
if not ok:
359+
logger.warning("save task failed group=%s uris=%d result=%s",
360+
group.spec_name, len(uris), result)
361+
for i in valid:
362+
ok_mask[i] = ok
363+
except BaseException:
364+
# Drain the stream before the slots go back: a failed task may
365+
# have left kernel/copy work enqueued against the staging views.
366+
self._save_stream.synchronize()
367+
raise
368+
finally:
369+
pool.release(start, len(valid))
263370

264371
def create_save_done_callback(self, req_id, tp_rank, write_session_id, num_blocks):
265372
"""block success = AND across all groups that had data for the block.
@@ -326,43 +433,52 @@ def load_task(self, multi_result: MultiResult, task_idx, group: TransferGroup,
326433

327434
def _load_valid_blocks(self, group, remote_uris, block_token_indices,
328435
block_ids, valid) -> bool:
329-
cpu_buffer = torch.empty(len(valid) * group.per_block_bytes, dtype=torch.uint8,
330-
device="cpu", pin_memory=True)
331-
buffers = self._make_block_buffers(cpu_buffer.data_ptr(),
332-
group.per_block_bytes, len(valid))
333-
uris = [remote_uris[i] for i in valid]
334-
assert all(uri is not None for uri in uris), \
335-
f"group {group.spec_name}: load batch contains a block without a " \
336-
f"location; load_task must have failed it"
337-
result = self._transfer_client.LoadKvCaches(uris, buffers)
338-
ok = (result == kvcm_py_client.ClientErrorCode.ER_OK)
339-
if ok:
340-
with self._device_mod.stream(self._load_stream):
341-
gpu_buffer = cpu_buffer.to(self._device, non_blocking=True)
342-
if isinstance(group, AttentionTransferGroup):
343-
view = gpu_buffer.view(self._info.dtype).view(
344-
len(valid), group.num_kv_ptrs,
345-
self._manager_block_size, group.per_token_dim)
346-
batch_gather_scatter_helper.batch_scatter_kv_caches(
347-
group.kvcache_ptr_tensor_gpu, view,
348-
[block_token_indices[i] for i in valid],
349-
list(range(len(valid))), self._manager_block_size,
350-
group.per_token_dim,
351-
block_stride=group.block_stride,
352-
local_block_size=group.kernel_block_size)
353-
else:
354-
for out_i, i in enumerate(valid):
355-
for layer_idx in range(group.layer_num):
356-
src = (out_i * group.layer_num + layer_idx) * group.page_size_bytes
357-
group.block_view_tensors[layer_idx][block_ids[i]].copy_(
358-
gpu_buffer[src:src + group.page_size_bytes])
359-
done = self._device_mod.Event()
360-
done.record(self._load_stream)
361-
done.synchronize()
362-
else:
363-
logger.warning("load task failed group=%s uris=%d result=%s",
364-
group.spec_name, len(uris), result)
365-
return ok
436+
pool = self._pools[group.spec_name]
437+
start = pool.acquire(len(valid))
438+
try:
439+
cpu_buffer = pool.cpu_view(start, len(valid))
440+
buffers = self._make_block_buffers(cpu_buffer.data_ptr(),
441+
group.per_block_bytes, len(valid))
442+
uris = [remote_uris[i] for i in valid]
443+
assert all(uri is not None for uri in uris), \
444+
f"group {group.spec_name}: load batch contains a block without a " \
445+
f"location; load_task must have failed it"
446+
result = self._transfer_client.LoadKvCaches(uris, buffers)
447+
ok = (result == kvcm_py_client.ClientErrorCode.ER_OK)
448+
if ok:
449+
with self._device_mod.stream(self._load_stream):
450+
gpu_buffer = pool.gpu_view(start, len(valid))
451+
gpu_buffer.copy_(cpu_buffer, non_blocking=True)
452+
if isinstance(group, AttentionTransferGroup):
453+
view = gpu_buffer.view(self._info.dtype).view(
454+
len(valid), group.num_kv_ptrs,
455+
self._manager_block_size, group.per_token_dim)
456+
batch_gather_scatter_helper.batch_scatter_kv_caches(
457+
group.kvcache_ptr_tensor_gpu, view,
458+
[block_token_indices[i] for i in valid],
459+
list(range(len(valid))), self._manager_block_size,
460+
group.per_token_dim,
461+
block_stride=group.block_stride,
462+
local_block_size=group.kernel_block_size)
463+
else:
464+
for out_i, i in enumerate(valid):
465+
for layer_idx in range(group.layer_num):
466+
src = (out_i * group.layer_num + layer_idx) * group.page_size_bytes
467+
group.block_view_tensors[layer_idx][block_ids[i]].copy_(
468+
gpu_buffer[src:src + group.page_size_bytes])
469+
done = self._device_mod.Event()
470+
done.record(self._load_stream)
471+
done.synchronize()
472+
else:
473+
logger.warning("load task failed group=%s uris=%d result=%s",
474+
group.spec_name, len(uris), result)
475+
return ok
476+
except BaseException:
477+
# Drain the stream before the slots go back (as in save).
478+
self._load_stream.synchronize()
479+
raise
480+
finally:
481+
pool.release(start, len(valid))
366482

367483
def create_load_done_callback(self, req_id, tp_rank, epoch, block_ids, num_blocks,
368484
report_failures=True):

0 commit comments

Comments
 (0)