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

464 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""End-to-end test for the ShmFile connector.
This test verifies that:
1. Data written via put() is persisted to a file.
2. Data read via get() matches the original data exactly.
3. The C++ subprocess (shm_file_worker) correctly attaches to
POSIX shared memory and performs I/O without extra copies.
"""
# Standard
from multiprocessing import shared_memory
from typing import Optional
import asyncio
import ctypes
import os
import subprocess
import tempfile
import time
# Third Party
import pytest
import torch
# First Party
from lmcache.logging import init_logger
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.memory_allocators.mixed_memory_allocator import MixedMemoryAllocator
from lmcache.v1.metadata import LMCacheMetadata
from lmcache.v1.storage_backend import LocalCPUBackend
# Local
from ..utils import (
close_asyncio_loop,
dumb_cache_engine_key,
init_asyncio_loop,
)
from .shmfile_connector import ShmFileConnector
logger = init_logger(__name__)
SHM_NAME = "/lmcache_test_shm"
SHM_BUF_SIZE = 5 * 1024 * 1024 * 1024 # 5 GB
_CSRC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "csrc")
_BUILD_DIR = os.path.join(_CSRC_DIR, "build")
_WORKER_BIN = os.path.join(_BUILD_DIR, "shm_file_worker")
def _build_worker_binary() -> str:
"""Build shm_file_worker from tests/v1/shm_allocator/csrc/
and return the path to the compiled binary."""
env_bin = os.environ.get("SHM_FILE_WORKER_BIN")
if env_bin and os.path.isfile(env_bin):
return env_bin
if os.path.isfile(_WORKER_BIN):
return _WORKER_BIN
os.makedirs(_BUILD_DIR, exist_ok=True)
try:
subprocess.check_call(
["cmake", ".."],
cwd=_BUILD_DIR,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.check_call(
["make", "shm_file_worker"],
cwd=_BUILD_DIR,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.CalledProcessError):
pytest.skip(
"Cannot compile shm_file_worker. "
"Ensure cmake and a C++ compiler are available."
)
if not os.path.isfile(_WORKER_BIN):
pytest.skip("shm_file_worker build failed.")
return _WORKER_BIN
def _get_metadata():
kv_shape = (32, 2, 256, 8, 128)
dtype = torch.bfloat16
return LMCacheMetadata(
model_name="test_model",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=dtype,
kv_shape=kv_shape,
)
def _create_shmfs_config(
storage_dir: str,
worker_binary: str,
shm_name: str = SHM_NAME,
worker_addr: Optional[str] = None,
) -> LMCacheEngineConfig:
"""Build config with shmfs.* extra_config entries."""
extra = {
"shmfs.storage_dir": storage_dir,
"shmfs.shm_name": shm_name,
"shmfs.worker_binary": worker_binary,
"shm_name": shm_name,
}
if worker_addr:
extra["shmfs.worker_addr"] = worker_addr
return LMCacheEngineConfig.from_defaults(
extra_config=extra,
)
def _create_local_cpu_backend(memory_allocator):
config = LMCacheEngineConfig.from_defaults()
metadata = _get_metadata()
return LocalCPUBackend(
config=config,
metadata=metadata,
memory_allocator=memory_allocator,
)
@pytest.fixture
def shm_allocator():
"""Create a MixedMemoryAllocator backed by POSIX shm."""
allocator = MixedMemoryAllocator(SHM_BUF_SIZE, shm_name=SHM_NAME)
yield allocator
allocator.close()
@pytest.fixture
def worker_binary():
worker_binary_path = _build_worker_binary()
logger.info(f"Using shm_file_worker binary: {worker_binary_path}")
return worker_binary_path
class TestShmFileConnector:
"""End-to-end tests for ShmFileConnector."""
def test_put_and_get_roundtrip(self, shm_allocator, worker_binary):
"""Write data via put(), then read via get() and
verify byte-for-byte equality."""
with tempfile.TemporaryDirectory() as tmp_dir:
async_loop, async_thread = init_asyncio_loop()
try:
backend = _create_local_cpu_backend(shm_allocator)
cfg = _create_shmfs_config(
tmp_dir,
worker_binary,
)
connector = ShmFileConnector(
loop=async_loop,
local_cpu_backend=backend,
config=cfg,
)
key = dumb_cache_engine_key()
# Verify key does not exist initially
fut = asyncio.run_coroutine_threadsafe(
connector.exists(key), async_loop
)
assert not fut.result()
# Allocate and fill with deterministic data
shape = torch.Size([2, 32, 256, 1024])
dtype = torch.bfloat16
mem_obj = backend.allocate(shape, dtype)
assert mem_obj is not None
mem_obj.ref_count_up()
torch.manual_seed(42)
test_data = torch.randint(
0,
100,
mem_obj.raw_data.shape,
dtype=torch.int64,
)
mem_obj.raw_data.copy_(test_data.to(torch.float32).to(dtype))
# Put
fut = asyncio.run_coroutine_threadsafe(
connector.put(key, mem_obj), async_loop
)
fut.result()
# Verify file exists on disk
fut = asyncio.run_coroutine_threadsafe(
connector.exists(key), async_loop
)
assert fut.result()
# Get
fut = asyncio.run_coroutine_threadsafe(connector.get(key), async_loop)
retrieved = fut.result()
assert retrieved is not None
# Compare
orig = mem_obj.tensor
got = retrieved.tensor
assert orig is not None
assert got is not None
assert orig.shape == got.shape
assert torch.equal(orig, got), "Data mismatch after roundtrip!"
# Clean up
fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
fut.result()
backend.close()
finally:
close_asyncio_loop(async_loop, async_thread)
def test_get_nonexistent_key(self, shm_allocator, worker_binary):
"""get() on a missing key returns None."""
with tempfile.TemporaryDirectory() as tmp_dir:
async_loop, async_thread = init_asyncio_loop()
try:
backend = _create_local_cpu_backend(shm_allocator)
cfg = _create_shmfs_config(
tmp_dir,
worker_binary,
)
connector = ShmFileConnector(
loop=async_loop,
local_cpu_backend=backend,
config=cfg,
)
key = dumb_cache_engine_key(id=999)
fut = asyncio.run_coroutine_threadsafe(connector.get(key), async_loop)
assert fut.result() is None
fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
fut.result()
backend.close()
finally:
close_asyncio_loop(async_loop, async_thread)
def test_list_keys(self, shm_allocator, worker_binary):
"""list() returns keys for all stored files."""
with tempfile.TemporaryDirectory() as tmp_dir:
async_loop, async_thread = init_asyncio_loop()
try:
backend = _create_local_cpu_backend(shm_allocator)
cfg = _create_shmfs_config(
tmp_dir,
worker_binary,
)
connector = ShmFileConnector(
loop=async_loop,
local_cpu_backend=backend,
config=cfg,
)
shape = torch.Size([2, 32, 256, 1024])
dtype = torch.bfloat16
keys = [dumb_cache_engine_key(id=i) for i in range(3)]
for k in keys:
mem_obj = backend.allocate(shape, dtype)
assert mem_obj is not None
mem_obj.ref_count_up()
fut = asyncio.run_coroutine_threadsafe(
connector.put(k, mem_obj), async_loop
)
fut.result()
fut = asyncio.run_coroutine_threadsafe(connector.list(), async_loop)
listed = set(fut.result())
expected = {k.to_string() for k in keys}
assert listed == expected
fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
fut.result()
backend.close()
finally:
close_asyncio_loop(async_loop, async_thread)
class TestShmWorkerSubprocess:
"""Test the C++ shm_file_worker directly."""
def test_allocator_receives_shm_name_from_config(self):
"""Verify that initialize_allocator passes shm_name
when extra_config contains shm_name."""
config = LMCacheEngineConfig.from_defaults(
extra_config={"shm_name": "/lmc_prod_test"},
max_local_cpu_size=0.001,
)
metadata = _get_metadata()
backend = LocalCPUBackend(config=config, metadata=metadata)
alloc = backend.memory_allocator
assert isinstance(alloc, MixedMemoryAllocator)
assert alloc.shm_name == "/lmc_prod_test"
backend.close()
def test_allocator_no_shm_name_without_extra_config(self):
"""Verify that shm_name is None when extra_config
does not contain shm_name."""
config = LMCacheEngineConfig.from_defaults(
max_local_cpu_size=0.001,
)
metadata = _get_metadata()
backend = LocalCPUBackend(config=config, metadata=metadata)
alloc = backend.memory_allocator
assert isinstance(alloc, MixedMemoryAllocator)
assert alloc.shm_name is None
backend.close()
def test_worker_attach_and_io(self, worker_binary):
"""Verify the worker can attach, write, and read."""
shm_test_name = "lmcache_worker_test"
shm_test_size = 4096
# Create shm segment from Python side
shm = shared_memory.SharedMemory(
name=shm_test_name, create=True, size=shm_test_size
)
try:
# Write known pattern into shm
msg = b"Hello SHM Worker!"
shm.buf[:17] = msg
# Get the base address of shm buffer
arr_t = ctypes.c_uint8 * shm_test_size
arr = arr_t.from_buffer(shm.buf)
base_addr = ctypes.addressof(arr)
proc = subprocess.Popen(
[worker_binary],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
def send(cmd):
assert proc.stdin is not None
assert proc.stdout is not None
proc.stdin.write(cmd + "\n")
proc.stdin.flush()
return proc.stdout.readline().strip()
# Attach with base_addr
resp = send("ATTACH /%s %d %d" % (shm_test_name, shm_test_size, base_addr))
assert resp == "OK", "ATTACH failed: %s" % resp
# Write shm content to a temp file (data_ptr = base_addr)
with tempfile.NamedTemporaryFile(delete=False) as f:
tmp_path = f.name
try:
resp = send("WRITE %s %d 17" % (tmp_path, base_addr))
assert resp.startswith("OK"), "WRITE failed: %s" % resp
with open(tmp_path, "rb") as f:
assert f.read() == msg
# Now zero the shm region and read back
shm.buf[:17] = b"\x00" * 17
resp = send("READ %s %d 17" % (tmp_path, base_addr))
assert resp.startswith("OK"), "READ failed: %s" % resp
assert bytes(shm.buf[:17]) == msg
finally:
os.unlink(tmp_path)
send("QUIT")
proc.wait(timeout=5)
finally:
del arr # Release exported pointer before close
shm.close()
shm.unlink()
def test_worker_tcp_mode(self, worker_binary):
"""Verify the worker can run in TCP mode and
communicate via socket."""
shm_test_name = "lmcache_tcp_test"
shm_test_size = 4096
tcp_port = 19800
shm = shared_memory.SharedMemory(
name=shm_test_name,
create=True,
size=shm_test_size,
)
try:
msg = b"Hello TCP Worker!"
shm.buf[:17] = msg
arr_t = ctypes.c_uint8 * shm_test_size
arr = arr_t.from_buffer(shm.buf)
base_addr = ctypes.addressof(arr)
# Start worker in TCP mode
proc = subprocess.Popen(
[
worker_binary,
"--listen",
"127.0.0.1:%d" % tcp_port,
],
stderr=subprocess.PIPE,
)
# Wait for worker to start listening
time.sleep(0.5)
# Standard
import socket as sock_mod
conn = sock_mod.socket(sock_mod.AF_INET, sock_mod.SOCK_STREAM)
conn.connect(("127.0.0.1", tcp_port))
def send_tcp(cmd):
conn.sendall((cmd + "\n").encode("utf-8"))
buf = b""
while b"\n" not in buf:
data = conn.recv(4096)
if not data:
return "ERROR connection closed"
buf += data
return buf.split(b"\n", 1)[0].decode().strip()
# Attach
resp = send_tcp(
"ATTACH /%s %d %d" % (shm_test_name, shm_test_size, base_addr)
)
assert resp == "OK", "ATTACH failed: %s" % resp
# Write to file
with tempfile.NamedTemporaryFile(delete=False) as f:
tmp_path = f.name
try:
resp = send_tcp("WRITE %s %d 17" % (tmp_path, base_addr))
assert resp.startswith("OK"), "WRITE failed: %s" % resp
with open(tmp_path, "rb") as f:
assert f.read() == msg
# Zero and read back
shm.buf[:17] = b"\x00" * 17
resp = send_tcp("READ %s %d 17" % (tmp_path, base_addr))
assert resp.startswith("OK"), "READ failed: %s" % resp
assert bytes(shm.buf[:17]) == msg
finally:
os.unlink(tmp_path)
send_tcp("QUIT")
conn.close()
proc.wait(timeout=5)
finally:
del arr
shm.close()
shm.unlink()