Skip to content

Commit 0c24225

Browse files
authored
Avoid quadratic eviction scans across cache pools (#27)
Replace repeated eviction searches with one traversal per allocation attempt in DRAM and G3. Preserve score order and restore skipped entries on exit. Signed-off-by: Moein Khazraee <moein@nvidia.com>
1 parent 2addabb commit 0c24225

4 files changed

Lines changed: 112 additions & 85 deletions

File tree

‎src/kvcr/local_disk.py‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
from collections import deque
99
from collections.abc import Callable, Collection, Mapping
10+
from contextlib import closing
1011
from dataclasses import dataclass, field
1112
from pathlib import Path
1213
from typing import TYPE_CHECKING, Any, Literal
@@ -516,29 +517,29 @@ def _allocate_slot(self, protected: set[BlockKey]) -> int | None:
516517
if self._free_slots:
517518
return self._free_slots.popleft()
518519
self._retry_unscored()
519-
skipped = set(protected)
520-
while (key := self._evictable.select(skipped)) is not None:
521-
record = self._kvcr._block_record_map.get(key)
522-
residency = record.g3 if record is not None else None
523-
if record is None or residency is None or residency.claim_count:
524-
raise RuntimeError(f"invalid G3 eviction candidate {key!r}")
525-
if any(op_id[0] == "fetch" for op_id in record.in_flight_ops or ()):
526-
skipped.add(key)
527-
continue
528-
decision = self._kvcr._policy.decide_eviction(
529-
self._kvcr._block_meta(key, record, self._slot_size),
530-
CacheTier.G3,
531-
)
532-
if decision[0] is PlacementAction.KEEP:
533-
skipped.add(key)
534-
continue
535-
self._remove_evictable(key)
536-
record.g3 = None
537-
self._residency_observer(key, record)
538-
self._kvcr._on_remove(self._kvcr._block_meta(key, record, self._slot_size))
539-
self._kvcr._publish_inventory((key,), CacheTier.G3, removed=True)
540-
self._kvcr._prune_block_record(key)
541-
return residency.slot
520+
with closing(self._evictable.candidates(protected)) as candidates:
521+
for key in candidates:
522+
record = self._kvcr._block_record_map.get(key)
523+
residency = record.g3 if record is not None else None
524+
if record is None or residency is None or residency.claim_count:
525+
raise RuntimeError(f"invalid G3 eviction candidate {key!r}")
526+
if any(op_id[0] == "fetch" for op_id in record.in_flight_ops or ()):
527+
continue
528+
decision = self._kvcr._policy.decide_eviction(
529+
self._kvcr._block_meta(key, record, self._slot_size),
530+
CacheTier.G3,
531+
)
532+
if decision[0] is PlacementAction.KEEP:
533+
continue
534+
self._remove_evictable(key)
535+
record.g3 = None
536+
self._residency_observer(key, record)
537+
self._kvcr._on_remove(
538+
self._kvcr._block_meta(key, record, self._slot_size)
539+
)
540+
self._kvcr._publish_inventory((key,), CacheTier.G3, removed=True)
541+
self._kvcr._prune_block_record(key)
542+
return residency.slot
542543
return None
543544

544545
def _commit(self, reservation: _Reservation) -> None:

‎src/kvcr/local_dram.py‎

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from collections import Counter, deque
77
from collections.abc import Callable, Collection, Mapping
8+
from contextlib import closing
89
from dataclasses import dataclass, field
910
from enum import Enum, auto
1011
from typing import TYPE_CHECKING, cast
@@ -1021,7 +1022,6 @@ def _allocate_slots(
10211022
if self._capacity_eviction_key is not None:
10221023
return None, [], True
10231024
self._retry_unscored()
1024-
skipped = set(protected)
10251025
victims: list[tuple[BlockKey, "_BlockRecord", _LocalDramResidency, int]] = []
10261026
freed: Counter[str] = Counter()
10271027

@@ -1032,39 +1032,37 @@ def short() -> set[str]:
10321032
if len(self._free_slots[name]) + freed[name] < count
10331033
}
10341034

1035-
while deficient := short():
1036-
key = self._evictable.select(skipped)
1037-
if key is None:
1038-
return None, [], False
1039-
record = self._kvcr._block_record_map.get(key)
1040-
residency = record.local_dram if record is not None else None
1041-
if (
1042-
record is None
1043-
or residency is None
1044-
or residency.state is not _LocalDramState.READY
1045-
or residency.claim_count
1046-
):
1047-
raise RuntimeError(f"invalid evictable local DRAM entry {key!r}")
1048-
if not any(name in deficient for name, _ in residency.slots):
1049-
skipped.add(key)
1050-
continue
1051-
size_bytes = self._size_bytes(residency.slots)
1052-
decision, eviction_pending = self._kvcr._decide_eviction(
1053-
self._kvcr._block_meta(key, record, size_bytes),
1054-
CacheTier.LOCAL_G2,
1055-
deadline,
1056-
)
1057-
if not short():
1058-
break
1059-
if eviction_pending:
1060-
self._capacity_eviction_key = key
1061-
return None, [], True
1062-
if decision[0] is PlacementAction.KEEP:
1063-
skipped.add(key)
1064-
continue
1065-
victims.append((key, record, residency, size_bytes))
1066-
skipped.add(key)
1067-
freed.update(name for name, _ in residency.slots)
1035+
with closing(self._evictable.candidates(protected)) as candidates:
1036+
while deficient := short():
1037+
key = next(candidates, None)
1038+
if key is None:
1039+
return None, [], False
1040+
record = self._kvcr._block_record_map.get(key)
1041+
residency = record.local_dram if record is not None else None
1042+
if (
1043+
record is None
1044+
or residency is None
1045+
or residency.state is not _LocalDramState.READY
1046+
or residency.claim_count
1047+
):
1048+
raise RuntimeError(f"invalid evictable local DRAM entry {key!r}")
1049+
if not any(name in deficient for name, _ in residency.slots):
1050+
continue
1051+
size_bytes = self._size_bytes(residency.slots)
1052+
decision, eviction_pending = self._kvcr._decide_eviction(
1053+
self._kvcr._block_meta(key, record, size_bytes),
1054+
CacheTier.LOCAL_G2,
1055+
deadline,
1056+
)
1057+
if not short():
1058+
break
1059+
if eviction_pending:
1060+
self._capacity_eviction_key = key
1061+
return None, [], True
1062+
if decision[0] is PlacementAction.KEEP:
1063+
continue
1064+
victims.append((key, record, residency, size_bytes))
1065+
freed.update(name for name, _ in residency.slots)
10681066

10691067
for key, record, residency, size_bytes in victims:
10701068
self._remove_evictable(key, residency)

‎src/kvcr/policy_runtime.py‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import heapq
66
import logging
77
import math
8-
from collections.abc import Collection
8+
from collections.abc import Collection, Generator
99
from dataclasses import dataclass
1010

1111
from .policy import KVCachePolicy
@@ -152,7 +152,7 @@ class _Entry:
152152

153153
class _EvictionQueue:
154154
# TODO: Bound stale heap growth from DRAM/G3 claim/release cycles.
155-
# Removal only invalidates _live; select() removes stale heap entries as
155+
# Removal only invalidates _live; candidates() removes stale heap entries as
156156
# it encounters them, so repeated cache use can grow _heap without eviction.
157157
def __init__(self) -> None:
158158
self._heap: list[tuple[float, int, BlockKey]] = []
@@ -171,19 +171,21 @@ def insert(self, key: BlockKey, score: float) -> None:
171171
def remove(self, key: BlockKey) -> bool:
172172
return self._live.pop(key, None) is not None
173173

174-
def select(self, excluded: set[BlockKey]) -> BlockKey | None:
174+
def candidates(self, excluded: set[BlockKey]) -> Generator[BlockKey, None, None]:
175+
"""Visit each key once in score order; close to restore live entries."""
176+
excluded = set(excluded)
175177
skipped: list[tuple[float, int, BlockKey]] = []
176-
selected: BlockKey | None = None
177-
while self._heap:
178-
score, sequence, key = self._heap[0]
179-
entry = self._live.get(key)
180-
if entry != _Entry(score, sequence):
181-
heapq.heappop(self._heap)
182-
continue
183-
if key not in excluded:
184-
selected = key
185-
break
186-
skipped.append(heapq.heappop(self._heap))
187-
for item in skipped:
188-
heapq.heappush(self._heap, item)
189-
return selected
178+
try:
179+
while self._heap:
180+
item = heapq.heappop(self._heap)
181+
score, sequence, key = item
182+
if self._live.get(key) != _Entry(score, sequence):
183+
continue
184+
skipped.append(item)
185+
if key not in excluded:
186+
excluded.add(key)
187+
yield key
188+
finally:
189+
for score, sequence, key in skipped:
190+
if self._live.get(key) == _Entry(score, sequence):
191+
heapq.heappush(self._heap, (score, sequence, key))

‎tests/unit/test_kvcr_local_dram.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""KVCR local-DRAM, capacity, and policy tests."""
44

55
import ctypes
6+
import heapq
67
import logging
78
from unittest.mock import Mock
89

@@ -227,37 +228,62 @@ def test_failed_group_reservation_does_not_evict_a_partial_group() -> None:
227228
(QueryStatus.HIT, CacheTier.LOCAL_G2),
228229
(QueryStatus.HIT, CacheTier.LOCAL_G2),
229230
]
231+
retry = kvcr.deposit({grouped: [descriptors[0]]})
232+
assert dict(_poll_until(kvcr, lambda done: retry in dict(done)))[retry][
233+
grouped
234+
].success
230235

231236

232-
def test_group_allocation_evicts_enough_whole_keys() -> None:
233-
pools = [ctypes.create_string_buffer(16), ctypes.create_string_buffer(16)]
237+
def test_group_allocation_evicts_enough_whole_keys(monkeypatch) -> None:
238+
full = tuple(BlockKey(f"full{index}".encode()) for index in range(32))
239+
pools = [
240+
ctypes.create_string_buffer((len(full) + 1) * 8),
241+
ctypes.create_string_buffer(16),
242+
]
234243
source = ctypes.create_string_buffer(24)
235244
agent = FakeNixlAgent()
236245
agent.state = "DONE"
237246
kvcr = _two_pool_kvcr(agent, pools)
238-
full, swa0, swa1, grouped = (
239-
BlockKey(name) for name in (b"full", b"swa0", b"swa1", b"grouped")
240-
)
247+
swa0, swa1, grouped = (BlockKey(name) for name in (b"swa0", b"swa1", b"grouped"))
241248
descriptors = [
242249
_mem_descriptor(ctypes.addressof(source), 8, info="full"),
243250
_mem_descriptor(ctypes.addressof(source) + 8, 8, info="swa"),
244251
_mem_descriptor(ctypes.addressof(source) + 16, 8, info="swa"),
245252
]
246253

247-
for key, descriptor in zip((full, swa0, swa1), descriptors, strict=True):
248-
operation = kvcr.deposit({key: [descriptor]})
249-
_poll_until(kvcr, lambda done: operation in dict(done))
254+
operation = kvcr.deposit(
255+
{
256+
**{key: [descriptors[0]] for key in full},
257+
swa0: [descriptors[1]],
258+
swa1: [descriptors[2]],
259+
}
260+
)
261+
_poll_until(kvcr, lambda done: operation in dict(done))
262+
pops = Mock(wraps=heapq.heappop)
263+
monkeypatch.setattr(heapq, "heappop", pops)
250264
operation = kvcr.deposit({grouped: descriptors})
251265
result = dict(_poll_until(kvcr, lambda done: operation in dict(done)))[operation]
252266

253267
assert result[grouped].success
254-
assert kvcr.query((full, swa0, swa1, grouped)) == [
255-
(QueryStatus.HIT, CacheTier.LOCAL_G2),
268+
# The older full-only rows must be scanned once while making room in swa.
269+
assert pops.call_count <= len(full) + 2
270+
assert kvcr.query(full) == [(QueryStatus.HIT, CacheTier.LOCAL_G2)] * len(full)
271+
assert kvcr.query((swa0, swa1, grouped)) == [
256272
(QueryStatus.MISS, None),
257273
(QueryStatus.MISS, None),
258274
(QueryStatus.HIT, CacheTier.LOCAL_G2),
259275
]
260-
assert kvcr._core._local_dram.telemetry_state()["local_g2_evictable_slots"] == 4
276+
assert kvcr._core._local_dram.telemetry_state()["local_g2_evictable_slots"] == (
277+
len(full) + 3
278+
)
279+
retry = kvcr.deposit({swa0: [descriptors[0]]})
280+
assert dict(_poll_until(kvcr, lambda done: retry in dict(done)))[retry][
281+
swa0
282+
].success
283+
assert kvcr.query(full[:2]) == [
284+
(QueryStatus.MISS, None),
285+
(QueryStatus.HIT, CacheTier.LOCAL_G2),
286+
]
261287

262288

263289
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)