L3 3.0: CpL3Store orchestrator (async spill/reload, object-granular acks)
Ties config -> per-rank disk slabs + slot pools + shared LMDB index + slab accessors. Background spill/reload threads (off the scheduler tick, storage-template shape); object-granular acks (one per object after all owned pages durable; zero-owned ranks ack in lockstep) via ack queues + has_inflight, so the caller's CP-cpu-group MIN-drain frees the same objects on every rank. Spill = gather->O_DIRECT write->durable index (data->fdatasync->index-commit ordering) with content-hash dedup; reload = index lookup->read_into->scatter (verify-on-read). free_object frees owned slots+index (3.3 eviction consumes); clear() is the flush_cache hook (stop/drain/reset/restart). from_config splits the disk budget across ranks-on-disk x payloads (equal slot count). PLP gate at connect(). Model B: store is content-addressed + rank-local I/O; only the LMDB is shared. End-to-end test (spill/exists/evict/reload byte-exact/dedup/free/clear) 5/5 (venv lmdb). + accessor n_layers/page_num accessors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,14 @@ class CpSharedL2SlabAccessor:
|
||||
def page_blob_bytes(self) -> int:
|
||||
return self._lo.page_blob_bytes
|
||||
|
||||
@property
|
||||
def n_layers(self) -> int:
|
||||
return self._lo.n_layers
|
||||
|
||||
@property
|
||||
def page_num(self) -> int:
|
||||
return self._lo.page_num
|
||||
|
||||
def _slice_offset(self, layer: int, global_page: int) -> int:
|
||||
return layer * self._lo.layer_stride_bytes + global_page * self._lo.slice_bytes
|
||||
|
||||
|
||||
349
python/sglang/srt/mem_cache/cp_l3_store.py
Normal file
349
python/sglang/srt/mem_cache/cp_l3_store.py
Normal file
@@ -0,0 +1,349 @@
|
||||
# Copyright 2023-2024 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
"""CP HiCache L3 — the content-addressed disk store (orchestrator).
|
||||
|
||||
`CpL3Store` ties the pieces together for one CP rank: per-(payload,assigned-disk) ``CpL3DiskSlab`` + rank-local
|
||||
``CpL3SlotPool``, the shared per-node LMDB index, and the host-slab accessors. Spill/reload run on dedicated
|
||||
background threads (off the scheduler tick, mirroring the mainline storage template); completion is reported
|
||||
**object-granular** (one ack per object after ALL this rank's owned pages are durable; a zero-owned rank acks
|
||||
in lockstep so the cross-rank MIN-drain frees the same objects). The CP-cpu-group MIN over the ack-queue sizes
|
||||
is done by the caller (hiradix `_drain_l3_control_queues`) — this store just exposes the queues + the drain
|
||||
helpers + ``has_inflight`` (the replicated idle early-return).
|
||||
|
||||
Model B (R1): radix tracks RAM-residency; this store is the durable content-addressed tier (key = per-page
|
||||
content hash). They meet at spill (on radix eviction) + reload (on a radix miss). L3 eviction (3.3) rides the
|
||||
index's replicated clock. The store is rank-local for I/O (own disk); only the LMDB index is shared per node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sglang.srt.mem_cache.cp_l3_config import CpL3Config, probe_disk_plp
|
||||
from sglang.srt.mem_cache.cp_l3_disk import (
|
||||
CP_L3_HEADER_BYTES,
|
||||
compute_crc,
|
||||
content_hash_to_bytes,
|
||||
_l3_failfast,
|
||||
)
|
||||
from sglang.srt.mem_cache.cp_l3_index import CpL3IndexEntry, CpL3MetaStore
|
||||
from sglang.srt.mem_cache.cp_l3_posix import CpL3DiskSlab
|
||||
from sglang.srt.mem_cache.cp_l3_slab_accessor import CpSharedL2SlabAccessor
|
||||
|
||||
# (global_page, content_hash_hex) for one of this rank's owned pages of an object
|
||||
OwnedPage = Tuple[int, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class L3SpillOp:
|
||||
op_id: int
|
||||
object_key: str
|
||||
owned_pages: Dict[str, List[OwnedPage]] # payload_kind -> this rank's owned (slab_page, hash)
|
||||
last_access: int # replicated logical clock for the index entries
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not any(self.owned_pages.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class L3ReloadOp:
|
||||
op_id: int
|
||||
object_key: str
|
||||
owned_pages: Dict[str, List[OwnedPage]] # payload_kind -> this rank's owned (dest_slab_page, hash)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not any(self.owned_pages.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ack:
|
||||
op_id: int
|
||||
ok: bool
|
||||
|
||||
|
||||
class CpL3Store:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cp_rank: int,
|
||||
cp_size: int,
|
||||
disk_dir: str,
|
||||
disk_id: int,
|
||||
index: CpL3MetaStore,
|
||||
slabs: Dict[str, CpL3DiskSlab],
|
||||
pools, # Dict[str, CpL3SlotPool]
|
||||
accessors: Dict[str, CpSharedL2SlabAccessor],
|
||||
):
|
||||
self.cp_rank = cp_rank
|
||||
self.cp_size = cp_size
|
||||
self.disk_dir = disk_dir
|
||||
self.disk_id = disk_id
|
||||
self.index = index
|
||||
self.slabs = slabs
|
||||
self.pools = pools
|
||||
self.accessors = accessors
|
||||
self.payloads: Tuple[str, ...] = tuple(slabs.keys())
|
||||
|
||||
self._op_counter = 0
|
||||
self._spill_q: "queue.Queue[L3SpillOp]" = queue.Queue()
|
||||
self._reload_q: "queue.Queue[L3ReloadOp]" = queue.Queue()
|
||||
self._ack_spill_q: "queue.Queue[_Ack]" = queue.Queue()
|
||||
self._ack_reload_q: "queue.Queue[_Ack]" = queue.Queue()
|
||||
self._ongoing_spill: set = set()
|
||||
self._ongoing_reload: set = set()
|
||||
self._stop = threading.Event()
|
||||
self._spill_thread: Optional[threading.Thread] = None
|
||||
self._reload_thread: Optional[threading.Thread] = None
|
||||
self._started = False
|
||||
|
||||
# ---------- construction ----------
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
cfg: CpL3Config,
|
||||
*,
|
||||
cp_rank: int,
|
||||
cp_size: int,
|
||||
accessors: Dict[str, CpSharedL2SlabAccessor],
|
||||
) -> "CpL3Store":
|
||||
from sglang.srt.mem_cache.cp_l3_disk import CpL3SlotPool, slot_bytes_for_page
|
||||
|
||||
cfg.validate(cp_size)
|
||||
disk_id = cfg.disk_for_rank(cp_rank, cp_size)
|
||||
disk = cfg.disks[disk_id]
|
||||
# split this disk's budget across the ranks mapped to it (graceful sharing) and across payloads
|
||||
ranks_here = sum(1 for r in range(cp_size) if cfg.disk_for_rank(r, cp_size) == disk_id)
|
||||
per_rank_budget = disk.budget_bytes // max(1, ranks_here)
|
||||
payloads = tuple(accessors.keys())
|
||||
sum_slot_bytes = sum(slot_bytes_for_page(accessors[pk].page_blob_bytes) for pk in payloads)
|
||||
if sum_slot_bytes <= 0:
|
||||
_l3_failfast("no payloads / zero slot bytes")
|
||||
num_slots = max(1, per_rank_budget // sum_slot_bytes) # equal slot count across payloads
|
||||
os.makedirs(disk.path, exist_ok=True)
|
||||
slabs: Dict[str, CpL3DiskSlab] = {}
|
||||
pools = {}
|
||||
for pk in payloads:
|
||||
page_bytes = accessors[pk].page_blob_bytes
|
||||
path = os.path.join(disk.path, f"l3_r{cp_rank}_{pk}.slab")
|
||||
slabs[pk] = CpL3DiskSlab(path, pk, page_bytes, num_slots).open()
|
||||
pools[pk] = CpL3SlotPool(num_slots)
|
||||
index = CpL3MetaStore(cfg.index_dir, cfg.index_map_bytes, durable=True)
|
||||
return cls(
|
||||
cp_rank=cp_rank, cp_size=cp_size, disk_dir=disk.path, disk_id=disk_id,
|
||||
index=index, slabs=slabs, pools=pools, accessors=accessors,
|
||||
)
|
||||
|
||||
def connect(self, cfg: Optional[CpL3Config] = None) -> None:
|
||||
"""Start the bg threads. If cfg.require_plp, fail loud on a non-PLP disk (durability gate)."""
|
||||
if cfg is not None and cfg.require_plp:
|
||||
is_plp, median_us = probe_disk_plp(self.disk_dir)
|
||||
if not is_plp:
|
||||
_l3_failfast(
|
||||
f"L3 disk {self.disk_dir} is not power-loss-protected (fdatasync median "
|
||||
f"{median_us:.1f}us > threshold); durability is unsafe. Set require_plp=false only if "
|
||||
f"you accept data loss on power failure."
|
||||
)
|
||||
self._start_threads()
|
||||
|
||||
def _start_threads(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._spill_thread = threading.Thread(target=self._spill_loop, daemon=True, name="cp-l3-spill")
|
||||
self._reload_thread = threading.Thread(target=self._reload_loop, daemon=True, name="cp-l3-reload")
|
||||
self._spill_thread.start()
|
||||
self._reload_thread.start()
|
||||
self._started = True
|
||||
|
||||
# ---------- query (scheduler thread; lockless) ----------
|
||||
def exists_prefix(self, page_keys: Sequence[str], payload_kinds: Sequence[str]) -> int:
|
||||
keys = [content_hash_to_bytes(h) for h in page_keys]
|
||||
return self.index.exists_prefix(keys, payload_kinds)
|
||||
|
||||
# ---------- submit (scheduler thread; non-blocking) ----------
|
||||
def _next_op_id(self) -> int:
|
||||
self._op_counter += 1
|
||||
return self._op_counter
|
||||
|
||||
def submit_spill(self, object_key: str, owned_pages: Dict[str, List[OwnedPage]], last_access: int) -> int:
|
||||
op = L3SpillOp(self._next_op_id(), object_key, owned_pages, last_access)
|
||||
self._ongoing_spill.add(op.op_id)
|
||||
self._spill_q.put(op)
|
||||
return op.op_id
|
||||
|
||||
def submit_reload(self, object_key: str, owned_pages: Dict[str, List[OwnedPage]]) -> int:
|
||||
op = L3ReloadOp(self._next_op_id(), object_key, owned_pages)
|
||||
self._ongoing_reload.add(op.op_id)
|
||||
self._reload_q.put(op)
|
||||
return op.op_id
|
||||
|
||||
def has_inflight(self) -> bool:
|
||||
return bool(self._ongoing_spill) or bool(self._ongoing_reload)
|
||||
|
||||
# ---------- completion harvest (scheduler thread; caller does the CP-group MIN) ----------
|
||||
def ack_spill_qsize(self) -> int:
|
||||
return self._ack_spill_q.qsize()
|
||||
|
||||
def ack_reload_qsize(self) -> int:
|
||||
return self._ack_reload_q.qsize()
|
||||
|
||||
def drain_spill_acks(self, n: int) -> List[Tuple[int, bool]]:
|
||||
out = []
|
||||
for _ in range(n):
|
||||
try:
|
||||
ack = self._ack_spill_q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._ongoing_spill.discard(ack.op_id)
|
||||
out.append((ack.op_id, ack.ok))
|
||||
return out
|
||||
|
||||
def drain_reload_acks(self, n: int) -> List[Tuple[int, bool]]:
|
||||
out = []
|
||||
for _ in range(n):
|
||||
try:
|
||||
ack = self._ack_reload_q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._ongoing_reload.discard(ack.op_id)
|
||||
out.append((ack.op_id, ack.ok))
|
||||
return out
|
||||
|
||||
# ---------- background workers ----------
|
||||
def _spill_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
op = self._spill_q.get(block=True, timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
ok = True
|
||||
try:
|
||||
self._spill_object(op)
|
||||
except Exception as exc: # never kill the thread; report failure -> caller keeps L2 (no defer-free)
|
||||
ok = False
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error("L3 spill op %s failed: %r", op.op_id, exc)
|
||||
self._ack_spill_q.put(_Ack(op.op_id, ok))
|
||||
|
||||
def _reload_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
op = self._reload_q.get(block=True, timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
ok = True
|
||||
try:
|
||||
ok = self._reload_object(op)
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error("L3 reload op %s failed: %r", op.op_id, exc)
|
||||
self._ack_reload_q.put(_Ack(op.op_id, ok))
|
||||
|
||||
def _spill_object(self, op: L3SpillOp) -> None:
|
||||
"""Gather+write this rank's owned pages of the object, then durably commit their index entries.
|
||||
Zero-owned ranks no-op (the ack alone keeps the MIN-drain in lockstep)."""
|
||||
touched: set = set()
|
||||
with self.index.write_batch() as wb:
|
||||
for pk, pages in op.owned_pages.items():
|
||||
if not pages:
|
||||
continue
|
||||
slab, pool, accessor = self.slabs[pk], self.pools[pk], self.accessors[pk]
|
||||
for (slab_page, h_hex) in pages:
|
||||
h = content_hash_to_bytes(h_hex)
|
||||
if self.index.exists(h, pk):
|
||||
continue # content-hash dedup: already durable in L3
|
||||
slot = pool.alloc()
|
||||
if slot < 0:
|
||||
_l3_failfast(
|
||||
f"L3 {pk} slab full on rank {self.cp_rank} (evict-to-fit must run before spill)"
|
||||
)
|
||||
buf = slab.new_aligned_buffer()
|
||||
try:
|
||||
accessor.gather_into(slab_page, buf, CP_L3_HEADER_BYTES)
|
||||
crc = compute_crc(memoryview(buf)[CP_L3_HEADER_BYTES:CP_L3_HEADER_BYTES + slab.page_bytes])
|
||||
slab.fill_header(buf, h, accessor.n_layers, crc)
|
||||
slab.write_aligned(slot, buf)
|
||||
finally:
|
||||
buf.close()
|
||||
wb.put(h, pk, CpL3IndexEntry(
|
||||
disk_id=self.disk_id, file_id=0, slot_idx=slot, page_bytes=slab.page_bytes,
|
||||
crc=crc, last_access=op.last_access, hit_count=0,
|
||||
))
|
||||
touched.add(pk)
|
||||
# data -> fdatasync (durable) -> index commit (on __exit__, durable) : the crash-safe ordering
|
||||
for pk in touched:
|
||||
self.slabs[pk].fdatasync()
|
||||
# (write_batch __exit__ committed the index durably here)
|
||||
|
||||
def _reload_object(self, op: L3ReloadOp) -> bool:
|
||||
"""Read this rank's owned pages from L3 and scatter into the reserved L2 dest pages. Returns False if
|
||||
any owned page is absent/corrupt (the prefix is then not fully reloadable on this rank)."""
|
||||
ok = True
|
||||
for pk, pages in op.owned_pages.items():
|
||||
if not pages:
|
||||
continue
|
||||
slab, accessor = self.slabs[pk], self.accessors[pk]
|
||||
for (dest_page, h_hex) in pages:
|
||||
h = content_hash_to_bytes(h_hex)
|
||||
entry = self.index.get(h, pk)
|
||||
if entry is None:
|
||||
ok = False
|
||||
continue
|
||||
buf = slab.new_aligned_buffer()
|
||||
try:
|
||||
if slab.read_into(entry.slot_idx, buf, expect_hash=h):
|
||||
accessor.scatter_from(dest_page, buf, CP_L3_HEADER_BYTES)
|
||||
else:
|
||||
ok = False
|
||||
finally:
|
||||
buf.close()
|
||||
return ok
|
||||
|
||||
# ---------- L3 eviction support (3.3 consumes; here = the rank-local free) ----------
|
||||
def free_object(self, owned_pages: Dict[str, List[OwnedPage]]) -> None:
|
||||
"""Free this rank's owned disk slots + index entries for an evicted object (selection is replicated
|
||||
by the caller via the index's replicated clock)."""
|
||||
with self.index.write_batch() as wb:
|
||||
for pk, pages in owned_pages.items():
|
||||
for (_page, h_hex) in pages:
|
||||
h = content_hash_to_bytes(h_hex)
|
||||
entry = self.index.get(h, pk)
|
||||
if entry is not None:
|
||||
self.pools[pk].free(entry.slot_idx)
|
||||
wb.delete(h, pk)
|
||||
|
||||
# ---------- lifecycle ----------
|
||||
def clear(self) -> None:
|
||||
"""flush_cache hook: stop+drain threads, reset slot pools + index, restart. Idle-gated by the caller."""
|
||||
self._stop_threads()
|
||||
for q in (self._spill_q, self._reload_q, self._ack_spill_q, self._ack_reload_q):
|
||||
with q.mutex:
|
||||
q.queue.clear()
|
||||
self._ongoing_spill.clear()
|
||||
self._ongoing_reload.clear()
|
||||
for pool in self.pools.values():
|
||||
pool.reset()
|
||||
self.index.clear()
|
||||
self._start_threads()
|
||||
|
||||
def _stop_threads(self) -> None:
|
||||
if not self._started:
|
||||
return
|
||||
self._stop.set()
|
||||
for t in (self._spill_thread, self._reload_thread):
|
||||
if t is not None:
|
||||
t.join(timeout=3)
|
||||
self._started = False
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop_threads()
|
||||
for slab in self.slabs.values():
|
||||
slab.close()
|
||||
self.index.close()
|
||||
167
test/registered/unit/mem_cache/test_cp_l3_store.py
Normal file
167
test/registered/unit/mem_cache/test_cp_l3_store.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""End-to-end unit test for the CP HiCache L3 store (cp_l3_store.CpL3Store), single rank (cp_size=1).
|
||||
|
||||
Spill -> ack drain -> exists_prefix -> simulate L2 eviction (zero slab pages) -> reload (byte-exact) ->
|
||||
free_object -> clear. Requires lmdb (run with the l3venv). Uses a synthetic host slab + temp-dir config.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import mmap
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_MEM = _REPO_ROOT / "python" / "sglang" / "srt" / "mem_cache"
|
||||
|
||||
|
||||
def _load_file(module_name, path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_l3():
|
||||
for name, pkg_path in (
|
||||
("sglang", _REPO_ROOT / "python" / "sglang"),
|
||||
("sglang.srt", _REPO_ROOT / "python" / "sglang" / "srt"),
|
||||
("sglang.srt.mem_cache", _MEM),
|
||||
):
|
||||
if name not in sys.modules:
|
||||
mod = types.ModuleType(name)
|
||||
mod.__path__ = [str(pkg_path)]
|
||||
sys.modules[name] = mod
|
||||
for mod in ("cp_l3_disk", "cp_l3_config", "cp_l3_index", "cp_l3_posix", "cp_l3_slab_accessor", "cp_l3_store"):
|
||||
_load_file(f"sglang.srt.mem_cache.{mod}", _MEM / f"{mod}.py")
|
||||
return (
|
||||
sys.modules["sglang.srt.mem_cache.cp_l3_store"],
|
||||
sys.modules["sglang.srt.mem_cache.cp_l3_config"],
|
||||
sys.modules["sglang.srt.mem_cache.cp_l3_slab_accessor"],
|
||||
)
|
||||
|
||||
|
||||
store_mod, cfg_mod, acc_mod = _load_l3()
|
||||
|
||||
N_LAYERS, PAGE_NUM, SLICE = 3, 8, 16
|
||||
|
||||
|
||||
def _h(i):
|
||||
return f"{i:064x}" # 64 hex chars == 32 bytes
|
||||
|
||||
|
||||
def _pattern(layer, page):
|
||||
return bytes(((layer * 41 + page * 13 + k) & 0xFF) for k in range(SLICE))
|
||||
|
||||
|
||||
def _make_slab_and_accessor():
|
||||
lo = acc_mod.CpL3SlabLayout(n_layers=N_LAYERS, page_num=PAGE_NUM, slice_bytes=SLICE)
|
||||
mm = mmap.mmap(-1, lo.total_bytes)
|
||||
for layer in range(N_LAYERS):
|
||||
for page in range(PAGE_NUM):
|
||||
off = layer * lo.layer_stride_bytes + page * SLICE
|
||||
mm[off:off + SLICE] = _pattern(layer, page)
|
||||
return mm, acc_mod.CpSharedL2SlabAccessor(mm, lo)
|
||||
|
||||
|
||||
def _wait_ack(qsize_fn, n=1, timeout=5.0):
|
||||
t0 = time.time()
|
||||
while qsize_fn() < n and time.time() - t0 < timeout:
|
||||
time.sleep(0.005)
|
||||
return qsize_fn() >= n
|
||||
|
||||
|
||||
class TestCpL3Store(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.mm, self.acc = _make_slab_and_accessor()
|
||||
cfg = cfg_mod.CpL3Config.from_dict({
|
||||
"backend": "posix",
|
||||
"require_plp": False, # temp FS / buffered
|
||||
"index_map_gb": 0.05,
|
||||
"disks": [{"path": os.path.join(self._td.name, "disk0"), "budget_gb": 0.02}],
|
||||
})
|
||||
self.store = store_mod.CpL3Store.from_config(
|
||||
cfg, cp_rank=0, cp_size=1, accessors={"target_kv": self.acc})
|
||||
self.store.connect(cfg)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self._td.cleanup()
|
||||
|
||||
def test_spill_exists_reload_roundtrip(self):
|
||||
# spill pages 2 and 5
|
||||
pages = {"target_kv": [(2, _h(2)), (5, _h(5))]}
|
||||
orig2 = self.acc.gather(2)
|
||||
orig5 = self.acc.gather(5)
|
||||
self.store.submit_spill("obj-A", pages, last_access=10)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
acks = self.store.drain_spill_acks(self.store.ack_spill_qsize())
|
||||
self.assertEqual(acks, [(1, True)])
|
||||
self.assertFalse(self.store.has_inflight())
|
||||
|
||||
# exists_prefix: consecutive present count
|
||||
self.assertEqual(self.store.exists_prefix([_h(2), _h(5)], ["target_kv"]), 2)
|
||||
self.assertEqual(self.store.exists_prefix([_h(2), _h(99)], ["target_kv"]), 1)
|
||||
self.assertEqual(self.store.exists_prefix([_h(99)], ["target_kv"]), 0)
|
||||
|
||||
# simulate L2 eviction: zero out pages 2 and 5 in the slab
|
||||
for p in (2, 5):
|
||||
for layer in range(N_LAYERS):
|
||||
off = layer * (PAGE_NUM * SLICE) + p * SLICE
|
||||
self.mm[off:off + SLICE] = bytes(SLICE)
|
||||
self.assertNotEqual(self.acc.gather(2), orig2)
|
||||
|
||||
# reload from L3 -> scatter back into the slab
|
||||
self.store.submit_reload("obj-A", pages)
|
||||
self.assertTrue(_wait_ack(self.store.ack_reload_qsize, 1))
|
||||
racks = self.store.drain_reload_acks(self.store.ack_reload_qsize())
|
||||
self.assertEqual(racks, [(2, True)])
|
||||
self.assertEqual(self.acc.gather(2), orig2) # byte-exact round-trip through disk
|
||||
self.assertEqual(self.acc.gather(5), orig5)
|
||||
|
||||
def test_zero_owned_spill_acks_in_lockstep(self):
|
||||
# a rank that owns no pages of the object still must ack (empty op)
|
||||
op_id = self.store.submit_spill("obj-empty", {"target_kv": []}, last_access=1)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
self.assertEqual(self.store.drain_spill_acks(1), [(op_id, True)])
|
||||
|
||||
def test_dedup_skip_existing(self):
|
||||
pages = {"target_kv": [(3, _h(3))]}
|
||||
self.store.submit_spill("o1", pages, last_access=1)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
self.store.drain_spill_acks(1)
|
||||
free_after_first = self.store.pools["target_kv"].num_free
|
||||
# spill the SAME hash again -> dedup, no new slot consumed
|
||||
self.store.submit_spill("o1b", pages, last_access=2)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
self.store.drain_spill_acks(1)
|
||||
self.assertEqual(self.store.pools["target_kv"].num_free, free_after_first)
|
||||
|
||||
def test_free_object_releases_slots_and_index(self):
|
||||
pages = {"target_kv": [(1, _h(1)), (4, _h(4))]}
|
||||
before = self.store.pools["target_kv"].num_free
|
||||
self.store.submit_spill("o", pages, last_access=1)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
self.store.drain_spill_acks(1)
|
||||
self.assertEqual(self.store.pools["target_kv"].num_free, before - 2)
|
||||
self.store.free_object(pages)
|
||||
self.assertEqual(self.store.pools["target_kv"].num_free, before)
|
||||
self.assertEqual(self.store.exists_prefix([_h(1)], ["target_kv"]), 0)
|
||||
|
||||
def test_clear_resets(self):
|
||||
self.store.submit_spill("o", {"target_kv": [(0, _h(0))]}, last_access=1)
|
||||
self.assertTrue(_wait_ack(self.store.ack_spill_qsize, 1))
|
||||
self.store.drain_spill_acks(1)
|
||||
self.store.clear()
|
||||
self.assertEqual(self.store.exists_prefix([_h(0)], ["target_kv"]), 0)
|
||||
self.assertEqual(self.store.pools["target_kv"].num_free, self.store.pools["target_kv"].num_slots)
|
||||
self.assertFalse(self.store.has_inflight())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user