Refactor NIXL hicache. Add O_DIRECT support (#25173)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas Humbel
2026-06-01 17:28:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 89feb18eb9
commit d8a5a25c36
12 changed files with 1565 additions and 839 deletions
@@ -0,0 +1,777 @@
"""Unit tests for the NIXL HiCache storage backend -- no server, no model loading."""
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=30, stage="base-a", runner_config="1-gpu-small")
import os
import shutil
import socket
import subprocess
import tempfile
import threading
import time
import unittest
from typing import List
import torch
from sglang.srt.mem_cache.hicache_storage import HiCacheStorageConfig
from sglang.srt.mem_cache.storage.nixl.hicache_nixl import HiCacheNixl
from sglang.test.test_utils import CustomTestCase
# Stress tests are opt-in: CI never sets this; set locally to exercise them.
STRESS_ENABLED = bool(os.environ.get("SGLANG_RUN_NIXL_STRESS"))
class MockMemPoolHost:
"""Minimal MHA-style HostKVCache stand-in supporting the v1 paths.
zero_copy mode uses ``page_first`` so ``get_page_buffer_meta`` returns
valid (k, v) pointers into ``kv_buffer``. Non-zero-copy uses
``layer_first`` so the slow path uses ``get_data_page`` /
``set_from_flat_data_page`` against the same buffer.
"""
def __init__(
self,
is_zero_copy_mode: bool,
page_size: int = 2,
layer_num: int = 2,
head_num: int = 2,
head_dim: int = 4,
num_pages: int = 4,
dtype: torch.dtype = torch.float32,
):
self.layout = "page_first" if is_zero_copy_mode else "layer_first"
self.page_size = page_size
self.layer_num = layer_num
self.head_num = head_num
self.head_dim = head_dim
self.dtype = dtype
self.num_pages = num_pages
self.size = page_size * num_pages
self.pin_memory = False
if is_zero_copy_mode:
# page_first: (2, size, layer, head, head_dim)
self.kv_buffer = torch.zeros(
(2, self.size, layer_num, head_num, head_dim), dtype=dtype
)
else:
# layer_first: (2, layer, size, head, head_dim)
self.kv_buffer = torch.zeros(
(2, layer_num, self.size, head_num, head_dim), dtype=dtype
)
def get_page_buffer_meta(self, indices):
ptr_list = []
base = self.kv_buffer.data_ptr()
v_offset = (
self.layer_num
* self.size
* self.head_num
* self.head_dim
* self.dtype.itemsize
)
idx_list = indices.tolist()
for i in range(0, len(idx_list), self.page_size):
k_ptr = base + idx_list[i] * (
self.layer_num * self.head_num * self.head_dim * self.dtype.itemsize
)
ptr_list.append(k_ptr)
ptr_list.append(k_ptr + v_offset)
element_size = (
self.layer_num
* self.dtype.itemsize
* self.page_size
* self.head_num
* self.head_dim
)
return ptr_list, [element_size] * len(ptr_list)
def get_dummy_flat_data_page(self):
return torch.zeros(
(2, self.layer_num, self.page_size, self.head_num, self.head_dim),
dtype=self.dtype,
).flatten()
def get_data_page(self, index, flat=True):
if hasattr(index, "item"):
index = int(index.item())
page = self.kv_buffer[:, :, index : index + self.page_size, :, :]
return page.flatten() if flat else page
def set_from_flat_data_page(self, index, data_page):
if hasattr(index, "item"):
index = int(index.item())
self.kv_buffer[:, :, index : index + self.page_size, :, :] = data_page.reshape(
2, self.layer_num, self.page_size, self.head_num, self.head_dim
)
def is_stride_page_aligned(self, page_size_bytes: int = 4096) -> bool:
# Test tensors are too small to satisfy 4 KiB stride alignment; the
# O_DIRECT path correctly falls back to copy mode in this case.
return False
class MinioFixture:
"""Spin up a single-node MinIO server on localhost and create a bucket.
Relies on MinIO's default ``minioadmin``/``minioadmin`` root credentials
so no env vars need to be plumbed through.
"""
user = "minioadmin"
password = "minioadmin"
def __init__(self, bucket: str = "hicache-test"):
self.bucket = bucket
self.api_port = self._find_free_port()
self.data_dir = tempfile.mkdtemp(prefix="nixl_minio_")
self.proc: subprocess.Popen | None = None
@property
def endpoint(self) -> str:
return f"127.0.0.1:{self.api_port}"
@staticmethod
def _find_free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@staticmethod
def _minio_bin() -> str | None:
path = shutil.which("minio") or "/usr/local/bin/minio"
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
@classmethod
def is_available(cls) -> bool:
"""True iff a minio binary and boto3 are both importable."""
if cls._minio_bin() is None:
return False
try:
import boto3 # noqa: F401
except ImportError:
return False
return True
def start(self) -> None:
minio_bin = self._minio_bin()
if minio_bin is None:
raise FileNotFoundError("minio binary not available")
self.proc = subprocess.Popen(
[minio_bin, "server", "--address", self.endpoint, self.data_dir],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.time() + 15.0
while time.time() < deadline:
if self.proc.poll() is not None:
raise RuntimeError(f"minio exited early with rc={self.proc.returncode}")
try:
with socket.create_connection(
("127.0.0.1", self.api_port), timeout=0.5
):
break
except OSError:
time.sleep(0.1)
else:
self.stop()
raise RuntimeError("minio did not become ready within 15s")
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url=f"http://{self.endpoint}",
aws_access_key_id=self.user,
aws_secret_access_key=self.password,
config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
)
s3.create_bucket(Bucket=self.bucket)
def stop(self) -> None:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=5)
shutil.rmtree(self.data_dir, ignore_errors=True)
class TestNixlUnified(CustomTestCase):
"""Unified test suite for all NIXL components."""
def setUp(self):
"""Set up test environment."""
self.test_dir = "/tmp/test_nixl_unified"
os.makedirs(self.test_dir, exist_ok=True)
# Disable O_DIRECT here: these tests use small, arbitrarily-aligned
# tensors that do not satisfy the sector-alignment constraints required
# by O_DIRECT. O_DIRECT-specific behaviour is exercised in
# TestNixlDirectIO below.
self.storage_config = HiCacheStorageConfig(
tp_rank=0,
tp_size=2,
pp_rank=0,
pp_size=1,
attn_cp_rank=0,
attn_cp_size=1,
is_mla_model=False,
is_page_first_layout=False,
model_name="test_model",
enable_storage_metrics=False,
extra_config={
"plugin": {"posix": {"active": True}},
"use_direct_io": False,
},
)
try:
self.hicache = HiCacheNixl(
storage_config=self.storage_config,
file_path=self.test_dir,
)
except ImportError:
self.skipTest("NIXL not available, skipping NIXL storage tests")
def tearDown(self):
"""Clean up test directories."""
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
@staticmethod
def _open_fds() -> int:
return len(os.listdir("/proc/self/fd"))
def test_storage_register_failure_closes_fds(self):
"""If NIXL register_memory raises after fds are opened, all fds are still closed."""
files = [os.path.join(self.test_dir, f"fail_{i}.bin") for i in range(3)]
buffers = [(0, 64) for _ in range(3)]
fds_before = self._open_fds()
orig = self.hicache.agent.register_memory
def boom(*args, **kwargs):
raise RuntimeError("simulated register_memory failure")
self.hicache.agent.register_memory = boom
try:
with self.hicache.registry.storage(buffers, files, "WRITE") as descs:
self.assertIsNone(
descs, "storage CM should yield None on register failure"
)
finally:
self.hicache.agent.register_memory = orig
self.assertEqual(
self._open_fds(),
fds_before,
"fd leak after register_memory failure mid-storage",
)
def _assert_host_addrs_pre_registered(
self, is_zero_copy_mode: bool, hicache: HiCacheNixl = None
):
"""Exercise the v1 path and assert every host xfer addr lies within a
currently-registered host (DRAM/tensor) region.
Spies are installed BEFORE ``register_mem_pool_host`` so the up-front
pre-registration is captured too.
"""
if hicache is None:
hicache = self.hicache
agent = hicache.agent
# Map registration-handle id -> [(addr, size, mem_type), ...]
active_regs: dict = {}
# Capture items list per get_reg_descs call so we can attribute them
# to the registration handle returned by the next register_memory call.
pending: list = []
orig_get_reg = agent.get_reg_descs
def spy_get_reg(items, mem_type=None):
# NIXL's register_memory calls get_reg_descs internally with an
# already-built nixlRegDList; iterating that pybind11 type is
# unsafe, so only record entries when the input is a plain list.
if isinstance(items, list) and items:
entries = []
for it in items:
if isinstance(it, torch.Tensor):
entries.append(
(it.data_ptr(), it.numel() * it.element_size(), None)
)
elif isinstance(it, tuple):
entries.append((it[0], it[1], mem_type))
pending.append(entries)
return orig_get_reg(items, mem_type)
orig_register = agent.register_memory
def spy_register(reg_descs):
reg = orig_register(reg_descs)
entries = pending.pop(0) if pending else []
active_regs[id(reg)] = entries
return reg
orig_dereg = agent.deregister_memory
def spy_dereg(reg):
active_regs.pop(id(reg), None)
return orig_dereg(reg)
last_host_xfer: list = []
orig_get_xfer = agent.get_xfer_descs
def spy_get_xfer(items, mem_type=None):
if mem_type in (None, "DRAM"):
ranges = []
for it in items:
if isinstance(it, torch.Tensor):
ranges.append((it.data_ptr(), it.numel() * it.element_size()))
elif isinstance(it, tuple):
ranges.append((it[0], it[1]))
last_host_xfer.clear()
last_host_xfer.extend(ranges)
return orig_get_xfer(items, mem_type)
violations: list = []
orig_init = agent.initialize_xfer
def spy_init(direction, local, remote, agent_name):
host_regs = [
(a, s)
for entries in active_regs.values()
for (a, s, mt) in entries
if mt in (None, "DRAM")
]
for a, s in last_host_xfer:
if not any(ra <= a and a + s <= ra + rs for (ra, rs) in host_regs):
violations.append((a, s, dict(host_regs=host_regs)))
last_host_xfer.clear()
return orig_init(direction, local, remote, agent_name)
agent.get_reg_descs = spy_get_reg
agent.register_memory = spy_register
agent.deregister_memory = spy_dereg
agent.get_xfer_descs = spy_get_xfer
agent.initialize_xfer = spy_init
try:
mock_host = MockMemPoolHost(is_zero_copy_mode)
hicache.register_mem_pool_host(mock_host)
# Force the requested mode regardless of how register_mem_pool_host derives it.
hicache.is_zero_copy = is_zero_copy_mode
num_pages = 3
keys = [
f"compliance_{int(is_zero_copy_mode)}_{i}" for i in range(num_pages)
]
host_indices = torch.arange(
num_pages * mock_host.page_size, dtype=torch.int64
)
set_results = hicache.batch_set_v1(keys, host_indices)
self.assertTrue(
all(set_results),
f"batch_set_v1 failed (zero_copy={is_zero_copy_mode}): {set_results}",
)
get_results = hicache.batch_get_v1(keys, host_indices)
self.assertTrue(
all(get_results),
f"batch_get_v1 failed (zero_copy={is_zero_copy_mode}): {get_results}",
)
finally:
agent.get_reg_descs = orig_get_reg
agent.register_memory = orig_register
agent.deregister_memory = orig_dereg
agent.get_xfer_descs = orig_get_xfer
agent.initialize_xfer = orig_init
self.assertEqual(
violations,
[],
f"Host xfer addrs not covered by registration (zero_copy={is_zero_copy_mode}): {violations}",
)
def test_nixl_api_contract_host_addrs_within_registered_region_zero_copy(self):
"""All host xfer addrs must lie within a registered region -- zero-copy."""
self._assert_host_addrs_pre_registered(is_zero_copy_mode=True)
def test_nixl_api_contract_host_addrs_within_registered_region_non_zero_copy(self):
"""All host xfer addrs must lie within a registered region -- non-zero-copy."""
self._assert_host_addrs_pre_registered(is_zero_copy_mode=False)
def _make_obj_hicache(self) -> HiCacheNixl:
"""Start a MinIO server (cleaned up via addCleanup) and return a
HiCacheNixl wired to its OBJ backend. Skips the test if the backend
cannot be constructed."""
minio = MinioFixture()
minio.start()
self.addCleanup(minio.stop)
obj_config = HiCacheStorageConfig(
tp_rank=0,
tp_size=1,
pp_rank=0,
pp_size=1,
attn_cp_rank=0,
attn_cp_size=1,
is_mla_model=False,
is_page_first_layout=False,
model_name="test_model",
enable_storage_metrics=False,
extra_config={
"plugin": {
"obj": {
"active": True,
"endpoint_override": f"http://{minio.endpoint}",
"use_virtual_addressing": "false",
"access_key": minio.user,
"secret_key": minio.password,
"bucket": minio.bucket,
}
}
},
)
try:
return HiCacheNixl(storage_config=obj_config, file_path="")
except Exception as e:
self.skipTest(f"NIXL OBJ backend unavailable: {e}")
@unittest.skipUnless(
MinioFixture.is_available(), "minio binary or boto3 not available"
)
def test_nixl_api_contract_host_addrs_within_registered_region_obj(self):
"""Same property over the OBJ backend (MinIO fixture)."""
self._assert_host_addrs_pre_registered(
is_zero_copy_mode=False, hicache=self._make_obj_hicache()
)
def test_batch_set_v1_skips_on_nonzero_mla_rank(self):
"""batch_set_v1 is a no-op on nonzero MLA backup ranks.
With backup_skip=True the early-return must fire before the host-regs
check, so calling without register_mem_pool_host still returns all-True
(the host-regs check would otherwise return all-False).
"""
self.hicache.backup_skip = True
results = self.hicache.batch_set_v1(
["key1", "key2"], torch.tensor([0, 1], dtype=torch.int64)
)
self.assertEqual(results, [True, True])
def test_batch_exists_zero_copy_mla_uses_single_key_denominator(self):
"""Zero-copy MLA batch_exists counts one storage key per logical key."""
self.hicache.is_zero_copy = True
self.hicache.is_mla_model = True
self.hicache.agent.query_memory = lambda *a, **kw: [object(), None]
self.assertEqual(self.hicache.batch_exists(["key1", "key2"]), 1)
def test_batch_exists_zero_copy_mha_uses_two_key_denominator(self):
"""Zero-copy non-MLA batch_exists counts k/v pairs per logical key."""
self.hicache.is_zero_copy = True
self.hicache.is_mla_model = False
self.hicache.agent.query_memory = lambda *a, **kw: [
object(),
object(),
None,
None,
]
self.assertEqual(self.hicache.batch_exists(["key1", "key2"]), 1)
def _run_concurrent_stress(
self, is_zero_copy_mode: bool, hicache: HiCacheNixl = None
):
"""One getter thread + one setter thread share the same HiCacheNixl
for ``is_zero_copy_mode``. Defaults to ``self.hicache`` (FILE backend);
pass ``hicache`` to exercise a different backend (e.g. OBJ).
Phase 1 pre-seeds N preset pages and stores them under fixed keys.
Phase 2 runs the getter (reads the presets back and verifies content)
concurrently with the setter (writes a stream of fresh distinct keys
from a disjoint source region). The kv_buffer regions touched by the
two threads are disjoint so any data corruption observed is from the
backend's shared state (bounce buffers, devId maps, fd pool).
"""
if hicache is None:
hicache = self.hicache
# 8 preset pages, 8 getter dst pages, 8 setter src pages -> 24 in use.
mock_host = MockMemPoolHost(is_zero_copy_mode=is_zero_copy_mode, num_pages=32)
hicache.register_mem_pool_host(mock_host)
hicache.is_zero_copy = is_zero_copy_mode
page_size = mock_host.page_size
dtype = mock_host.dtype
num_pages = 8
# Disjoint per-thread regions in kv_buffer (indexed by token index).
preset_src = (0, num_pages)
getter_dst = (num_pages, 2 * num_pages)
setter_src = (2 * num_pages, 3 * num_pages)
# zero_copy=page_first uses dim 1 for the token axis; non-zero-copy=
# layer_first uses dim 2. All buffer accesses below go through this so
# the rest of the harness stays layout-agnostic.
def token_index(start_token: int, n_tokens: int):
s = slice(start_token, start_token + n_tokens)
if is_zero_copy_mode:
return (slice(None), s, slice(None), slice(None), slice(None))
return (slice(None), slice(None), s, slice(None), slice(None))
def page_index(start_page: int, n_pages: int):
return token_index(start_page * page_size, n_pages * page_size)
def fill_pages(start_page: int, n_pages: int, value_fn):
"""value_fn(i) -> scalar value for page i."""
for i in range(n_pages):
idx = page_index(start_page + i, 1)
shape = mock_host.kv_buffer[idx].shape
mock_host.kv_buffer[idx] = torch.full(
shape, float(value_fn(i)), dtype=dtype
)
# Phase 1: distinct value per preset page so a wrong-page result is
# detectable; setter source is constant (value irrelevant to the
# test, just needs to be valid).
fill_pages(preset_src[0], num_pages, lambda i: i + 1)
fill_pages(setter_src[0], num_pages, lambda i: -1.0)
preset_keys = [f"preset_{int(is_zero_copy_mode)}_{i}" for i in range(num_pages)]
preset_indices = torch.arange(
preset_src[0] * page_size,
preset_src[1] * page_size,
dtype=torch.int64,
)
self.assertTrue(
all(hicache.batch_set_v1(preset_keys, preset_indices)),
"phase 1: presetting keys failed",
)
# Expected per-page-i payload after a successful get into getter_dst.
expected_pages = [
mock_host.kv_buffer[page_index(preset_src[0] + i, 1)].clone()
for i in range(num_pages)
]
# Phase 2.
stop = threading.Event()
errors: List[str] = []
errors_lock = threading.Lock()
def record_error(msg: str):
with errors_lock:
errors.append(msg)
def getter_loop():
dst_indices = torch.arange(
getter_dst[0] * page_size,
getter_dst[1] * page_size,
dtype=torch.int64,
)
loops = 0
while not stop.is_set():
# Zero the dst pages so a no-op get is observable.
mock_host.kv_buffer[page_index(getter_dst[0], num_pages)] = 0.0
ok = hicache.batch_get_v1(preset_keys, dst_indices)
if not all(ok):
record_error(f"getter loop {loops}: batch_get_v1 returned {ok}")
return
for i in range(num_pages):
got = mock_host.kv_buffer[page_index(getter_dst[0] + i, 1)]
if not torch.equal(got, expected_pages[i]):
record_error(f"getter loop {loops}: preset page {i} corrupted")
return
loops += 1
def setter_loop():
src_indices = torch.arange(
setter_src[0] * page_size,
setter_src[1] * page_size,
dtype=torch.int64,
)
loops = 0
while not stop.is_set():
keys = [
f"setter_{int(is_zero_copy_mode)}_{loops}_{i}"
for i in range(num_pages)
]
ok = hicache.batch_set_v1(keys, src_indices)
if not all(ok):
record_error(f"setter loop {loops}: batch_set_v1 returned {ok}")
return
loops += 1
t_get = threading.Thread(target=getter_loop, daemon=True)
t_set = threading.Thread(target=setter_loop, daemon=True)
t_get.start()
t_set.start()
# Bounded run: long enough to interleave many ops under NIXL I/O
# GIL release, short enough for a unit test.
time.sleep(3.0)
stop.set()
t_get.join(timeout=10)
t_set.join(timeout=10)
self.assertFalse(
t_get.is_alive() or t_set.is_alive(),
"stress threads failed to stop",
)
self.assertEqual(errors, [], f"concurrency errors: {errors}")
@unittest.skipUnless(STRESS_ENABLED, "set SGLANG_RUN_NIXL_STRESS=1 to run")
def test_concurrent_getter_setter_file_zero_copy(self):
"""Stress: concurrent getter+setter, FILE backend, zero-copy."""
self._run_concurrent_stress(is_zero_copy_mode=True)
@unittest.skipUnless(STRESS_ENABLED, "set SGLANG_RUN_NIXL_STRESS=1 to run")
def test_concurrent_getter_setter_file_non_zero_copy(self):
"""Stress: concurrent getter+setter, FILE backend, non-zero-copy."""
self._run_concurrent_stress(is_zero_copy_mode=False)
@unittest.skipUnless(STRESS_ENABLED, "set SGLANG_RUN_NIXL_STRESS=1 to run")
@unittest.skipUnless(
MinioFixture.is_available(), "minio binary or boto3 not available"
)
def test_concurrent_getter_setter_obj_zero_copy(self):
"""Stress: concurrent getter+setter, OBJ backend (MinIO), zero-copy."""
self._run_concurrent_stress(
is_zero_copy_mode=True, hicache=self._make_obj_hicache()
)
@unittest.skipUnless(STRESS_ENABLED, "set SGLANG_RUN_NIXL_STRESS=1 to run")
@unittest.skipUnless(
MinioFixture.is_available(), "minio binary or boto3 not available"
)
def test_concurrent_getter_setter_obj_non_zero_copy(self):
"""Stress: concurrent getter+setter, OBJ backend (MinIO), non-zero-copy."""
self._run_concurrent_stress(
is_zero_copy_mode=False, hicache=self._make_obj_hicache()
)
@unittest.skipUnless(hasattr(os, "O_DIRECT"), "O_DIRECT not available on this platform")
class TestNixlDirectIO(CustomTestCase):
"""Tests for the O_DIRECT file I/O path in NixlFileManager and HiCacheNixl."""
def setUp(self):
self.test_dir = "/tmp/test_nixl_direct_io"
os.makedirs(self.test_dir, exist_ok=True)
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_open_file_sets_o_direct(self):
"""open_file sets O_DIRECT on the file descriptor when use_direct_io=True."""
import fcntl
from sglang.srt.mem_cache.storage.nixl.nixl_utils import NixlFileManager
fm = NixlFileManager(self.test_dir, use_direct_io=True)
test_file = os.path.join(self.test_dir, "test_odirect.bin")
fd = fm.open_file(test_file, create=True)
try:
self.assertTrue(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_DIRECT)
finally:
os.close(fd)
def test_open_file_no_o_direct(self):
"""open_file does not set O_DIRECT when use_direct_io=False."""
import fcntl
from sglang.srt.mem_cache.storage.nixl.nixl_utils import NixlFileManager
fm = NixlFileManager(self.test_dir, use_direct_io=False)
test_file = os.path.join(self.test_dir, "test_buffered.bin")
fd = fm.open_file(test_file, create=True)
try:
self.assertFalse(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_DIRECT)
finally:
os.close(fd)
def _make_direct_io_hicache(self) -> HiCacheNixl:
"""Return a HiCacheNixl configured for O_DIRECT (default) with the POSIX backend."""
storage_config = HiCacheStorageConfig(
tp_rank=0,
tp_size=1,
pp_rank=0,
pp_size=1,
attn_cp_rank=0,
attn_cp_size=1,
is_mla_model=False,
is_page_first_layout=False,
model_name="test_model",
enable_storage_metrics=False,
extra_config={"plugin": {"posix": {"active": True}}},
# use_direct_io defaults to True (env var)
)
try:
return HiCacheNixl(storage_config=storage_config, file_path=self.test_dir)
except ImportError:
self.skipTest("NIXL not available")
def test_needs_page_alignment_true_for_file_backend(self):
"""File-based backend + use_direct_io=True must set needs_page_alignment."""
hicache = self._make_direct_io_hicache()
self.assertTrue(hicache.needs_page_alignment)
def test_odirect_unaligned_pool_falls_back_to_copy(self):
"""O_DIRECT with non-aligned pool strides falls back to copy mode."""
hicache = self._make_direct_io_hicache()
mock_host = MockMemPoolHost(is_zero_copy_mode=True)
hicache.register_mem_pool_host(mock_host)
# MockMemPoolHost.is_stride_page_aligned() returns False, so even though
# the layout would otherwise enable zero-copy, the backend must fall back.
self.assertFalse(hicache.is_zero_copy)
self.assertIsNotNone(hicache._bounce_set)
self.assertIsNotNone(hicache._bounce_get)
def test_odirect_disabled_via_config(self):
"""Top-level use_direct_io=false in extra_config disables O_DIRECT."""
storage_config = HiCacheStorageConfig(
tp_rank=0,
tp_size=1,
pp_rank=0,
pp_size=1,
attn_cp_rank=0,
attn_cp_size=1,
is_mla_model=False,
is_page_first_layout=False,
model_name="test_model",
enable_storage_metrics=False,
extra_config={
"plugin": {"posix": {"active": True}},
"use_direct_io": False,
},
)
try:
hicache = HiCacheNixl(
storage_config=storage_config, file_path=self.test_dir
)
except ImportError:
self.skipTest("NIXL not available")
self.assertFalse(hicache.needs_page_alignment)
self.assertFalse(hicache.file_manager.use_direct_io)
if __name__ == "__main__":
unittest.main()