Files
2026-07-13 12:24:33 +08:00

1298 lines
41 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for PDBackendAsync (async PD sender/receiver).
No NIXL, CUDA, or real ZMQ peers required — all I/O is mocked with
asyncio.sleep stubs so tests run fast (< 1 s total) in CI. Assertions
focus on timing and call ordering; data integrity is covered separately
by the NIXL integration tests.
"""
# Standard
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import asyncio
import itertools
import threading
import time
# Third Party
import msgspec
import pytest
import torch
# First Party
from lmcache.utils import CacheEngineKey
from lmcache.v1.memory_management import MemoryFormat, MemoryObj
from lmcache.v1.storage_backend.pd_backend import AllocRequest as SyncAllocRequest
from lmcache.v1.storage_backend.pd_backend import AllocResponse as SyncAllocResponse
from lmcache.v1.storage_backend.pd_backend import PDMsg as SyncPDMsg
from lmcache.v1.storage_backend.pd_backend_async import (
AllocRequest as AsyncAllocRequest,
)
from lmcache.v1.storage_backend.pd_backend_async import (
AllocResponse as AsyncAllocResponse,
)
from lmcache.v1.storage_backend.pd_backend_async import (
CancelNotif,
PDBackendAsync,
)
from lmcache.v1.storage_backend.pd_backend_async import PDMsg as AsyncPDMsg
from lmcache.v1.storage_backend.pd_backend_async import (
ProxyNotif,
)
TRANSFER_DELAY = 0.15
NONBLOCKING_THRESHOLD_RATIO = 0.25
CI_SERIAL_TIMEOUT_MARGIN = 3
_DEFAULT_SHAPE = [4, 2, 16, 8, 128]
def _make_key(i: int) -> CacheEngineKey:
return CacheEngineKey(
model_name="test",
world_size=1,
worker_id=0,
chunk_hash=i,
dtype=torch.bfloat16,
)
def _make_mem_obj(idx: int = 0) -> MemoryObj:
obj = MagicMock(spec=MemoryObj)
obj.meta = SimpleNamespace(
address=idx,
fmt=MemoryFormat.KV_2LTD,
shape=torch.Size(_DEFAULT_SHAPE),
dtype=torch.bfloat16,
)
obj._ref_count = 1
def _ref_up():
obj._ref_count += 1
def _ref_down():
obj._ref_count -= 1
def _get_ref_count():
return obj._ref_count
obj.ref_count_up.side_effect = _ref_up
obj.ref_count_down.side_effect = _ref_down
obj.get_ref_count.side_effect = _get_ref_count
return obj
def _make_transfer_spec(
receiver_host="127.0.0.1",
init_port=9100,
alloc_port=9101,
req_id="req-0",
is_last_prefill=True,
num_transferred_tokens=0,
total_chunks=0,
):
return SimpleNamespace(
receiver_host=receiver_host,
receiver_init_port=[init_port],
receiver_alloc_port=[alloc_port],
req_id=req_id,
is_last_prefill=is_last_prefill,
num_transferred_tokens=num_transferred_tokens,
total_chunks=total_chunks,
)
def _make_alloc_req(
keys,
last_chunk_toks=16,
req_id="",
is_last_batch=False,
shape=None,
total_chunks=0,
):
return AsyncAllocRequest(
keys=[k.to_string() for k in keys],
fmt=MemoryFormat.KV_2LTD.value,
shape=list(shape or _DEFAULT_SHAPE),
dtype="bfloat16",
last_chunk_toks=last_chunk_toks,
req_id=req_id,
is_last_batch=is_last_batch,
total_chunks=total_chunks,
)
def _auto_alloc():
"""Allocator that returns a fresh MemoryObj stub on every call."""
c = itertools.count()
def alloc(shapes, dtype, fmt=MemoryFormat.KV_2LTD, **kw):
return _make_mem_obj(idx=next(c))
return alloc
def _pd_backend_patches():
return (
patch(
"lmcache.v1.storage_backend.pd_backend_async.get_zmq_context",
return_value=MagicMock(),
),
patch(
"lmcache.v1.storage_backend.pd_backend_async.CreateTransferChannel",
return_value=MagicMock(),
),
patch(
"lmcache.v1.storage_backend.pd_backend_async.get_correct_device",
return_value="cpu",
),
)
@contextmanager
def _patched_pd():
p1, p2, p3 = _pd_backend_patches()
with p1, p2, p3:
yield
# ── fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
def async_sender():
p1, p2, p3 = _pd_backend_patches()
with p1, p2 as mock_create_tc, p3:
alloc_socket = MagicMock()
alloc_response = AsyncAllocResponse(remote_indexes=[0])
alloc_socket.recv_multipart = AsyncMock(
return_value=[b"", msgspec.msgpack.encode(alloc_response)]
)
alloc_socket.send_multipart = AsyncMock()
tc = MagicMock()
async def _slow_write(*a, **kw):
await asyncio.sleep(TRANSFER_DELAY)
return 1
tc.async_batched_write = _slow_write
mock_create_tc.return_value = tc
# First Party
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.metadata import LMCacheMetadata
config = LMCacheEngineConfig.from_defaults(
chunk_size=16,
pd_role="sender",
pd_proxy_host="127.0.0.1",
pd_proxy_port=5555,
pd_buffer_size=64 * 1024 * 1024,
pd_buffer_device="cpu",
)
metadata = LMCacheMetadata(
model_name="test",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 16, 8, 128),
)
backend = PDBackendAsync(config, metadata)
# Override async proxy socket with a mock so tests don't need a real ZMQ
# connection. _init_sender() already ran on the sender loop; we replace
# the socket here before any tests send ProxyNotifs.
backend._async_proxy_socket = AsyncMock()
receiver_id = "127.0.0.1" + str(9100)
backend.initialized_peers.add(receiver_id)
backend._async_alloc_sockets[receiver_id] = alloc_socket
yield backend
backend.close()
@pytest.fixture
def async_receiver():
p1, p2, p3 = _pd_backend_patches()
with p1, p2, p3:
# First Party
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.metadata import LMCacheMetadata
config = LMCacheEngineConfig.from_defaults(
chunk_size=16,
pd_role="receiver",
pd_peer_host="127.0.0.1",
pd_peer_init_port=[9200],
pd_peer_alloc_port=[9201],
pd_buffer_size=64 * 1024 * 1024,
pd_buffer_device="cpu",
)
metadata = LMCacheMetadata(
model_name="test",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 16, 8, 128),
)
backend = PDBackendAsync(config, metadata)
yield backend
for mem_obj in backend.data.values():
try:
mem_obj.ref_count_down()
except Exception:
pass
backend.close()
# ── sender tests ──────────────────────────────────────────────────────────
def test_sender_nonblocking_fifo_transfers(async_sender):
"""batched_submit_put_task returns immediately; all requests complete."""
N = 4
done_events = [threading.Event() for _ in range(N)]
def make_cb(i):
def cb(key):
done_events[i].set()
return cb
t0 = time.monotonic()
for i in range(N):
async_sender.batched_submit_put_task(
[_make_key(i)],
[_make_mem_obj(i)],
transfer_spec=_make_transfer_spec(req_id=f"req-{i}"),
on_complete_callback=make_cb(i),
)
enqueue_elapsed = time.monotonic() - t0
assert enqueue_elapsed < TRANSFER_DELAY * NONBLOCKING_THRESHOLD_RATIO
timeout = TRANSFER_DELAY * N * CI_SERIAL_TIMEOUT_MARGIN
for i, ev in enumerate(done_events):
assert ev.wait(timeout=timeout), f"req-{i} did not complete"
def test_sender_flow_control_backpressure(async_sender):
"""allocate() blocks when staging buffer is physically full, unblocks on notify."""
sentinel = _make_mem_obj(idx=77)
should_block = [True]
def alloc_fn(*args, **kw):
if should_block[0]:
return None
return sentinel
async_sender.memory_allocator.allocate = alloc_fn
result = []
blocked = threading.Event()
unblocked = threading.Event()
def worker():
blocked.set()
result.append(async_sender.allocate(torch.Size(_DEFAULT_SHAPE), torch.bfloat16))
unblocked.set()
t = threading.Thread(target=worker, daemon=True)
t.start()
assert blocked.wait(timeout=2.0)
time.sleep(0.1)
assert not unblocked.is_set(), "allocate() should be blocked"
# Make allocator return sentinel and wake the condition.
should_block[0] = False
async_sender._notify_staging_freed()
assert unblocked.wait(timeout=2.0)
assert result[0] is sentinel
t.join(timeout=1.0)
def test_sender_chunk_ordering(async_sender):
"""Last prefill chunk waits for prior slow chunk before sending ProxyNotif."""
SLOW, FAST = 0.30, 0.05
REQ_ID = "req-chunked"
# total_chunks=2 is passed via transfer_spec so the sender loop initializes
# per-request tracking internally (try_admit is not called from test code
# because it belongs to the sender loop, not caller threads).
call_count = 0
call_lock = threading.Lock()
async def controlled_write(*a, **kw):
nonlocal call_count
with call_lock:
call_count += 1
idx = call_count
await asyncio.sleep(SLOW if idx == 1 else FAST)
return 1
async_sender.transfer_channel.async_batched_write = controlled_write
notify_times = []
sent_data = []
async def record_send(data):
notify_times.append(time.monotonic())
sent_data.append(data)
async_sender._async_proxy_socket.send = record_send
async_sender.batched_submit_put_task(
[_make_key(0)],
[_make_mem_obj(0)],
transfer_spec=_make_transfer_spec(
req_id=REQ_ID, is_last_prefill=False, total_chunks=2
),
)
time.sleep(0.01)
done = threading.Event()
async_sender.batched_submit_put_task(
[_make_key(1)],
[_make_mem_obj(1)],
transfer_spec=_make_transfer_spec(
req_id=REQ_ID, is_last_prefill=True, total_chunks=2
),
on_complete_callback=lambda k: done.set(),
)
t_submit = time.monotonic()
assert done.wait(timeout=SLOW * 3)
# Wait for ProxyNotif to be sent (fires after the slow batch 1 completes).
timeout_end = time.monotonic() + SLOW * 2
while not notify_times and time.monotonic() < timeout_end:
time.sleep(0.01)
assert len(notify_times) == 1
notif = msgspec.msgpack.decode(sent_data[0], type=AsyncPDMsg)
assert isinstance(notif, ProxyNotif) and notif.req_id == REQ_ID
elapsed = notify_times[0] - t_submit
assert elapsed >= SLOW * 0.8, (
f"ProxyNotif too early ({elapsed:.3f}s) — fast chunk didn't wait for slow"
)
def test_sender_per_receiver_concurrency(async_sender):
"""Different-receiver requests run concurrently."""
SLOW, FAST = 0.25, 0.05
recv1_id = "127.0.0.1" + str(9100)
recv2_id = "127.0.0.1" + str(9200)
sock2 = MagicMock()
sock2.recv_multipart = AsyncMock(
return_value=[
b"",
msgspec.msgpack.encode(AsyncAllocResponse(remote_indexes=[0])),
]
)
sock2.send_multipart = AsyncMock()
async_sender.initialized_peers.add(recv2_id)
async_sender._async_alloc_sockets[recv2_id] = sock2
delays = {recv1_id: SLOW, recv2_id: FAST}
orig_transfer = async_sender._async_transfer_task
async def patched_transfer(**kw):
rid = kw.get("receiver_id", "")
await asyncio.sleep(delays.get(rid, FAST))
cb = kw.get("on_complete_callback")
for key in kw.get("keys", []):
if cb:
try:
cb(key)
except Exception:
pass
async_sender._notify_staging_freed()
async_sender._async_transfer_task = patched_transfer
events = {"A": threading.Event(), "B": threading.Event(), "C": threading.Event()}
times = {}
lock = threading.Lock()
def cb(name):
def f(key):
with lock:
times[name] = time.monotonic()
events[name].set()
return f
async_sender.batched_submit_put_task(
[_make_key(10)],
[_make_mem_obj(10)],
transfer_spec=_make_transfer_spec(
init_port=9100, alloc_port=9101, req_id="A", is_last_prefill=True
),
on_complete_callback=cb("A"),
)
async_sender.batched_submit_put_task(
[_make_key(20)],
[_make_mem_obj(20)],
transfer_spec=_make_transfer_spec(
init_port=9200, alloc_port=9201, req_id="B", is_last_prefill=True
),
on_complete_callback=cb("B"),
)
async_sender.batched_submit_put_task(
[_make_key(30)],
[_make_mem_obj(30)],
transfer_spec=_make_transfer_spec(
init_port=9100, alloc_port=9101, req_id="C", is_last_prefill=True
),
on_complete_callback=cb("C"),
)
for name, ev in events.items():
assert ev.wait(timeout=SLOW * 6), f"{name} timed out"
assert times["B"] < times["A"], "B (fast recv2) should finish before A (slow recv1)"
async_sender._async_transfer_task = orig_transfer
# ── receiver tests ────────────────────────────────────────────────────────
def test_receiver_nonblocking_async_sleep(async_receiver):
"""Busy-wait retries yield via asyncio.sleep, not time.sleep."""
RETRY_COUNT = 5
TOKS_A, TOKS_B = 16, 8
key_a, key_b = _make_key(100), _make_key(200)
obj_a, obj_b = _make_mem_obj(idx=10), _make_mem_obj(idx=20)
finish_order = []
orig_put = async_receiver.put
def tracked_put(key, mem_obj):
if key == key_a:
finish_order.append("a")
elif key == key_b:
finish_order.append("b")
return orig_put(key, mem_obj)
async_receiver.put = tracked_put
calls = {}
def patched_alloc(shapes, dtype, fmt=MemoryFormat.KV_2LTD, **kw):
tok_dim = MemoryFormat.KV_2LTD.token_dim()
toks = shapes[tok_dim] if isinstance(shapes, torch.Size) else shapes[tok_dim]
calls[toks] = calls.get(toks, 0) + 1
if toks == TOKS_A and calls[toks] <= RETRY_COUNT:
return None
return obj_a if toks == TOKS_A else obj_b
async_receiver.allocate = patched_alloc
req_a = _make_alloc_req(
[key_a], last_chunk_toks=TOKS_A, shape=[4, 2, TOKS_A, 8, 128]
)
req_b = _make_alloc_req(
[key_b], last_chunk_toks=TOKS_B, shape=[4, 2, TOKS_B, 8, 128]
)
async def _run():
await asyncio.gather(
async_receiver._async_allocate_and_put(req_a),
async_receiver._async_allocate_and_put(req_b),
)
asyncio.run(_run())
assert finish_order == ["b", "a"], (
f"Got {finish_order}, busy-wait likely uses time.sleep instead of asyncio.sleep"
)
def test_receiver_flow_control_inflight(async_receiver):
"""Allocation blocks when memory is full, unblocks when remove() frees a chunk."""
mem_obj = _make_mem_obj(idx=60)
freed = [False] # Controls when allocate succeeds
def patched_alloc(shapes, dtype, fmt=MemoryFormat.KV_2LTD, **kw):
return mem_obj if freed[0] else None
async_receiver.allocate = patched_alloc
alloc_req = _make_alloc_req([_make_key(600)])
# Wait longer than several poll intervals so condition-timeout retries
# still return None (freed[0] is still False).
block_duration = async_receiver._condition_poll_interval * 5
async def run():
completed = asyncio.Event()
holder = []
async def do_alloc():
holder.append(await async_receiver._async_allocate_and_put(alloc_req))
completed.set()
async def free_later():
await asyncio.sleep(block_duration)
assert not completed.is_set(), "should still be blocked"
freed[0] = True
# Notify the alloc freed condition to wake the blocked coroutine.
async with async_receiver._alloc_freed_condition:
async_receiver._alloc_freed_condition.notify_all()
await asyncio.gather(do_alloc(), free_later())
assert holder[0].remote_indexes == [mem_obj.meta.address]
asyncio.run(run())
def test_receiver_last_chunk_shape_override(async_receiver):
"""Last chunk's token dim is overridden to last_chunk_toks."""
mem_obj = _make_mem_obj(idx=30)
LAST_TOKS = 7
shapes_seen = []
def tracking_alloc(shapes, dtype, fmt=MemoryFormat.KV_2LTD, **kw):
shapes_seen.append(shapes)
return mem_obj
async_receiver.allocate = tracking_alloc
keys = [_make_key(300), _make_key(301), _make_key(302)]
asyncio.run(
async_receiver._async_allocate_and_put(
_make_alloc_req(keys, last_chunk_toks=LAST_TOKS)
)
)
assert len(shapes_seen) == 3
tok_dim = MemoryFormat.KV_2LTD.token_dim()
assert shapes_seen[-1][tok_dim] == LAST_TOKS
@pytest.mark.parametrize(
"total_declared, b1_n, b2_n",
[(5, 5, 1), (4, 3, 2)],
ids=["exact-then-overflow", "partial-then-overflow"],
)
def test_receiver_fail_fast_overflow(async_receiver, total_declared, b1_n, b2_n):
"""
Cumulative chunks > declared total_chunks → RuntimeError;
all keys rolled back.
"""
async_receiver.allocate = _auto_alloc()
req_id = "req-failfast"
b1_keys = [_make_key(i) for i in range(b1_n)]
async def run():
r1 = await async_receiver._async_allocate_and_put(
_make_alloc_req(b1_keys, req_id=req_id, total_chunks=total_declared)
)
assert -1 not in r1.remote_indexes and len(r1.remote_indexes) == b1_n
with pytest.raises(RuntimeError, match="total_chunks"):
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(5000 + i) for i in range(b2_n)],
req_id=req_id,
total_chunks=total_declared,
)
)
asyncio.run(run())
# Fail-fast path rolls back prior batch keys too.
for k in b1_keys:
assert not async_receiver.contains(k, pin=False)
# unrelated req_id should work fine
async def check_other():
r = await async_receiver._async_allocate_and_put(
_make_alloc_req([_make_key(20000)], req_id="req-other", total_chunks=1)
)
assert -1 not in r.remote_indexes
asyncio.run(check_other())
def test_receiver_alloc_timeout(async_receiver):
"""
allocate() returning None past deadline → RuntimeError;
prior batches rolled back.
"""
req_id = "req-timeout"
async_receiver.allocate = _auto_alloc()
b1_keys = [_make_key(1000 + i) for i in range(3)]
r1 = asyncio.run(
async_receiver._async_allocate_and_put(
_make_alloc_req(b1_keys, req_id=req_id, total_chunks=6)
)
)
assert -1 not in r1.remote_indexes
# second batch: first key ok, rest always None
n = [0]
def fail_after_first(shapes, dtype, fmt=MemoryFormat.KV_2LTD, **kw):
n[0] += 1
return _make_mem_obj(idx=999) if n[0] == 1 else None
async_receiver.allocate = fail_after_first
async_receiver._allocation_timeout = 0.05
async def run():
with pytest.raises(RuntimeError, match="timeout"):
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(2000 + i) for i in range(3)],
req_id=req_id,
total_chunks=6,
)
)
asyncio.run(run())
# New design rolls back prior batches on failure.
for k in b1_keys:
assert not async_receiver.contains(k, pin=False)
def test_receiver_is_last_batch_cleanup(async_receiver):
"""is_last_batch=True removes req_id from _req_allocated_keys."""
req_id = "req-lifecycle"
async_receiver.allocate = _auto_alloc()
asyncio.run(
async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(3000 + i) for i in range(3)],
req_id=req_id,
is_last_batch=False,
total_chunks=5,
)
)
)
assert req_id in async_receiver._req_allocated_keys
assert len(async_receiver._req_allocated_keys[req_id]) == 3
asyncio.run(
async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(4000 + i) for i in range(2)],
req_id=req_id,
is_last_batch=True,
total_chunks=5,
)
)
)
assert req_id not in async_receiver._req_allocated_keys
def test_receiver_admission_control(async_receiver):
"""Reservation-based admission: concurrent requests can proceed."""
async_receiver.allocate = _auto_alloc()
log = []
async def run():
log.append("A1-start")
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(7000 + i) for i in range(2)],
req_id="req-A",
is_last_batch=False,
total_chunks=3,
)
)
log.append("A1-done")
async def do_b():
log.append("B-start")
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(8000)],
req_id="req-B",
is_last_batch=True,
total_chunks=1,
)
)
log.append("B-done")
async def do_a2():
await asyncio.sleep(0.02)
log.append("A2-start")
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(9000)],
req_id="req-A",
is_last_batch=True,
total_chunks=3,
)
)
log.append("A2-done")
await asyncio.gather(do_b(), do_a2())
asyncio.run(run())
# With reservation-based admission, A2 and B can proceed concurrently.
# All should complete successfully.
assert "A1-done" in log
assert "A2-done" in log
assert "B-done" in log
def test_receiver_error_response(async_receiver):
"""_handle_alloc_request sends error AllocResponse when allocation fails."""
payload = msgspec.msgpack.encode(_make_alloc_req([_make_key(0)], req_id="req-err"))
identity = b"fake-sender"
frames_sent = []
sock = MagicMock()
sock.send_multipart = AsyncMock(side_effect=lambda f: frames_sent.append(f))
orig = async_receiver._async_allocate_and_put
async def failing(req):
raise RuntimeError("boom")
async_receiver._async_allocate_and_put = failing
asyncio.run(async_receiver._handle_alloc_request(sock, identity, payload))
async_receiver._async_allocate_and_put = orig
assert len(frames_sent) == 1
f = frames_sent[0]
assert f[0] == identity and f[1] == b""
resp = msgspec.msgpack.decode(f[2], type=AsyncPDMsg)
assert isinstance(resp, AsyncAllocResponse) and resp.remote_indexes == [-1]
# ── close() ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("role", ["sender", "receiver"])
def test_close_stops_thread(role, async_sender, async_receiver):
"""close() stops the background event-loop thread."""
backend = async_sender if role == "sender" else async_receiver
attr = "_sender_thread" if role == "sender" else "_recv_thread"
assert getattr(backend, attr).is_alive()
backend.close()
assert not getattr(backend, attr).is_alive()
backend.running = False
# ── pd_max_prefill_len init check ────────────────────────────────────────
def test_pd_max_prefill_len_check():
"""pd_max_prefill_len > buffer capacity → ValueError on init."""
# First Party
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.metadata import LMCacheMetadata
def recv_cfg(max_len):
return LMCacheEngineConfig.from_defaults(
chunk_size=16,
pd_role="receiver",
pd_peer_host="127.0.0.1",
pd_peer_init_port=[9200],
pd_peer_alloc_port=[9201],
pd_buffer_size=64 * 1024 * 1024,
pd_buffer_device="cpu",
pd_max_prefill_len=max_len,
)
def send_cfg(max_len):
return LMCacheEngineConfig.from_defaults(
chunk_size=16,
pd_role="sender",
pd_proxy_host="127.0.0.1",
pd_proxy_port=5555,
pd_buffer_size=64 * 1024 * 1024,
pd_buffer_device="cpu",
pd_max_prefill_len=max_len,
)
meta = LMCacheMetadata(
model_name="test",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 16, 8, 128),
)
with _patched_pd():
with pytest.raises(ValueError, match="pd_max_prefill_len"):
PDBackendAsync(recv_cfg(5000), meta)
with _patched_pd():
with pytest.raises(ValueError, match="pd_max_prefill_len"):
PDBackendAsync(send_cfg(5000), meta)
with _patched_pd():
PDBackendAsync(recv_cfg(4096), meta).close() # boundary ok
with _patched_pd():
PDBackendAsync(recv_cfg(0), meta).close() # 0 skips check
# ── wire-format compatibility (sync ↔ async) ─────────────────────────────
def test_sync_request_decoded_as_async():
req = SyncAllocRequest(
keys=["k0", "k1"],
fmt=0,
shape=[4, 2, 16, 8, 128],
dtype="bfloat16",
last_chunk_toks=7,
)
decoded = msgspec.msgpack.decode(msgspec.msgpack.encode(req), type=AsyncPDMsg)
assert isinstance(decoded, AsyncAllocRequest)
assert (
decoded.keys == ["k0", "k1"]
and decoded.req_id == ""
and not decoded.is_last_batch
)
def test_async_response_decoded_as_sync():
resp = AsyncAllocResponse(remote_indexes=[100, 200])
decoded = msgspec.msgpack.decode(msgspec.msgpack.encode(resp), type=SyncPDMsg)
assert isinstance(decoded, SyncAllocResponse)
assert decoded.already_sent_indexes == [] and decoded.remote_indexes == [100, 200]
def test_sync_response_decoded_as_async():
resp = SyncAllocResponse(already_sent_indexes=[0], remote_indexes=[100])
decoded = msgspec.msgpack.decode(msgspec.msgpack.encode(resp), type=AsyncPDMsg)
assert isinstance(decoded, AsyncAllocResponse)
assert decoded.already_sent_indexes == [0] and decoded.remote_indexes == [100]
# ── new coverage tests ────────────────────────────────────────────────────
def test_receiver_reject_legacy_sender_zero_total_chunks(async_receiver):
"""req_id + total_chunks=0 → RuntimeError (legacy sender rejected).
Verifies that a request with a req_id but total_chunks=0 is rejected
immediately, since the new reservation-based design requires senders to
declare total_chunks upfront.
"""
async_receiver.allocate = _auto_alloc()
req = _make_alloc_req([_make_key(0)], req_id="req-legacy", total_chunks=0)
async def run():
with pytest.raises(RuntimeError, match="total_chunks"):
await async_receiver._async_allocate_and_put(req)
asyncio.run(run())
def test_receiver_reservation_admission_timeout(async_receiver):
"""Admission times out when buffer is fully reserved by another request.
Verifies that when the entire buffer is reserved by an in-progress request,
a new request's admission attempt times out and raises RuntimeError.
"""
async_receiver.allocate = _auto_alloc()
# Set the reservation manager's own admission timeout to keep the test fast.
async_receiver._recv_reservation_mgr._allocation_timeout = 0.05
total = async_receiver._recv_reservation_mgr._total_chunks
async def run():
# Admit a request that reserves the entire buffer (not yet released).
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(i) for i in range(total)],
req_id="req-hog",
total_chunks=total,
is_last_batch=False,
)
)
# Second request cannot be admitted → timeout.
with pytest.raises(RuntimeError, match="timed out"):
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(9000)],
req_id="req-blocked",
total_chunks=1,
)
)
asyncio.run(run())
def test_receiver_cancel_notif_releases_keys_and_reservation(async_receiver):
"""CancelNotif removes keys, releases reservation, and cleans up tracking.
Verifies that when a CancelNotif arrives via _handle_alloc_request, all
previously allocated keys are removed, the per-request tracking entry is
deleted, and the reservation is fully released.
"""
async_receiver.allocate = _auto_alloc()
req_id = "req-cancel"
keys = [_make_key(5000 + i) for i in range(3)]
async def run():
# Allocate first batch (not last).
await async_receiver._async_allocate_and_put(
_make_alloc_req(keys, req_id=req_id, total_chunks=5, is_last_batch=False)
)
assert req_id in async_receiver._req_allocated_keys
assert async_receiver._recv_reservation_mgr._total_reserved > 0
# Send CancelNotif via _handle_alloc_request.
cancel = CancelNotif(req_id=req_id, keys=[k.to_string() for k in keys])
payload = msgspec.msgpack.encode(cancel)
sock = MagicMock()
sock.send_multipart = AsyncMock()
await async_receiver._handle_alloc_request(sock, b"sender-1", payload)
# Verify cleanup.
for k in keys:
assert not async_receiver.contains(k, pin=False)
assert req_id not in async_receiver._req_allocated_keys
assert async_receiver._recv_reservation_mgr._total_reserved == 0
asyncio.run(run())
def test_receiver_no_req_id_skips_admission(async_receiver):
"""Empty req_id bypasses reservation admission and chunk accounting.
Verifies that anonymous requests (req_id="") are allocated without
touching the reservation manager, leaving total_reserved unchanged.
"""
async_receiver.allocate = _auto_alloc()
async def run():
r = await async_receiver._async_allocate_and_put(
_make_alloc_req([_make_key(0), _make_key(1)], req_id="", total_chunks=0)
)
assert len(r.remote_indexes) == 2
assert -1 not in r.remote_indexes
# No reservation should be held for anonymous requests.
assert async_receiver._recv_reservation_mgr._total_reserved == 0
asyncio.run(run())
def test_receiver_batch_failure_rolls_back_prior_batches(async_receiver):
"""Second batch allocation timeout → both batches rolled back.
Verifies that when a batch fails mid-allocation, all previously allocated
keys from earlier batches for the same request are also removed and the
reservation is released, since the decoder needs all chunks to proceed.
"""
async_receiver.allocate = _auto_alloc()
req_id = "req-rollback"
b1_keys = [_make_key(1000 + i) for i in range(3)]
async def run():
# First batch succeeds.
r1 = await async_receiver._async_allocate_and_put(
_make_alloc_req(b1_keys, req_id=req_id, total_chunks=6, is_last_batch=False)
)
assert -1 not in r1.remote_indexes
for k in b1_keys:
assert async_receiver.contains(k, pin=False)
# Second batch: allocator always returns None → timeout.
async_receiver.allocate = lambda *a, **kw: None
async_receiver._allocation_timeout = 0.05
with pytest.raises(RuntimeError, match="timeout"):
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(2000 + i) for i in range(3)],
req_id=req_id,
total_chunks=6,
is_last_batch=True,
)
)
# Prior batch keys must be rolled back too.
for k in b1_keys:
assert not async_receiver.contains(k, pin=False)
assert req_id not in async_receiver._req_allocated_keys
assert async_receiver._recv_reservation_mgr._total_reserved == 0
asyncio.run(run())
def test_receiver_reservation_released_unblocks_waiting_request(async_receiver):
"""Releasing a reservation unblocks a concurrently waiting admission.
Covers the notify_all() call in async_release_reservation.
"""
async_receiver.allocate = _auto_alloc()
total = async_receiver._recv_reservation_mgr._total_chunks
async def run():
# Fill all reservation with req-fill (not last batch, so not released yet).
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(i) for i in range(total)],
req_id="req-fill",
total_chunks=total,
is_last_batch=False,
)
)
result: list = []
async def blocked_req():
r = await async_receiver._async_allocate_and_put(
_make_alloc_req(
[_make_key(9000)],
req_id="req-wait",
total_chunks=1,
)
)
result.append(r)
async def release_later():
await asyncio.sleep(0.05)
# Release req-fill's reservation by sending the last batch.
await async_receiver._async_allocate_and_put(
_make_alloc_req(
[],
req_id="req-fill",
total_chunks=total,
is_last_batch=True,
)
)
await asyncio.gather(blocked_req(), release_later())
assert len(result) == 1
assert -1 not in result[0].remote_indexes
asyncio.run(run())
# ── ref-count call verification for already_sent_indexes (dedup path) ─────
def test_receiver_dedup_pins_existing_key_via_ref_count_up(async_receiver):
"""
_async_allocate_and_put calls ref_count_up
on pre-existing key (via contains(pin=True)).
"""
existing_key = _make_key(11000)
existing_obj = _make_mem_obj(idx=42)
async_receiver.data[existing_key] = existing_obj
new_key = _make_key(11001)
new_obj = _make_mem_obj(idx=99)
async_receiver.allocate = lambda *a, **kw: new_obj
req = _make_alloc_req(
[existing_key, new_key],
req_id="req-dedup-pin",
total_chunks=2,
is_last_batch=True,
)
resp = asyncio.run(async_receiver._async_allocate_and_put(req))
assert resp.already_sent_indexes == [0]
assert len(resp.remote_indexes) == 1
# The critical assertion: production code called ref_count_up exactly once
# on the existing object (via contains(pin=True)).
existing_obj.ref_count_up.assert_called_once()
# New object should NOT have ref_count_up called by dedup logic.
new_obj.ref_count_up.assert_not_called()
def test_receiver_dedup_multiple_existing_keys_each_pinned_once(async_receiver):
"""Each pre-existing key gets exactly one ref_count_up call."""
keys = [_make_key(12000 + i) for i in range(4)]
objs = [_make_mem_obj(idx=i) for i in range(4)]
# Index 0, 2 pre-exist.
async_receiver.data[keys[0]] = objs[0]
async_receiver.data[keys[2]] = objs[2]
alloc_counter = itertools.count(start=500)
def counting_alloc(*a, **kw):
return _make_mem_obj(idx=next(alloc_counter))
async_receiver.allocate = counting_alloc
req = _make_alloc_req(
keys,
req_id="req-multi-dedup",
total_chunks=4,
is_last_batch=True,
)
resp = asyncio.run(async_receiver._async_allocate_and_put(req))
assert sorted(resp.already_sent_indexes) == [0, 2]
assert len(resp.remote_indexes) == 2
objs[0].ref_count_up.assert_called_once()
objs[2].ref_count_up.assert_called_once()
def test_remove_calls_ref_count_down_and_conditional_delete(async_receiver):
"""remove() calls ref_count_down; deletes from data only when get_ref_count()==0."""
key = _make_key(13000)
obj = _make_mem_obj(idx=50)
obj._ref_count = 3
async_receiver.data[key] = obj
# First remove: ref_count_down called, but get_ref_count() returns 2 → not deleted
async_receiver.remove(key)
assert obj.ref_count_down.call_count == 1
assert key in async_receiver.data
# Second remove
async_receiver.remove(key)
assert obj.ref_count_down.call_count == 2
assert key in async_receiver.data
# Third remove: get_ref_count() will return 0 → deleted
async_receiver.remove(key)
assert obj.ref_count_down.call_count == 3
assert key not in async_receiver.data
def test_put_duplicate_calls_ref_count_down_on_new_obj(async_receiver):
"""Duplicate put() calls ref_count_down on the *new* (rejected) object."""
key = _make_key(14000)
first_obj = _make_mem_obj(idx=60)
second_obj = _make_mem_obj(idx=61)
async_receiver.put(key, first_obj)
first_obj.ref_count_down.assert_not_called()
async_receiver.put(key, second_obj)
# Production code drops the new obj:
second_obj.ref_count_down.assert_called_once()
# Original untouched:
first_obj.ref_count_down.assert_not_called()
assert async_receiver.data[key] is first_obj
def test_sender_dedup_calls_ref_count_down_on_skipped_chunks():
"""Sender calls ref_count_down on dedup'd chunks without RDMA write."""
p1, p2, p3 = _pd_backend_patches()
with p1, p2 as mock_create_tc, p3:
alloc_response = AsyncAllocResponse(
remote_indexes=[100],
already_sent_indexes=[0],
)
alloc_socket = MagicMock()
alloc_socket.recv_multipart = AsyncMock(
return_value=[b"", msgspec.msgpack.encode(alloc_response)]
)
alloc_socket.send_multipart = AsyncMock()
tc = MagicMock()
tc.async_batched_write = AsyncMock(return_value=1)
mock_create_tc.return_value = tc
# First Party
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.metadata import LMCacheMetadata
config = LMCacheEngineConfig.from_defaults(
chunk_size=16,
pd_role="sender",
pd_proxy_host="127.0.0.1",
pd_proxy_port=5555,
pd_buffer_size=64 * 1024 * 1024,
pd_buffer_device="cpu",
)
metadata = LMCacheMetadata(
model_name="test",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 16, 8, 128),
)
sender = PDBackendAsync(config, metadata)
sender._async_proxy_socket = AsyncMock()
receiver_id = "127.0.0.1" + str(9100)
sender.initialized_peers.add(receiver_id)
sender._async_alloc_sockets[receiver_id] = alloc_socket
mem_obj_0 = _make_mem_obj(idx=0)
mem_obj_0._ref_count = 2 # post ref_count_up
mem_obj_1 = _make_mem_obj(idx=1)
mem_obj_1._ref_count = 2
keys = [_make_key(15000), _make_key(15001)]
async def run():
await sender._async_transfer_task(
keys=keys,
memory_objs=[mem_obj_0, mem_obj_1],
receiver_id=receiver_id,
on_complete_callback=None,
transfer_spec=_make_transfer_spec(
req_id="req-sender-dedup",
total_chunks=2,
),
)
asyncio.run(run())
# mem_obj_0 (dedup'd): ref_count_down called exactly once (skip RDMA path)
mem_obj_0.ref_count_down.assert_called_once()
# mem_obj_1 (sent): ref_count_down called exactly once (after RDMA write)
mem_obj_1.ref_count_down.assert_called_once()
# Only mem_obj_1 was sent via RDMA
tc.async_batched_write.assert_called_once()
call_kw = tc.async_batched_write.call_args[1]
assert call_kw["objects"] == [mem_obj_1]
sender.close()
def test_dedup_pin_then_remove_verifies_call_sequence(async_receiver):
"""Full lifecycle: verify ref_count_up/down call counts through dedup + removes."""
key = _make_key(16000)
obj = _make_mem_obj(idx=70)
async_receiver.data[key] = obj
# Dedup pin via contains(pin=True)
async_receiver.contains(key, pin=True)
obj.ref_count_up.assert_called_once()
# First remove
async_receiver.remove(key)
assert obj.ref_count_down.call_count == 1
assert key in async_receiver.data # ref_count=1 after mock side_effect
# Second remove
async_receiver.remove(key)
assert obj.ref_count_down.call_count == 2
assert key not in async_receiver.data # ref_count=0 → deleted