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

274 lines
9.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for ``lmcache.v1.platform.cpu.shm``.
Validates that the POSIX-SHM-backed wrapper can round-trip a CPU
tensor in-process: the constructed wrapper's ``to_tensor()`` view
sees writes made through the original tensor.
"""
# Standard
import os
# Third Party
import pytest
import torch
# First Party
from lmcache.v1.multiprocess.posix_shm import shm_unlink
from lmcache.v1.platform.cpu.shm import (
CpuShmTensorWrapper,
migrate_to_shm_and_wrap,
shm_create_readwrite,
)
def test_shm_create_unlink_roundtrip():
"""``shm_create_readwrite`` succeeds and ``shm_unlink`` cleans up."""
name = "/lmcache_test_%d" % os.getpid()
addr = shm_create_readwrite(name, 4096)
try:
assert addr not in (0, None)
finally:
shm_unlink(name)
def test_migrate_to_shm_and_wrap_zero_copy_view():
"""After migrate, writes via the original tensor are visible via wrapper."""
src = torch.zeros((2, 4, 4), dtype=torch.float32)
wrapper = migrate_to_shm_and_wrap(src)
try:
assert isinstance(wrapper, CpuShmTensorWrapper)
assert wrapper.shape == (2, 4, 4)
assert wrapper.dtype == torch.float32
# Mutate via the migrated source tensor; its storage is now the
# SHM segment, so the wrapper's reconstructed view must see it.
src.add_(7.0)
view = wrapper.to_tensor()
assert torch.equal(view, src)
finally:
shm_unlink(wrapper.shm_name)
def test_migrate_handles_empty_tensor():
"""Empty tensors must not call ``mmap`` (length 0 is EINVAL).
Regression for the case where ``nbytes == 0``: the wrapper carries
an empty ``shm_name`` and ``to_tensor`` rebuilds the empty view in
process without touching POSIX shared memory.
"""
src = torch.empty((0, 4), dtype=torch.float32)
wrapper = migrate_to_shm_and_wrap(src)
assert isinstance(wrapper, CpuShmTensorWrapper)
assert wrapper.shm_name == ""
assert wrapper.nbytes == 0
view = wrapper.to_tensor()
assert view.shape == (0, 4)
assert view.dtype == torch.float32
def test_migrate_is_idempotent_on_same_tensor():
"""Re-wrapping the same tensor reuses the existing SHM segment."""
src = torch.zeros((3, 5), dtype=torch.float32)
w1 = migrate_to_shm_and_wrap(src)
try:
w2 = migrate_to_shm_and_wrap(src)
assert w1.shm_name == w2.shm_name
finally:
shm_unlink(w1.shm_name)
def test_rejects_non_cpu_tensor():
"""Construction rejects tensors that are not on CPU."""
if not torch.backends.mps.is_available():
pytest.skip("MPS not available; cannot synthesize a non-cpu tensor")
src = torch.zeros((2, 2), device="mps")
with pytest.raises(ValueError, match="CPU tensor"):
CpuShmTensorWrapper(src, "/lmcache_test_should_not_exist")
def test_migrate_finalizer_unlinks_on_gc():
"""Once the migrated tensor is GC-ed, its SHM segment is unlinked."""
# Standard
import gc
# First Party
from lmcache.v1.platform.cpu.shm import shm_map_readwrite
src = torch.zeros((2, 2), dtype=torch.float32)
w = migrate_to_shm_and_wrap(src)
name = w.shm_name
nbytes = w.nbytes
# Drop both references; the weakref.finalize hook should unlink.
del src, w
gc.collect()
with pytest.raises(OSError):
shm_map_readwrite(name, nbytes)
def test_shm_create_cleans_up_on_existing_name():
"""If ``shm_open(O_EXCL)`` fails the helper must not leave the fd open.
We exercise the failure path by creating a segment, then asking
``shm_create_readwrite`` to recreate the same name -- it must
raise without leaking the file descriptor it briefly held.
"""
name = "/lmcache_test_excl_%d" % os.getpid()
addr = shm_create_readwrite(name, 4096)
try:
with pytest.raises(OSError):
shm_create_readwrite(name, 4096)
finally:
shm_unlink(name)
# And after unlink, the name is reusable again.
addr2 = shm_create_readwrite(name, 4096)
assert addr2 not in (0, None)
shm_unlink(name)
_ = addr # silence unused-variable hint
def test_to_tensor_view_carries_munmap_finalizer():
"""``to_tensor`` returns a tensor whose storage releases its mmap on GC."""
# Standard
import gc
import weakref
src = torch.zeros((2, 2), dtype=torch.float32)
w = migrate_to_shm_and_wrap(src)
try:
view = w.to_tensor()
ref = weakref.ref(view)
del view
gc.collect()
assert ref() is None
finally:
del src
gc.collect()
shm_unlink(w.shm_name)
def test_to_tensor_view_survives_reshape_after_original_is_gced():
"""A reshape view must keep working after the original tensor is GC-ed.
Regression for the CI ``cpu_e2e_validation (server-side copy)`` segfault
where ``normalize_kv_and_discover_format`` reshapes the unwrapped 4D
tensor into the canonical 5D ``[NB, NH, BS, 2, HS]`` and the original
4D tensor goes out of scope. The reshape view shares the same storage
but used to be left without a lifetime hook, so the next read after GC
landed on an already-``munmap``-ed page and crashed the LMCache server.
"""
# Standard
import gc
NB, NH, BS, HS = 8, 4, 16, 8
src = torch.zeros((NB, NH, BS, 2 * HS), dtype=torch.bfloat16)
w = migrate_to_shm_and_wrap(src)
try:
unwrapped = [w.to_tensor()]
normalized = [
layer.reshape(*layer.shape[:3], 2, layer.shape[3] // 2)
for layer in unwrapped
]
# Drop every reference to the 4D unwrapped tensor; only the 5D
# reshape view keeps the storage alive now.
del unwrapped
gc.collect()
# These ops would segfault before the fix because the SHM mapping
# had been munmap-ed when the 4D tensor's finalizer ran.
idx = torch.tensor([0, 3, 5], dtype=torch.long)
gathered = normalized[0].index_select(0, idx)
assert tuple(gathered.shape) == (3, NH, BS, 2, HS)
# The SHM segment is freshly mmap'd zeros; just make sure the
# bytes are addressable end-to-end so the kernel does not fault.
assert gathered.float().abs().sum().item() == 0.0
finally:
del src
gc.collect()
shm_unlink(w.shm_name)
def test_to_tensor_replays_stride_and_storage_offset():
"""``to_tensor`` rebuilds the view via stride+offset (not reshape)."""
src = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4).contiguous()
w = migrate_to_shm_and_wrap(src)
try:
view = w.to_tensor()
assert tuple(view.stride()) == w.stride
assert int(view.storage_offset()) == w.storage_offset
assert torch.equal(view, src)
finally:
del src, view
shm_unlink(w.shm_name)
def test_wrap_kv_caches_unlinks_partial_batch_on_failure(monkeypatch):
"""If wrapping the N-th tensor raises, earlier SHM names are unlinked.
Drives :func:`wrap_kv_caches` with two CPU tensors and forces the
second factory call to raise; the first iteration's SHM segment
must be ``shm_unlink``-ed so the named segment does not outlive
the failed batch.
"""
# First Party
from lmcache.integration.vllm import vllm_multi_process_adapter as adapter
from lmcache.v1.platform.cpu.shm import shm_map_readwrite
real_wrap = adapter.wrap_one_kv_cache
state = {"n": 0, "first_name": None}
def flaky_wrap(tensor):
state["n"] += 1
if state["n"] == 2:
raise RuntimeError("simulated migration failure")
w = real_wrap(tensor)
state["first_name"] = w.shm_name
return w
monkeypatch.setattr(adapter, "wrap_one_kv_cache", flaky_wrap)
t1 = torch.zeros((2, 2), dtype=torch.float32)
t2 = torch.zeros((2, 2), dtype=torch.float32)
with pytest.raises(RuntimeError, match="simulated migration failure"):
adapter.wrap_kv_caches({"a": t1, "b": t2})
# The first iteration's SHM segment must no longer be openable.
nbytes = t1.numel() * t1.element_size()
with pytest.raises(OSError):
shm_map_readwrite(state["first_name"], nbytes)
def test_migrate_ignores_stale_entry_from_id_reuse():
"""A cached entry whose weakref is dead must not be reused.
Simulates CPython recycling an object id by injecting a stale
``(dead_ref, old_name)`` tuple keyed by the live tensor's id,
then calling :func:`migrate_to_shm_and_wrap`. The factory must
treat the dead entry as a miss and allocate a fresh SHM segment
-- if it blindly reused the cached name, ``shm_create_readwrite``
would crash with ``EEXIST`` (and even worse, the fresh tensor
would be silently bound to the wrong SHM name).
"""
# Standard
import gc
import weakref as _wr
# First Party
from lmcache.v1.platform.cpu.shm import inject_stale_cache_entry_for_test
# Build a tensor we will let die so we have a guaranteed-dead ref.
ghost = torch.zeros((1,), dtype=torch.float32)
dead_ref = _wr.ref(ghost)
del ghost
gc.collect()
assert dead_ref() is None
live = torch.zeros((2, 2), dtype=torch.float32)
stale_name = "/lmcache_test_stale_%d" % os.getpid()
inject_stale_cache_entry_for_test(live, dead_ref, stale_name)
w = migrate_to_shm_and_wrap(live)
try:
assert w.shm_name != stale_name
finally:
shm_unlink(w.shm_name)