From d8a5a25c36e8532a0d2908001dc554e5a5e760d4 Mon Sep 17 00:00:00 2001 From: Lukas Humbel Date: Mon, 1 Jun 2026 17:28:53 +0200 Subject: [PATCH] Refactor NIXL hicache. Add O_DIRECT support (#25173) Co-authored-by: Claude Opus 4.7 (1M context) --- .../docs/references/environment_variables.mdx | 10 + python/sglang/srt/environ.py | 5 + .../sglang/srt/managers/cache_controller.py | 17 +- .../sglang/srt/mem_cache/hicache_storage.py | 3 + .../sglang/srt/mem_cache/memory_pool_host.py | 82 +- python/sglang/srt/mem_cache/mmap_allocator.py | 127 +++ .../srt/mem_cache/storage/nixl/README.md | 47 +- .../mem_cache/storage/nixl/hicache_nixl.py | 741 ++++++++--------- .../mem_cache/storage/nixl/nixl_registry.py | 144 ++++ .../srt/mem_cache/storage/nixl/nixl_utils.py | 136 +-- .../storage/nixl/test_hicache_nixl_storage.py | 315 ------- .../mem_cache/test_hicache_nixl_storage.py | 777 ++++++++++++++++++ 12 files changed, 1565 insertions(+), 839 deletions(-) create mode 100644 python/sglang/srt/mem_cache/mmap_allocator.py create mode 100644 python/sglang/srt/mem_cache/storage/nixl/nixl_registry.py delete mode 100755 python/sglang/srt/mem_cache/storage/nixl/test_hicache_nixl_storage.py create mode 100644 test/registered/unit/mem_cache/test_hicache_nixl_storage.py diff --git a/docs_new/docs/references/environment_variables.mdx b/docs_new/docs/references/environment_variables.mdx index e5e6d7189..e222daa0a 100644 --- a/docs_new/docs/references/environment_variables.mdx +++ b/docs_new/docs/references/environment_variables.mdx @@ -837,6 +837,16 @@ SGLang supports various environment variables that can be used to configure its Decode-side incremental KV cache offload stride. Rounded down to a multiple of --page-size (min is --page-size). If unset/invalid/<=0, it falls back to --page-size. Not set (uses --page-size) + + SGLANG_HICACHE_NIXL_USE_DIRECT_IO + Enable O_DIRECT for any file-based NIXL backend (POSIX, GDS, GDS_MT, 3FS) when opening cache files (bypasses the OS page cache, reducing memory pressure and improving throughput on NVMe). Can also be disabled via {'{"use_direct_io": false}'} in --hicache-storage-backend-extra-config. Falls back to buffered I/O with a warning when O_DIRECT is unavailable on the current OS. + true + + + SGLANG_HUGEPAGE_SIZE + Use huge pages for host KV cache allocations (HiCache / disaggregation offload). Valid values: 2MB (2 MiB pages via MAP_HUGE_2MB) or 1GB (1 GiB pages via MAP_HUGE_1GB). Requires huge pages to be pre-allocated on the host OS (/proc/sys/vm/nr_hugepages or /sys/kernel/mm/hugepages). If the allocation fails, the allocator logs a warning and falls back to regular page-size mmap automatically. + Not set (uses OS default page size) + diff --git a/python/sglang/srt/environ.py b/python/sglang/srt/environ.py index 9742bf441..86eb37e8f 100644 --- a/python/sglang/srt/environ.py +++ b/python/sglang/srt/environ.py @@ -342,6 +342,11 @@ class Envs: SGLANG_HICACHE_DECODE_OFFLOAD_STRIDE = EnvInt(None) SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR = EnvStr(None) SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR = EnvStr(None) + # Enable O_DIRECT when opening NIXL POSIX backend files (bypasses OS page cache). + # Disable with SGLANG_HICACHE_NIXL_USE_DIRECT_IO=0 or via the + # "use_direct_io": false key in --hicache-storage-backend-extra-config. + SGLANG_HICACHE_NIXL_USE_DIRECT_IO = EnvBool(True) + SGLANG_HUGEPAGE_SIZE = EnvStr("") # Staging buffer for heterogeneous TP KV transfer SGLANG_DISAGG_STAGING_BUFFER = EnvBool(False) SGLANG_DISAGG_STAGING_BUFFER_SIZE_MB = EnvInt(64) diff --git a/python/sglang/srt/managers/cache_controller.py b/python/sglang/srt/managers/cache_controller.py index cbee102fe..65ef06ab3 100644 --- a/python/sglang/srt/managers/cache_controller.py +++ b/python/sglang/srt/managers/cache_controller.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, List, NamedTuple, Optional import torch from sglang.srt.mem_cache.hicache_storage import ( + STORAGE_BATCH_SIZE, HiCacheStorageConfig, HiCacheStorageExtraInfo, ) @@ -507,8 +508,6 @@ class HiCacheController: self.prefetch_capacity_limit = max( 0, int(0.8 * (self.mem_pool_host.size - self.mem_pool_device.size)) ) - # granularity of batch storage IO operations, in number of pages - self.storage_batch_size = 128 # tracking the number of tokens locked in prefetching, updated by the main scheduler thread self.prefetch_tokens_occupied = 0 @@ -915,8 +914,8 @@ class HiCacheController: def _page_transfer(self, operation): # Transfer batch by batch prefix_keys = operation.prefix_keys - for i in range(0, len(operation.hash_value), self.storage_batch_size): - batch_hashes = operation.hash_value[i : i + self.storage_batch_size] + for i in range(0, len(operation.hash_value), STORAGE_BATCH_SIZE): + batch_hashes = operation.hash_value[i : i + STORAGE_BATCH_SIZE] batch_host_indices = operation.host_indices[ i * self.page_size : (i + len(batch_hashes)) * self.page_size ] @@ -978,11 +977,9 @@ class HiCacheController: hash_value = [] for start in range( - 0, len(tokens_to_fetch), self.page_size * self.storage_batch_size + 0, len(tokens_to_fetch), self.page_size * STORAGE_BATCH_SIZE ): - end = min( - start + self.page_size * self.storage_batch_size, len(tokens_to_fetch) - ) + end = min(start + self.page_size * STORAGE_BATCH_SIZE, len(tokens_to_fetch)) batch_tokens = tokens_to_fetch[start:end] batch_hashes = [] for i in range(0, len(batch_tokens), self.page_size): @@ -1120,8 +1117,8 @@ class HiCacheController: def _page_backup(self, operation): # Backup batch by batch prefix_keys = operation.prefix_keys - for i in range(0, len(operation.hash_value), self.storage_batch_size): - batch_hashes = operation.hash_value[i : i + self.storage_batch_size] + for i in range(0, len(operation.hash_value), STORAGE_BATCH_SIZE): + batch_hashes = operation.hash_value[i : i + STORAGE_BATCH_SIZE] batch_host_indices = operation.host_indices[ i * self.page_size : (i + len(batch_hashes)) * self.page_size ] diff --git a/python/sglang/srt/mem_cache/hicache_storage.py b/python/sglang/srt/mem_cache/hicache_storage.py index b384d4cdc..effbdde8e 100644 --- a/python/sglang/srt/mem_cache/hicache_storage.py +++ b/python/sglang/srt/mem_cache/hicache_storage.py @@ -16,6 +16,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Max pages per batched storage IO call. +STORAGE_BATCH_SIZE = 128 + @dataclass class HiCacheStorageConfig: diff --git a/python/sglang/srt/mem_cache/memory_pool_host.py b/python/sglang/srt/mem_cache/memory_pool_host.py index d5d9d5655..913918e16 100644 --- a/python/sglang/srt/mem_cache/memory_pool_host.py +++ b/python/sglang/srt/mem_cache/memory_pool_host.py @@ -37,6 +37,7 @@ from sglang.srt.mem_cache.memory_pool import ( MHATokenToKVPool, MLATokenToKVPool, ) +from sglang.srt.mem_cache.mmap_allocator import alloc_mmap from sglang.srt.utils import is_cuda, is_hip, is_mps, is_npu, is_xpu _is_cuda = is_cuda() @@ -78,18 +79,19 @@ def synchronized(func): return wrapper -class HostTensorAllocator(abc.ABC): +class HostTensorAllocator: def __init__(self): """Initialize the HostTensorAllocator.""" self.dtype = None self.dims = None def allocate(self, dims: tuple, dtype: torch.dtype, device: str) -> torch.Tensor: - """Allocate a tensor of given dims and dtype on the memory.""" + assert ( + device == "cpu" + ), f"HostTensorAllocator only supports CPU allocations; got device={device!r}" self.dtype = dtype self.dims = dims - tensor = torch.empty(dims, dtype=dtype, device=device) - return tensor + return alloc_mmap(dims, dtype) class HiSparseHostPoolMixin: @@ -187,11 +189,16 @@ def alloc_with_host_register( """ buffer = allocator.allocate(dims, dtype=dtype, device=device) if pin_memory: - ret = torch.cuda.cudart().cudaHostRegister( - buffer.data_ptr(), buffer.numel() * buffer.element_size(), 0 - ) - if ret != 0: - raise RuntimeError(f"cudaHostRegister failed with error code {ret}") + cudart = torch.cuda.cudart() + n_bytes = buffer.numel() * buffer.element_size() + rc = cudart.cudaHostRegister(buffer.data_ptr(), n_bytes, 0) + if int(rc) != 0: + raise RuntimeError( + f"cudaHostRegister failed (rc={int(rc)}, " + f"{cudart.cudaGetErrorString(rc)}) for ptr={buffer.data_ptr():#x} " + f"size={n_bytes}; host buffer is not pinned and device transfers " + f"may silently return stale data." + ) return buffer @@ -324,6 +331,19 @@ class HostKVCache(abc.ABC): """ raise NotImplementedError() + def is_stride_page_aligned(self, page_size_bytes: int = 4096) -> bool: + """Return True if per-page strides are multiples of *page_size_bytes*. + + Subclasses should override this with a layout-specific stride formula. + This base implementation logs a warning and returns False (safe default). + """ + logger.warning( + "%s does not implement is_stride_page_aligned(); assuming not aligned. " + "O_DIRECT with a file-based NIXL backend will fall back to copy mode for this pool.", + type(self).__name__, + ) + return False + @synchronized def clear(self): # Initialize memory states and tracking structures. @@ -850,6 +870,30 @@ class MHATokenToKVPoolHost(HostKVCache): raise ValueError(f"Unsupported layout: {self.layout}") return ptr_list, element_size_list + def is_stride_page_aligned(self, page_size_bytes: int = 4096) -> bool: + """Return True if per-page strides are multiples of *page_size_bytes*. + + When O_DIRECT is used with any file-based NIXL backend, every data pointer + passed to the kernel must be page-aligned. In zero-copy mode the + pointer for KV page ``p`` is: + + base_ptr + p * page_size * layer_num * head_num * head_dim * itemsize + + For this to be page-aligned (given a page-aligned ``base_ptr``) the per-page + stride must itself be a multiple of the OS page size. + """ + if self.layout not in ("page_first", "page_first_direct", "page_head"): + return False + stride = ( + self.page_size + * self.layer_num + * self.head_num + * self.head_dim + * self.dtype.itemsize + ) + base_aligned = self.kv_buffer.data_ptr() % page_size_bytes == 0 + return base_aligned and stride % page_size_bytes == 0 + class MLATokenToKVPoolHost(HiSparseHostPoolMixin, HostKVCache): device_pool: MLATokenToKVPool @@ -1250,6 +1294,26 @@ class MLATokenToKVPoolHost(HiSparseHostPoolMixin, HostKVCache): raise ValueError(f"Unsupported layout: {self.layout}") return ptr_list, element_size_list + def is_stride_page_aligned(self, page_size_bytes: int = 4096) -> bool: + """Return True if per-page strides are multiples of *page_size_bytes*. + + When O_DIRECT is used with any file-based NIXL backend, every data pointer + passed to the kernel must be page-aligned. In zero-copy mode the + pointer for KV page ``p`` is: + + base_ptr + p * page_size * layer_num * kv_cache_dim * itemsize + + For this to be page-aligned (given a page-aligned ``base_ptr``) the per-page + stride must itself be a multiple of the OS page size. + """ + if self.layout not in ("page_first", "page_first_direct"): + return False + stride = ( + self.page_size * self.layer_num * self.kv_cache_dim * self.dtype.itemsize + ) + base_aligned = self.kv_buffer.data_ptr() % page_size_bytes == 0 + return base_aligned and stride % page_size_bytes == 0 + class MambaPoolHost(HostKVCache): diff --git a/python/sglang/srt/mem_cache/mmap_allocator.py b/python/sglang/srt/mem_cache/mmap_allocator.py new file mode 100644 index 000000000..f2aeeabfa --- /dev/null +++ b/python/sglang/srt/mem_cache/mmap_allocator.py @@ -0,0 +1,127 @@ +import ctypes +import ctypes.util +import logging +import math +import mmap +import os +import weakref + +import torch + +from sglang.srt.environ import envs + +logger = logging.getLogger(__name__) + +# Load libc once at module level so munmap is callable safely at GC/shutdown time. +# Resolve the SONAME via find_library so the allocator also works on systems +# whose libc is not named "libc.so.6" (e.g. musl / Alpine). +try: + _libc_name = ctypes.util.find_library("c") or "libc.so.6" + _libc = ctypes.CDLL(_libc_name, use_errno=True) + _libc.mmap.restype = ctypes.c_void_p + _libc.mmap.argtypes = [ + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_long, + ] + _libc.munmap.restype = ctypes.c_int + _libc.munmap.argtypes = [ctypes.c_void_p, ctypes.c_size_t] +except OSError: + _libc = None + +# MAP_POPULATE is in Python's mmap module only since 3.11. +_MAP_POPULATE = getattr(mmap, "MAP_POPULATE", 0x08000) +# MAP_HUGETLB and MAP_HUGE_* are Linux-specific and not in Python's mmap module. +_MAP_HUGETLB = 0x40000 +_MAP_HUGE_2MB = 21 << 26 # 0x1400000 +_MAP_HUGE_1GB = 30 << 26 # 0x78000000 +_MAP_FAILED = ctypes.c_void_p(-1).value + + +def _alloc_hugepage(n_bytes: int, alloc_bytes: int, extra_flags: int) -> ctypes.Array: + """Call mmap via libc with hugepage flags and return an owning ctypes array. + + munmap fires automatically via weakref.finalize when the array is + garbage-collected (i.e. when the tensor that wraps it is freed). + """ + ptr = _libc.mmap( + None, + alloc_bytes, + mmap.PROT_READ | mmap.PROT_WRITE, + mmap.MAP_SHARED | mmap.MAP_ANONYMOUS | _MAP_POPULATE | extra_flags, + -1, + 0, + ) + if ptr is None or ptr == _MAP_FAILED: + errno = ctypes.get_errno() + raise OSError(errno, os.strerror(errno)) + array = (ctypes.c_uint8 * n_bytes).from_address(ptr) + weakref.finalize(array, _libc.munmap, ctypes.c_void_p(ptr), alloc_bytes) + return array + + +def alloc_mmap(dims: tuple, dtype: torch.dtype) -> torch.Tensor: + """Allocate a host tensor via anonymous mmap. Set SGLANG_HUGEPAGE_SIZE=2MB or 1GB for hugepages. + + MAP_SHARED + MAP_POPULATE are both required so cudaHostRegister pins real, + pre-faulted physical pages (otherwise pinning can race with COW or page + faults and the device ends up reading stale data). + + The tensor owns the mapping; munmap fires when the tensor is freed. + """ + # Re-read per call (not cached) so that envs.SGLANG_HUGEPAGE_SIZE.override() + # works correctly in tests. + hugepage_size = (envs.SGLANG_HUGEPAGE_SIZE.get() or "").strip().upper() + n_bytes = math.prod(dims) * torch.empty([], dtype=dtype).element_size() + + if hugepage_size == "": + page_size, extra_flags = mmap.PAGESIZE, 0 + elif hugepage_size == "2MB": + page_size, extra_flags = 2 * 1024 * 1024, _MAP_HUGETLB | _MAP_HUGE_2MB + elif hugepage_size == "1GB": + page_size, extra_flags = 1024 * 1024 * 1024, _MAP_HUGETLB | _MAP_HUGE_1GB + else: + logger.warning( + "Unrecognized SGLANG_HUGEPAGE_SIZE=%r; expected '2MB' or '1GB'. " + "Falling back to plain page-size mmap.", + envs.SGLANG_HUGEPAGE_SIZE.get(), + ) + page_size, extra_flags = mmap.PAGESIZE, 0 + + alloc_bytes = math.ceil(n_bytes / page_size) * page_size + + if extra_flags: + if _libc is None: + logger.error( + "Hugepage mmap requested but libc.so.6 could not be loaded; " + "falling back to plain mmap. SGLANG_HUGEPAGE_SIZE=%s will be ignored.", + hugepage_size, + ) + else: + try: + array = _alloc_hugepage(n_bytes, alloc_bytes, extra_flags) + return torch.frombuffer( + array, dtype=dtype, count=math.prod(dims) + ).reshape(dims) + except OSError as e: + logger.error( + "Hugepage mmap via libc failed (%s); falling back to plain mmap. " + "SGLANG_HUGEPAGE_SIZE=%s will be ignored.", + e, + hugepage_size, + ) + alloc_bytes = math.ceil(n_bytes / mmap.PAGESIZE) * mmap.PAGESIZE + + # Plain mmap path -- used directly when no hugepages requested, or as fallback. + # torch.frombuffer keeps a reference to mm inside the tensor storage, so mm + # stays alive until the tensor is freed and mmap.mmap.__del__ calls munmap. + mm = mmap.mmap( + -1, + alloc_bytes, + flags=mmap.MAP_SHARED | mmap.MAP_ANONYMOUS | _MAP_POPULATE, + prot=mmap.PROT_READ | mmap.PROT_WRITE, + ) + return torch.frombuffer(mm, dtype=dtype, count=math.prod(dims)).reshape(dims) diff --git a/python/sglang/srt/mem_cache/storage/nixl/README.md b/python/sglang/srt/mem_cache/storage/nixl/README.md index 8d1c542d9..7885f979f 100644 --- a/python/sglang/srt/mem_cache/storage/nixl/README.md +++ b/python/sglang/srt/mem_cache/storage/nixl/README.md @@ -45,8 +45,10 @@ The main storage connector that provides: Consolidated utility classes: - **NixlBackendSelection** - Handles backend selection and creation - **NixlBackendConfig** - Handles backend configuration -- **NixlRegistration** - Manages memory registration for tensors, files and objects -- **NixlFileManager** - Handles file system operations and NIXL tuple creation +- **NixlFileManager** - Handles file system operations + +### NixlRegistry (`nixl_registry.py`) +Owns the `(agent, mem_type, file_manager)` triple and exposes `host(...)` and `storage(...)` context managers that register on entry, yield the NIXL `xfer_descs`, and deregister + close fds on exit. Internally composes two single-resource primitives (`_open_files` and `_registered`) so leak-freeness is verifiable per primitive. The current implementation performs per-transfer registration for file / object targets and explicitly closes FILE descriptors after registration / transfer setup to avoid descriptor leaks. @@ -149,7 +151,7 @@ For debugging or quick testing, you may pass a **JSON-style string** directly vi This requires explicitly specifying the plugin type via an environment variable, and this method can be applicable to **only a few** plugins (e.g., POSIX, GDS, GDS_MT) -The below example shows how to use command-line string to use the POSIX plugin where URING is enabled for async POSIX storage. +The below example shows how to use command-line string to use the POSIX plugin where URING is enabled for async POSIX storage, with O_DIRECT enabled (the default). ```bash export SGLANG_HICACHE_NIXL_BACKEND_PLUGIN=POSIX @@ -164,7 +166,17 @@ python3 -m sglang.launch_server \ --hicache-size 64 \ --hicache-write-policy write_through \ --hicache-storage-backend nixl \ - --hicache-storage-backend-extra-config "{'use_uring': 'true'}" + --hicache-storage-backend-extra-config '{"use_uring": "true"}' +``` + +To disable O_DIRECT (e.g. for debugging or unsupported filesystems), set the top-level `use_direct_io` key: + +```bash +export SGLANG_HICACHE_NIXL_BACKEND_PLUGIN=POSIX + +python3 -m sglang.launch_server \ + ... \ + --hicache-storage-backend-extra-config '{"use_direct_io": false, "use_uring": "true"}' ``` ⚠️ **Note**: @@ -349,6 +361,33 @@ An example of the configuration is provided in [`nixl.config.toml.sample`](./nix For object storage, `bucket` may also be omitted from the config if `AWS_DEFAULT_BUCKET` is already defined in the environment. +### 1a. Top-Level Configuration Keys + +The following keys are placed at the **top level** of the config file (not inside any `[plugin.*]` section) and apply globally to the NIXL backend: + +| Key | Type | Default | Description | +| ---------------- | ------- | -------- | ----------- | +| `use_direct_io` | boolean | `true` | Open cache files with `O_DIRECT` to bypass the OS page cache. Reduces memory pressure and improves NVMe throughput. Falls back to buffered I/O with a warning if `O_DIRECT` is unavailable on the current OS. Can also be overridden via the `SGLANG_HICACHE_NIXL_USE_DIRECT_IO` environment variable. | + +**Page-alignment and `O_DIRECT`** + +When `use_direct_io = true` with any file-based backend (POSIX, GDS, GDS_MT, 3FS), the kernel requires every I/O buffer pointer to be OS-page-aligned (4 KiB). SGLang handles this automatically: + +* **Zero-copy mode** (`page_first` / `page_first_direct` layout): the host memory pool is always mmap-backed and therefore page-aligned. If the per-page stride is also a multiple of 4 KiB, zero-copy transfers are used as-is. +* **Copy mode** (all other layouts, or if stride alignment cannot be satisfied): SGLang pre-allocates page-aligned bounce buffers via `mmap` and falls back to copy mode, logging a warning. No user action is required -- this is fully automatic. + +To disable `O_DIRECT` (e.g. for debugging or when the filesystem does not support it): + +```toml +use_direct_io = false + +[plugin.posix] +use_uring = "true" +active = true +``` + +or via environment variable: `SGLANG_HICACHE_NIXL_USE_DIRECT_IO=0`. + ### 2. POSIX File System Backend (`plugin.posix`) diff --git a/python/sglang/srt/mem_cache/storage/nixl/hicache_nixl.py b/python/sglang/srt/mem_cache/storage/nixl/hicache_nixl.py index 5b961076a..45088e0fc 100644 --- a/python/sglang/srt/mem_cache/storage/nixl/hicache_nixl.py +++ b/python/sglang/srt/mem_cache/storage/nixl/hicache_nixl.py @@ -1,27 +1,25 @@ import logging import time import uuid -from typing import Any, List, Optional, Union +from typing import Any, List, Optional import torch from sglang.srt.environ import envs from sglang.srt.mem_cache.hicache_storage import ( + STORAGE_BATCH_SIZE, HiCacheStorage, HiCacheStorageConfig, HiCacheStorageExtraInfo, ) from sglang.srt.mem_cache.memory_pool_host import HostKVCache +from sglang.srt.mem_cache.mmap_allocator import alloc_mmap -from .nixl_utils import ( - NixlBackendConfig, - NixlBackendSelection, - NixlFileManager, - NixlRegistration, -) +from .nixl_registry import NixlRegistry +from .nixl_utils import NixlBackendConfig, NixlBackendSelection, NixlFileManager try: - from nixl._api import nixl_agent, nixl_agent_config + from nixl._api import nixl_agent, nixl_agent_config, nixlBind except ImportError as e: raise ImportError( "Please install NIXL by following the instructions at " @@ -48,15 +46,16 @@ class HiCacheNixl(HiCacheStorage): # select the NIXL backend plugin from extra_config or environment variable plugin = nixlconfig.get_specified_plugin() + use_direct_io = nixlconfig.get_use_direct_io() + # Might be better to be unified across HiCache backends and moved to HiCacheController file_path = envs.SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR.get() or file_path self.file_manager = ( - NixlFileManager(file_path) + NixlFileManager(file_path, use_direct_io=use_direct_io) if plugin not in NixlBackendSelection.OBJ_PLUGINS else None ) - # Initialize suffix based on storage config tp_rank, tp_size, model_name = ( storage_config.tp_rank, storage_config.tp_size, @@ -75,253 +74,156 @@ class HiCacheNixl(HiCacheStorage): else: self.config_suffix = f"_{model_name}_{tp_rank}_{tp_size}" + sync_mode = getattr( + nixlBind, "NIXL_THREAD_SYNC_RW", nixlBind.NIXL_THREAD_SYNC_STRICT + ) agent_config = nixl_agent_config(backends=[]) self.agent_name = f"hicache_nixl_{str(uuid.uuid4())}" self.agent = nixl_agent(self.agent_name, agent_config) + bind_cfg = nixlBind.nixlAgentConfig() + bind_cfg.useProgThread = agent_config.enable_pthread + bind_cfg.useListenThread = agent_config.enable_listen + bind_cfg.listenPort = agent_config.port + bind_cfg.syncMode = sync_mode + bind_cfg.pthrDelay = 0 + bind_cfg.lthrDelay = 100000 + bind_cfg.captureTelemetry = agent_config.capture_telemetry + self.agent.agent = nixlBind.nixlAgent(self.agent_name, bind_cfg) + self.agent.plugin_list = self.agent.agent.getAvailPlugins() self.backend_selector = NixlBackendSelection(plugin, nixlconfig) if not self.backend_selector.create_backend(self.agent): raise RuntimeError("Failed to create NIXL backend") - self.registration = NixlRegistration(self.agent) - self.is_zero_copy = False + self.registry = NixlRegistry( + self.agent, + self.backend_selector.mem_type, + self.file_manager, + ) + # O_DIRECT requires OS-page-aligned I/O buffers on all file-based backends + # (POSIX, GDS, GDS_MT, 3FS). OBJ backends never open files so they are exempt + # (file_manager is None for OBJ). + self.needs_page_alignment = use_direct_io and self.file_manager is not None + if self.needs_page_alignment: + logger.info( + "HiCacheNixl: O_DIRECT is active with a file-based backend (%s). " + "Page-aligned host buffers are required (needs_page_alignment=True).", + self.backend_selector.backend_name, + ) + # Pre-registered host regions (set by register_mem_pool_host): + # zero-copy: one registration covering mem_pool_host.kv_buffer + # non-zero-copy: two registrations, one bounce buffer per direction + # (set/get) so the two storage threads never share slots. + self._host_regs: List[Any] = [] + self._bounce_set: Optional[torch.Tensor] = None + self._bounce_get: Optional[torch.Tensor] = None + self._bounce_page_bytes: Optional[int] = None def _get_suffixed_key(self, key: str) -> str: return key + self.config_suffix - def register_buffers( - self, buffers: Union[torch.Tensor, List[torch.Tensor], List[tuple]] - ) -> Optional[Any]: - """Register tensor(s) or target locations in host memory (list of addr,len tuples) with NIXL.""" - if isinstance(buffers[0], tuple): - tuples = [(x[0], x[1], 0, "") for x in buffers] - return self.registration._register_memory(tuples, "DRAM") - else: - return self.registration._register_memory(buffers) + def _create_query_tuple(self, key: str) -> tuple: + """Build the NIXL query_memory tuple for a single key.""" + if self.backend_selector.mem_type == "FILE": + return (0, 0, 0, self.file_manager.get_file_path(key)) + return (0, 0, 0, key) - def register_files( - self, file_paths: List[str], open_file: Optional[bool] = True - ) -> Optional[Any]: - """Register files with NIXL.""" - tuples = self.file_manager.files_to_nixl_tuples(file_paths) - return self.registration._register_memory(tuples, "FILE") - - def register_objects( - self, keys: List[str], sizes: Optional[List[int]] = None - ) -> Optional[Any]: - """Register objects with NIXL.""" - if not keys: - return None - tuples = [(0, 0, key, "") for key in keys] - return self.registration._register_memory(tuples, "OBJ") - - def _execute_transfer( + def _xfer_and_wait( self, - buffers: Optional[List[torch.Tensor | tuple]], + host_descs: Any, + storage_descs: Any, + direction: str, + ) -> bool: + """Initialize and poll a NIXL transfer to completion.""" + try: + xfer_req = self.agent.initialize_xfer( + direction, host_descs, storage_descs, self.agent_name + ) + except Exception as e: + logger.error(f"Failed to create transfer request: {e}") + return False + + try: + state = self.agent.transfer(xfer_req) + while state != "DONE": + state = self.agent.check_xfer_state(xfer_req) + if state == "ERR": + logger.error("Transfer failed") + return False + # Best would be to have a better notification mechanism from NIXL, + # but we only have polling for now. + time.sleep(0.0001) + return True + except Exception as e: + logger.error(f"Failed to execute transfer: {e}") + import traceback + + logger.error(f"Traceback: {traceback.format_exc()}") + return False + finally: + self.agent.release_xfer_handle(xfer_req) + + def _xfer_pre_registered( + self, + host_buffers: List[tuple], keys: List[str], direction: str, ) -> bool: - if len(buffers) != len(keys): - logger.error("Mismatch between number of tensors/buffers and files/objects") + """Run a transfer where the host side is already pre-registered. + + ``host_buffers`` is a list of ``(addr, size)`` tuples within the + pre-registered host region (kv_buffer for zero-copy, bounce buffer + otherwise). Only the storage side is registered per transfer. + """ + if len(host_buffers) != len(keys): + logger.error("Mismatch between number of host buffers and keys") return False - # Registering file and object keys per transfer, to be updated when - # pre-registration for file and object is added to HiCache. - file_fds = [] - try: - if self.backend_selector.mem_type == "FILE": - tuples = self.file_manager.files_to_nixl_tuples(keys) - file_fds = [t[2] for t in tuples] - if not tuples or not self.registration._register_memory(tuples, "FILE"): - logger.error("Failed to prepare files for transfer") - return False - else: # mem_type == "OBJ" - tuples = [(0, 0, key, "") for key in keys] - if not tuples or not self.registration._register_memory(tuples, "OBJ"): - logger.error("Failed to register objects") - return False + host_descs = self.agent.get_xfer_descs( + [(addr, size, 0) for (addr, size) in host_buffers], "DRAM" + ) + if host_descs is None: + logger.error("Failed to build host xfer descs") + return False - # Prepare transfer descriptors - if isinstance(buffers[0], torch.Tensor): - tensor_sizes = [ - tensor.element_size() * tensor.numel() for tensor in buffers - ] - storage_tuples = [(x[0], s, x[2]) for x, s in zip(tuples, tensor_sizes)] - host_descs = self.agent.get_xfer_descs(buffers) - - if direction in ("READ", "WRITE"): - # register buffer to avoid calling initialize_xfer twice due to missing registration - self.register_buffers(buffers) - - elif isinstance(buffers[0], tuple): - storage_tuples = [(x[0], y[1], x[2]) for x, y in zip(tuples, buffers)] - host_descs = self.agent.get_xfer_descs( - [(x[0], x[1], 0) for x in buffers], "DRAM" - ) - - if direction in ("READ", "WRITE"): - # register buffer to avoid calling initialize_xfer twice due to missing registration - self.register_buffers(buffers) - - else: + with self.registry.storage(host_buffers, keys, direction) as storage_descs: + if storage_descs is None: return False - - storage_descs = self.agent.get_xfer_descs( - storage_tuples, self.backend_selector.mem_type - ) - - if (host_descs is None) or (storage_descs is None): - logger.error("Failed to get transfer descriptors") - return False - - # Initialize transfer, default assumption that tensor was registered - - try: - xfer_req = self.agent.initialize_xfer( - direction, host_descs, storage_descs, self.agent_name - ) - except Exception: - # Check if it was due to missing pre-registration - if not self.register_buffers(buffers): - logger.error("Failed to register tensors/buffers") - return False - - try: - xfer_req = self.agent.initialize_xfer( - direction, host_descs, storage_descs, self.agent_name - ) - except Exception as e: - logger.error(f"Failed to create transfer request: {e}") - return False - - # Execute transfer and wait for its completion - try: - state = self.agent.transfer(xfer_req) - while state != "DONE": - state = self.agent.check_xfer_state(xfer_req) - if state == "ERR": - self.agent.release_xfer_handle(xfer_req) - logger.error("Transfer failed") - return False - time.sleep( - 0.0001 - ) # Can be changed to os.sched_yield() or parametrized - - self.agent.release_xfer_handle(xfer_req) - return True - - except Exception as e: - logger.error(f"Failed to execute transfer: {e}") - import traceback - - logger.error(f"Traceback: {traceback.format_exc()}") - return False - - finally: - for fd in file_fds: - self.file_manager.close_file(fd) + return self._xfer_and_wait(host_descs, storage_descs, direction) def get( self, key: str, - target_location: Optional[torch.Tensor | int] = None, - target_sizes: Optional[int] = None, + target_location: Optional[Any] = None, + target_sizes: Optional[Any] = None, ) -> torch.Tensor | None: - # To be removed, being compatible with the current API - if target_location is None: - return None - if target_sizes: - result = self.batch_get([key], [target_location], [target_sizes]) - else: - result = self.batch_get([key], [target_location]) - return result[0] if result else None + raise NotImplementedError("deprecated; use batch_get_v1") def batch_get( self, keys: List[str], - target_locations: Optional[List[torch.Tensor | int]] = None, - target_sizes: Optional[List[int]] = None, + target_locations: Optional[Any] = None, + target_sizes: Optional[Any] = None, ) -> List[torch.Tensor | None]: - if not keys: - return [] - - # To be removed, being compatible with the current API - if not target_locations: - return [None] * len(keys) - - if target_sizes and (len(target_sizes) != len(target_locations)): - logger.error("Mismatch between number of target_locations and target_sizes") - return [None] * len(keys) - - if target_sizes: - dest = list(zip(target_locations, target_sizes)) - else: - dest = target_locations - - # Add suffix to keys - suffixed_keys = [self._get_suffixed_key(key) for key in keys] - - if self.backend_selector.mem_type == "FILE": - file_paths = [self.file_manager.get_file_path(key) for key in suffixed_keys] - success = self._execute_transfer(dest, file_paths, "READ") - else: - success = self._execute_transfer(dest, suffixed_keys, "READ") - return target_locations if success and not target_sizes else [None] * len(keys) + raise NotImplementedError("deprecated; use batch_get_v1") def set( self, key: str, - value: Optional[torch.Tensor] = None, - target_location: Optional[int] = None, - target_sizes: Optional[int] = None, + value: Optional[Any] = None, + target_location: Optional[Any] = None, + target_sizes: Optional[Any] = None, ) -> bool: - if target_location and target_sizes: - return self.batch_set([key], None, [target_location], [target_sizes]) - else: - return self.batch_set([key], [value]) + raise NotImplementedError("deprecated; use batch_set_v1") def batch_set( self, keys: List[str], - values: Optional[List[torch.Tensor]] = None, - target_locations: Optional[List[int]] = None, - target_sizes: Optional[List[int]] = None, + values: Optional[Any] = None, + target_locations: Optional[Any] = None, + target_sizes: Optional[Any] = None, ) -> bool: - - # skip on MLA backup rank - if self.backup_skip: - return True - - if not keys or (not values and (not target_locations or not target_sizes)): - logger.error("Keys or values were not passed") - return False - - if not values: - values = list(zip(target_locations, target_sizes)) - - # Add suffix to keys - suffixed_keys = [self._get_suffixed_key(key) for key in keys] - - if self.backend_selector.mem_type == "FILE": - file_paths = [] - for key in suffixed_keys: - file_path = self.file_manager.get_file_path(key) - # New file per set, to be updated when partial writes is added to HiCache - if not self.file_manager.create_file(file_path): - logger.error(f"Failed to create file {file_path}") - return False - file_paths.append(file_path) - return self._execute_transfer(values, file_paths, "WRITE") - else: # mem_type == "OBJ" - return self._execute_transfer(values, suffixed_keys, "WRITE") - - ############################################################################ - # batch_*_v1 functions - # zero copy + non-zero-copy version for get, set, exists, batch_exists - ############################################################################ - - def clear(self) -> None: - self.file_manager.clear() + raise NotImplementedError("deprecated; use batch_set_v1") def register_mem_pool_host(self, mem_pool_host: HostKVCache): super().register_mem_pool_host(mem_pool_host) @@ -332,10 +234,96 @@ class HiCacheNixl(HiCacheStorage): "page_first_direct", ] + if self.needs_page_alignment and self.is_zero_copy: + # Check that the kv_buffer base AND per-page strides are multiples of + # the OS page size so every pointer passed to NIXL (base + p * stride) + # is page-aligned. The base is whatever torch.empty() happened to give + # us -- it is not guaranteed to be page-aligned. Fall back to copy mode + # if either condition fails. + # 4096: O_DIRECT alignment is FS-dependent (some allow 512 B); 4 KiB + # is the safe lower bound all known FSes accept, and real page-sizes meet it. + if not self.mem_pool_host.is_stride_page_aligned(4096): + logger.warning( + "HiCacheNixl: O_DIRECT is active but the host kv_buffer is " + "not OS-page-aligned (base or per-page stride). Falling back " + "to copy mode for this pool." + ) + self.is_zero_copy = False + + if self.is_zero_copy: + kv = mem_pool_host.kv_buffer + self._pre_register_host( + kv.data_ptr(), kv.numel() * kv.element_size(), "kv_buffer" + ) + else: + # One bounce buffer per direction so set/get run lock-free across + # the prefetch and backup threads. Sized from get_dummy_flat_data_page() + # so each slot matches what the v1 path would otherwise allocate. + sample = mem_pool_host.get_dummy_flat_data_page() + page_numel = sample.numel() + self._bounce_page_bytes = page_numel * sample.element_size() + del sample + pin_memory = bool(getattr(mem_pool_host, "pin_memory", False)) + self._bounce_set = self._alloc_registered( + page_numel, mem_pool_host.dtype, pin_memory, "bounce_set" + ) + self._bounce_get = self._alloc_registered( + page_numel, mem_pool_host.dtype, pin_memory, "bounce_get" + ) + logger.info( - f"HiCacheNixl: Registered mem_pool_host with layout {self.mem_pool_host.layout}, zero_copy set to {self.is_zero_copy}" + f"HiCacheNixl: pre-registered host regions for " + f"layout={mem_pool_host.layout} zero_copy={self.is_zero_copy}" ) + def _alloc_registered( + self, + page_numel: int, + dtype: torch.dtype, + pin_memory: bool, + kind: str, + ) -> torch.Tensor: + """Allocate a ``(STORAGE_BATCH_SIZE, page_numel)`` bounce buffer and + pre-register it as a DRAM region with NIXL. Uses alloc_mmap so the + buffer is page-aligned -- required when O_DIRECT is on for any + file-based backend (POSIX/GDS/GDS_MT/3FS). pin_memory is currently + unused (alloc_mmap does not support it).""" + buf = alloc_mmap((STORAGE_BATCH_SIZE, page_numel), dtype) + self._pre_register_host(buf.data_ptr(), buf.numel() * buf.element_size(), kind) + return buf + + def _pre_register_host(self, base_addr: int, total_size: int, kind: str) -> None: + """Register a single DRAM region up-front and remember the handle.""" + reg_descs = self.agent.get_reg_descs([(base_addr, total_size, 0, "")], "DRAM") + if reg_descs is None: + raise RuntimeError(f"Failed to build reg descs for host {kind}") + try: + self._host_regs.append(self.agent.register_memory(reg_descs)) + except Exception as e: + raise RuntimeError(f"Failed to pre-register host {kind} with NIXL") from e + + def clear(self) -> None: + if self.file_manager is None: + return + self.file_manager.clear() + + def close(self): + while self._host_regs: + reg = self._host_regs.pop() + try: + self.agent.deregister_memory(reg) + except Exception as e: + logger.debug("deregister of pre-registered host region failed: %s", e) + self._bounce_set = None + self._bounce_get = None + self._bounce_page_bytes = None + + def __del__(self): + try: + self.close() + except Exception: + pass + def exists(self, key: str) -> bool: results = self.batch_exists([key]) return results > 0 @@ -345,8 +333,6 @@ class HiCacheNixl(HiCacheStorage): keys: List[str], extra_info: Optional[HiCacheStorageExtraInfo] = None, ) -> int: - # Add suffix to key - if self.is_zero_copy: key_list = self._get_key_list_from_meta(keys) key_denominator = ( @@ -356,14 +342,7 @@ class HiCacheNixl(HiCacheStorage): key_list = [self._get_suffixed_key(key) for key in keys] key_denominator = 1 - # obtain list of tuples by calling self.registration.create_query_tuples() - tuples = [] - for key in key_list: - tuples += self.registration.create_query_tuples( - key, - self.backend_selector.mem_type, - self.file_manager if self.backend_selector.mem_type == "FILE" else None, - ) + tuples = [self._create_query_tuple(key) for key in key_list] query_res = self.agent.query_memory( tuples, @@ -377,17 +356,14 @@ class HiCacheNixl(HiCacheStorage): return len(query_res) // key_denominator def _get_key_list_from_meta(self, keys: List[str]) -> List[str]: - # construct the key list for NIXL transfer based on the keys and the suffix, for each key, we will have one suffixed key for k buffer and one suffixed key for v buffer if it's not an MLA model, and only one suffixed key for k buffer if it's an MLA model, since MLA model only has k/v interleaved buffer + # Each key maps to a `_k` entry, plus a `_v` entry on non-MLA models + # (MLA stores k/v interleaved in a single buffer). key_list = [] - - for key_ in keys: - suffixed_key = self._get_suffixed_key(key_) - if self.is_mla_model: - key_list.append(f"{suffixed_key}_k") - else: - key_list.append(f"{suffixed_key}_k") + for key in keys: + suffixed_key = self._get_suffixed_key(key) + key_list.append(f"{suffixed_key}_k") + if not self.is_mla_model: key_list.append(f"{suffixed_key}_v") - return key_list def _get_location_and_size_list_from_meta( @@ -403,99 +379,133 @@ class HiCacheNixl(HiCacheStorage): logger.error( f"HiCacheNixl: mismatch between number of keys and number of buffer meta entries, keys: {len(keys)}, key_list: {len(key_list)}, buffer meta entries: {len(ptr_list)}" ) - return [], [], [], [] + return [], [], [] - return key_list, [], ptr_list, element_size_list + return key_list, ptr_list, element_size_list - def _batch_get_preprocess(self, keys: List[str], host_indices: torch.Tensor): - page_num = len(host_indices) // self.mem_pool_host.page_size + def _bounce_slot_buffers(self, buf: torch.Tensor, page_num: int) -> List[tuple]: + """Return ``page_num`` ``(addr, size)`` tuples pointing at the first + ``page_num`` slots of ``buf``. + """ + base = buf.data_ptr() + return [ + (base + i * self._bounce_page_bytes, self._bounce_page_bytes) + for i in range(page_num) + ] + + def _batch_preprocess(self, keys: List[str], host_indices: torch.Tensor, op: str): + """Build (key_list, host_buffers) for the v1 path. + + For zero-copy: ``host_buffers`` are ``(addr, size)`` tuples inside the + pre-registered ``kv_buffer``. + For non-zero-copy: ``host_buffers`` are slots of the direction-specific + pre-registered bounce buffer (``_bounce_set`` for set, ``_bounce_get`` + for get); for ``op == "set"`` we copy the host pages into those slots + here so the subsequent transfer reads from the bounce buffer. + Returns ``([], [])`` on validation failure. + """ + page_size = self.mem_pool_host.page_size + page_num = len(host_indices) // page_size if len(keys) == 0 or len(keys) != page_num: logger.warning( - f"HiCacheNixl: empty keys or mismatch in keys and host_indices lengths. keys: {len(keys)}, host_indices: {len(host_indices)}, page_size: {self.mem_pool_host.page_size}" + f"HiCacheNixl: empty keys or mismatch in keys and host_indices lengths. keys: {len(keys)}, host_indices: {len(host_indices)}, page_size: {page_size}" ) - return [], [], [], [] + return [], [] if self.is_zero_copy: - key_list, _, ptr_list, element_size_list = ( - self._get_location_and_size_list_from_meta(keys, host_indices) + key_list, ptr_list, size_list = self._get_location_and_size_list_from_meta( + keys, host_indices ) - return key_list, [], ptr_list, element_size_list - else: - # non zero copy: create contiguous, temporary tensors - target_tensors = [ - self.mem_pool_host.get_dummy_flat_data_page() for i in range(page_num) - ] + host_buffers = list(zip(ptr_list, size_list)) + return key_list, host_buffers - key_list = [self._get_suffixed_key(key) for key in keys] - ptr_list = [tensor.data_ptr() for tensor in target_tensors] - element_size_list = [ - tensor.numel() * tensor.element_size() for tensor in target_tensors - ] + if page_num > STORAGE_BATCH_SIZE: + logger.error( + f"HiCacheNixl: batch size {page_num} exceeds bounce buffer capacity {STORAGE_BATCH_SIZE}" + ) + return [], [] - return key_list, target_tensors, ptr_list, element_size_list + bounce = self._bounce_set if op == "set" else self._bounce_get + if op == "set": + for i in range(page_num): + src = self.mem_pool_host.get_data_page( + host_indices[i * page_size], flat=True + ) + bounce[i].copy_(src) - def _batch_get_zero_copy_impl( + host_buffers = self._bounce_slot_buffers(bounce, page_num) + key_list = [self._get_suffixed_key(key) for key in keys] + return key_list, host_buffers + + def _batch_xfer( self, keys: List[str], key_strs: List[str], - target_tensors: List[torch.Tensor], - target_locations: List[int], - target_sizes: List[int], - ) -> List[int]: - - if not key_strs or not target_locations or not target_sizes: + host_buffers: List[tuple], + direction: str, + ) -> List[bool]: + """Run a batch READ or WRITE for the v1 path against the pre-registered + host region (no per-transfer host registration). + """ + if not key_strs or not host_buffers: return [False] * len(keys) - if (len(key_strs) != len(target_locations)) or ( - len(target_sizes) != len(target_locations) - ): - logger.error( - "Mismatch between number of key_strs, target_locations and target_sizes" - ) + if len(key_strs) != len(host_buffers): + logger.error("Mismatch between number of key_strs and host_buffers") return [False] * len(keys) - if self.is_zero_copy: - dest = list(zip(target_locations, target_sizes)) - else: - dest = target_tensors - if self.backend_selector.mem_type == "FILE": file_paths = [self.file_manager.get_file_path(key) for key in key_strs] - success = self._execute_transfer(dest, file_paths, "READ") - else: - success = self._execute_transfer(dest, key_strs, "READ") + success = self._xfer_pre_registered(host_buffers, file_paths, direction) + else: # mem_type == "OBJ" + success = self._xfer_pre_registered(host_buffers, key_strs, direction) - return [True] * len(key_strs) if success else [False] * len(key_strs) + # READ results are consumed by _batch_get_postprocess, which pairs + # entries 2*i / 2*i+1 for non-MLA zero-copy: it needs one bool per + # key_str (i.e. per `_k`/`_v` buffer). WRITE results map 1:1 to + # pages, i.e. to `keys`. + result_len = len(key_strs) if direction == "READ" else len(keys) + return [success] * result_len def _batch_get_postprocess( self, host_indices: torch.Tensor, - target_tensors: List[torch.Tensor], results: List[bool], ) -> List[bool]: - - page_num = len(host_indices) // self.mem_pool_host.page_size + page_size = self.mem_pool_host.page_size + page_num = len(host_indices) // page_size if self.is_zero_copy: # zero copy: update final results based on the boolean results from NIXL transfer if self.is_mla_model: return results - else: - results = [ - (results[2 * i] and results[2 * i + 1]) for i in range(page_num) - ] - return results - else: - # non zero copy: copy data from temporary tensors to mem_pool_host page by page - for i in range(page_num): - if not results[i]: - break - self.mem_pool_host.set_from_flat_data_page( - host_indices[i * self.mem_pool_host.page_size], target_tensors[i] - ) + return [(results[2 * i] and results[2 * i + 1]) for i in range(page_num)] - return results + # non zero copy: copy data from the get-side bounce buffer to mem_pool_host + for i in range(page_num): + if not results[i]: + break + self.mem_pool_host.set_from_flat_data_page( + host_indices[i * page_size], self._bounce_get[i] + ) + return results + + def _log_xfer_stats( + self, + op_name: str, + num_keys: int, + host_indices: torch.Tensor, + buffer_sizes: List[int], + elapsed_ms: float, + ) -> None: + total_bytes = sum(s for s in buffer_sizes if s is not None) + bw = total_bytes / (elapsed_ms / 1000) / (1024 * 1024) if elapsed_ms else 0.0 + logger.debug( + f"HiCacheNixl {op_name} transferred: {num_keys} keys (pages), " + f"{host_indices.numel()} host_indices, {total_bytes} bytes, " + f"total time: {elapsed_ms:.3f} ms, effective bandwidth: {bw:.2f} MB/s" + ) def batch_get_v1( self, @@ -503,106 +513,28 @@ class HiCacheNixl(HiCacheStorage): host_indices: torch.Tensor, extra_info: Optional[HiCacheStorageExtraInfo] = None, ) -> List[bool]: - - key_strs, target_tensors, buffer_ptrs, buffer_sizes = ( - self._batch_get_preprocess(keys, host_indices) - ) - - if not key_strs or not buffer_ptrs or not buffer_sizes: + if not self._host_regs: logger.error( - "HiCacheNixl batch_get_v1: preprocessing failed, empty key_strs, buffer_ptrs or buffer_sizes" + "HiCacheNixl batch_get_v1: register_mem_pool_host must be called first" ) return [False] * len(keys) + key_strs, host_buffers = self._batch_preprocess(keys, host_indices, "get") + if not key_strs or not host_buffers: + return [False] * len(keys) + start_time = time.perf_counter() - - results_get = self._batch_get_zero_copy_impl( - keys, key_strs, target_tensors, buffer_ptrs, buffer_sizes + results = self._batch_xfer(keys, key_strs, host_buffers, "READ") + elapsed_ms = (time.perf_counter() - start_time) * 1000 + self._log_xfer_stats( + "batch_get_v1", + len(keys), + host_indices, + [s for _, s in host_buffers], + elapsed_ms, ) - end_time = time.perf_counter() - elapsed_time_ms = (end_time - start_time) * 1000 - total_bytes = sum(s for s in buffer_sizes if s is not None) - - logger.debug( - f"HiCacheNixl batch_get_v1 transferred: {len(keys)} keys (pages), {host_indices.numel()} host_indices, {total_bytes} bytes, total time: {elapsed_time_ms:.3f} ms, effective bandwidth: {total_bytes / (elapsed_time_ms / 1000) / (1024 * 1024):.2f} MB/s" - ) - - return self._batch_get_postprocess(host_indices, target_tensors, results_get) - - def _batch_set_preprocess(self, keys: List[str], host_indices: torch.Tensor): - - page_num = len(host_indices) // self.mem_pool_host.page_size - - if len(keys) == 0 or len(keys) != page_num: - logger.warning( - f"HiCacheNixl: empty keys or mismatch in keys and host_indices lengths. keys: {len(keys)}, host_indices: {len(host_indices)}, page_size: {self.mem_pool_host.page_size}" - ) - return [], [], [], [] - - if self.is_zero_copy: - key_list, _, ptr_list, element_size_list = ( - self._get_location_and_size_list_from_meta(keys, host_indices) - ) - return key_list, [], ptr_list, element_size_list - else: - # non zero copy: NIXL still requires contiguous tensors for transfer - target_tensors = [ - self.mem_pool_host.get_data_page( - host_indices[i * self.mem_pool_host.page_size], flat=False - ).contiguous() - for i in range(page_num) - ] - - key_list = [self._get_suffixed_key(key) for key in keys] - ptr_list = [tensor.data_ptr() for tensor in target_tensors] - element_size_list = [ - tensor.numel() * tensor.element_size() for tensor in target_tensors - ] - - return key_list, target_tensors, ptr_list, element_size_list - - def _batch_set_zero_copy_impl( - self, - keys: List[str], - key_strs: List[str], - target_tensors: List[torch.Tensor], - target_locations: List[int], - target_sizes: List[int], - ) -> List[bool]: - - if not key_strs or not target_locations or not target_sizes: - return [False] * len(keys) - - if (len(key_strs) != len(target_locations)) or ( - len(target_sizes) != len(target_locations) - ): - logger.error( - "Mismatch between number of key_strs, target_locations and target_sizes" - ) - return [False] * len(keys) - - if self.is_zero_copy: - src = list(zip(target_locations, target_sizes)) - else: - src = target_tensors - - if self.backend_selector.mem_type == "FILE": - file_paths = [] - for key in key_strs: - file_path = self.file_manager.get_file_path(key) - # New file per set, to be updated when partial writes is added to HiCache - if not self.file_manager.create_file(file_path): - logger.error( - f"******** Failed to create file {file_path} *********" - ) - return [False] * len(keys) - file_paths.append(file_path) - success = self._execute_transfer(src, file_paths, "WRITE") - else: # mem_type == "OBJ" - success = self._execute_transfer(src, key_strs, "WRITE") - - return [True] * len(keys) if success else [False] * len(keys) + return self._batch_get_postprocess(host_indices, results) def batch_set_v1( self, @@ -610,7 +542,6 @@ class HiCacheNixl(HiCacheStorage): host_indices: torch.Tensor, extra_info: Optional[HiCacheStorageExtraInfo] = None, ) -> List[bool]: - # skip on MLA backup rank if self.backup_skip: return [True] * len(keys) @@ -618,27 +549,25 @@ class HiCacheNixl(HiCacheStorage): if len(keys) == 0: return [] - key_strs, target_tensors, buffer_ptrs, buffer_sizes = ( - self._batch_set_preprocess(keys, host_indices) - ) - - if not key_strs or not buffer_ptrs or not buffer_sizes: + if not self._host_regs: logger.error( - "HiCacheNixl batch_set_v1: preprocessing failed, empty key_strs, buffer_ptrs or buffer_sizes" + "HiCacheNixl batch_set_v1: register_mem_pool_host must be called first" ) return [False] * len(keys) + key_strs, host_buffers = self._batch_preprocess(keys, host_indices, "set") + if not key_strs or not host_buffers: + return [False] * len(keys) + start_time = time.perf_counter() - - results_set = self._batch_set_zero_copy_impl( - keys, key_strs, target_tensors, buffer_ptrs, buffer_sizes + results = self._batch_xfer(keys, key_strs, host_buffers, "WRITE") + elapsed_ms = (time.perf_counter() - start_time) * 1000 + self._log_xfer_stats( + "batch_set_v1", + len(keys), + host_indices, + [s for _, s in host_buffers], + elapsed_ms, ) - end_time = time.perf_counter() - elapsed_time_ms = (end_time - start_time) * 1000 - total_bytes = sum(s for s in buffer_sizes if s is not None) - logger.debug( - f"HiCacheNixl batch_set_v1 transferred: {len(keys)} keys (pages), {host_indices.numel()} host_indices, {total_bytes} bytes, total time: {elapsed_time_ms:.3f} ms, effective bandwidth: {total_bytes / (elapsed_time_ms / 1000) / (1024 * 1024):.2f} MB/s" - ) - - return results_set + return results diff --git a/python/sglang/srt/mem_cache/storage/nixl/nixl_registry.py b/python/sglang/srt/mem_cache/storage/nixl/nixl_registry.py new file mode 100644 index 000000000..f9f341a05 --- /dev/null +++ b/python/sglang/srt/mem_cache/storage/nixl/nixl_registry.py @@ -0,0 +1,144 @@ +"""NIXL memory-registration helpers, exposed as context managers. + +A ``NixlRegistry`` instance bundles the agent, the memory type, and +(optionally) the file manager. Its ``storage(...)`` method is a context +manager that performs the entire register-and-build-descs sequence for +the storage side of a transfer on entry, yields the ``xfer_descs`` (or +None on failure), and unwinds ``agent.deregister_memory`` plus any +``os.close(fd)`` on exit. + +The host side is pre-registered up front by ``HiCacheNixl`` and is not +touched per transfer. +""" + +import logging +import threading +from contextlib import contextmanager +from typing import List, Optional + +from .nixl_utils import NixlFileManager + +logger = logging.getLogger(__name__) + + +def _buffer_sizes(buffers) -> Optional[List[int]]: + """Per-buffer byte sizes for ``(addr, len)`` tuple inputs.""" + if not buffers or not isinstance(buffers[0], tuple): + return None + return [b[1] for b in buffers] + + +class NixlRegistry: + """Owns the (agent, mem_type, file_manager) triple and provides a + context manager for the storage side of a transfer. + + A single instance is created once per HiCacheNixl in __init__ and + reused for every transfer. + """ + + def __init__( + self, + agent, + mem_type: str, + file_manager: Optional[NixlFileManager] = None, + ): + self.agent = agent + self.mem_type = mem_type + self.file_manager = file_manager + # OBJ devIds key a process-wide map in the NIXL OBJ plugin + # (devIdToObjKey_) that is not protected by a lock, so concurrent + # OBJ registrations must use disjoint devId ranges. Allocate them + # from a single monotonic counter. + self._obj_devid_lock = threading.Lock() + self._obj_devid_next = 1 + + @contextmanager + def _open_files(self, paths: List[str], create: bool): + """Open fds for ``paths``; close all of them on exit. + + Yields the list of fds, or None if any open fails (already-opened + fds are closed before returning by the same ``finally``). + """ + fds: List[int] = [] + try: + for path in paths: + fd = self.file_manager.open_file(path, create=create) + if fd is None: + yield None + return + fds.append(fd) + yield fds + finally: + for fd in fds: + self.file_manager.close_file(fd) + + @contextmanager + def _registered(self, items: List[tuple], mem_type: str): + """Register ``items`` with NIXL; deregister on exit. + + Yields the registration handle, or None if registration fails. + """ + reg = None + if items: + reg_descs = self.agent.get_reg_descs(items, mem_type) + if reg_descs is not None: + try: + reg = self.agent.register_memory(reg_descs) + except Exception as e: + logger.error(f"Failed to register memory of type {mem_type}: {e}") + try: + yield reg + finally: + if reg is not None: + try: + self.agent.deregister_memory(reg) + except Exception as e: + logger.debug("deregister_memory skipped: %s", e) + + @contextmanager + def storage(self, buffers, keys, direction): + """Open + register the storage side; deregister and close fds on exit. + + Yields the storage xfer_descs, or None on failure. For the FILE + backend, files are created (O_CREAT) when ``direction == "WRITE"``. + """ + sizes = _buffer_sizes(buffers) + if sizes is None: + yield None + return + + if self.mem_type == "FILE": + with self._open_files(keys, create=(direction == "WRITE")) as fds: + if fds is None: + yield None + return + tuples = [(0, sizes[i], fds[i], keys[i]) for i in range(len(keys))] + with self._registered(tuples, "FILE") as reg: + if reg is None: + yield None + return + yield self.agent.get_xfer_descs( + [(0, sizes[i], fds[i]) for i in range(len(fds))], "FILE" + ) + else: # OBJ + # Reg tuple: (addr=0, size, devId, metaInfo=key). + # Xfer tuple: (addr=0, size, devId). devId links each xfer desc + # back to its registered object's metaInfo, so devIds must be + # unique within the list AND globally unique across concurrent + # storage() calls (the OBJ plugin's devIdToObjKey_ map is shared + # and unlocked). NIXL's pybind layer requires position 3 to be + # int, hence the key goes in metaInfo (position 4). + n = len(keys) + with self._obj_devid_lock: + base = self._obj_devid_next + self._obj_devid_next += n + dev_ids = list(range(base, base + n)) + tuples = [(0, sizes[i], dev_ids[i], keys[i]) for i in range(n)] + with self._registered(tuples, "OBJ") as reg: + if reg is None: + yield None + return + yield self.agent.get_xfer_descs( + [(0, sizes[i], dev_ids[i]) for i in range(n)], + self.mem_type, + ) diff --git a/python/sglang/srt/mem_cache/storage/nixl/nixl_utils.py b/python/sglang/srt/mem_cache/storage/nixl/nixl_utils.py index 9742cf3f3..6e874b63c 100644 --- a/python/sglang/srt/mem_cache/storage/nixl/nixl_utils.py +++ b/python/sglang/srt/mem_cache/storage/nixl/nixl_utils.py @@ -1,8 +1,8 @@ import logging import os -from typing import Any, List, Optional, Tuple, Union +from typing import Optional -import torch +from sglang.srt.environ import envs logger = logging.getLogger(__name__) @@ -23,6 +23,17 @@ class NixlBackendConfig: """ self.config = config or {} + def get_use_direct_io(self) -> bool: + """Return True if O_DIRECT should be requested when opening files. + + Checks the top-level ``use_direct_io`` key in the long-form JSON config first, + then falls back to the ``SGLANG_HICACHE_NIXL_USE_DIRECT_IO`` environment variable + (default: enabled). + """ + if "use_direct_io" in self.config: + return bool(self.config["use_direct_io"]) + return envs.SGLANG_HICACHE_NIXL_USE_DIRECT_IO.get() + def get_specified_plugin(self) -> str: """decide which plugin to use: either config or SGLANG_HICACHE_NIXL_BACKEND_PLUGIN specifies the plugin, if not, use "auto" """ @@ -90,11 +101,6 @@ class NixlBackendSelection: self.mem_type = None self.nixlconfig = nixlconfig - def set_bucket(self, bucket_name: str) -> None: - """Set AWS bucket name in environment variable.""" - os.environ["AWS_DEFAULT_BUCKET"] = bucket_name - logger.debug(f"Set AWS bucket name to: {bucket_name}") - def create_backend(self, agent) -> bool: """Create the appropriate NIXL backend based on configuration.""" try: @@ -162,77 +168,28 @@ class NixlBackendSelection: return False -class NixlRegistration: - """Handles NIXL memory registration.""" - - def __init__(self, agent): - self.agent = agent - - def create_query_tuples( - self, key: str, mem_type: str, file_manager=None - ) -> List[Tuple]: - """Create NIXL tuples for querying memory. - Args: - key: Key to query (file path for FILE or object key for OBJ) - mem_type: Memory type ("FILE" or "OBJ") - file_manager: Optional NixlFileManager for FILE memory type - Returns: - List of NIXL tuples for querying - """ - if mem_type == "FILE": - if file_manager is None: - logger.error("file_manager required for FILE memory type") - return [] - return [(0, 0, 0, file_manager.get_file_path(key))] - else: # OBJ - return [(0, 0, 0, key)] - - def _register_memory( - self, - items: Union[List[tuple], torch.Tensor, List[torch.Tensor]], - mem_type: Optional[str] = None, - ) -> Optional[Any]: - """Common registration logic for files, objects, and buffers. - Args: - items: List of tuples or tensors to register - mem_type: Memory type ("FILE", "OBJ") or None for tensor or list of tensors - """ - if isinstance(items, list) and not items: - return None - - reg_descs = self.agent.get_reg_descs(items, mem_type) - if reg_descs is None: - logger.error("Failed to create registration descriptors") - return None - - try: - registered_memory = self.agent.register_memory(reg_descs) - return registered_memory # Could be None in case of error - except Exception as e: - if not mem_type: - logger.error(f"Failed to register Tensors with NIXL: {e}") - else: - logger.error( - f"Failed to register memory of type {mem_type} with NIXL: {e}" - ) - return None - - class NixlFileManager: """Handles file system operations for NIXL.""" - def __init__(self, base_dir: str): + def __init__(self, base_dir: str, use_direct_io: bool = True): """ Initialize file manager. Args: base_dir: Base directory for storing tensor files + use_direct_io: If True, open files with O_DIRECT (bypasses OS page cache). + Falls back to buffered I/O with a warning when O_DIRECT is unavailable. """ self.base_dir = base_dir + self.use_direct_io = use_direct_io if base_dir == "": - logger.debug(f"Initialized file manager without a base directory") + logger.debug( + f"Initialized file manager without a base directory. Direct I/O: {use_direct_io}" + ) else: os.makedirs(base_dir, exist_ok=True) - logger.debug(f"Initialized file manager with base directory: {base_dir}") + logger.debug( + f"Initialized file manager with base directory: {base_dir}. Direct I/O: {use_direct_io}" + ) def clear(self) -> None: """Clear all files in the base directory.""" @@ -254,23 +211,26 @@ class NixlFileManager: """Get full file path for a given key.""" return os.path.join(self.base_dir, key) - def create_file(self, file_path: str) -> bool: - """Create a file if it doesn't exist.""" - try: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - if not os.path.exists(file_path): - with open(file_path, "wb") as f: - pass # Create empty file - return True - except Exception as e: - logger.error(f"Failed to create file {file_path}: {e}") - return False + def open_file(self, file_path: str, create: bool = False) -> Optional[int]: + """Open a file and return its file descriptor. - def open_file(self, file_path: str) -> Optional[int]: - """Open a file and return its file descriptor.""" + If ``create`` is True, the file is created if it does not exist + (mode 0o644, no truncation). When ``self.use_direct_io`` is True, + the file is opened with ``O_DIRECT`` (bypasses the OS page cache); + falls back to buffered I/O with a warning if ``O_DIRECT`` is + unavailable on this platform. + """ + flags = os.O_RDWR | os.O_CREAT if create else os.O_RDWR + if self.use_direct_io: + if hasattr(os, "O_DIRECT"): + flags |= os.O_DIRECT + else: + logger.warning( + "use_direct_io is True, but O_DIRECT is not available on " + "this system. Falling back to buffered I/O." + ) try: - fd = os.open(file_path, os.O_RDWR) - return fd + return os.open(file_path, flags, 0o644) except Exception as e: logger.error(f"Failed to open file {file_path}: {e}") return None @@ -283,17 +243,3 @@ class NixlFileManager: except Exception as e: logger.error(f"Failed to close file descriptor {fd}: {e}") return False - - def files_to_nixl_tuples( - self, file_paths: List[str] - ) -> List[Tuple[int, int, int, str]]: - """Create NIXL tuples (offset, length, fd, file_path) for given files.""" - tuples = [] - for path in file_paths: - if (fd := self.open_file(path)) is None: - # Clean up on failure - for t in tuples: - self.close_file(t[2]) - return [] - tuples.append((0, 0, fd, path)) - return tuples diff --git a/python/sglang/srt/mem_cache/storage/nixl/test_hicache_nixl_storage.py b/python/sglang/srt/mem_cache/storage/nixl/test_hicache_nixl_storage.py deleted file mode 100755 index 9ddbbb24b..000000000 --- a/python/sglang/srt/mem_cache/storage/nixl/test_hicache_nixl_storage.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env python3 - -import os -import unittest -from typing import List -from unittest.mock import MagicMock - -import torch - -from sglang.srt.mem_cache.hicache_storage import HiCacheStorageConfig -from sglang.srt.mem_cache.storage.nixl.hicache_nixl import HiCacheNixl -from sglang.srt.mem_cache.storage.nixl.nixl_utils import ( - NixlFileManager, - NixlRegistration, -) - - -class TestNixlUnified(unittest.TestCase): - """Unified test suite for all NIXL components.""" - - def setUp(self): - """Set up test environment.""" - # Create test directories - self.test_dir = "/tmp/test_nixl_unified" - os.makedirs(self.test_dir, exist_ok=True) - os.environ["SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR"] = self.test_dir - - # Mock NIXL agent for registration tests - self.mock_agent = MagicMock() - self.mock_agent.get_reg_descs.return_value = "mock_reg_descs" - self.mock_agent.register_memory.return_value = "mock_registered_memory" - - # Create instances - self.file_manager = NixlFileManager(self.test_dir) - self.registration = NixlRegistration(self.mock_agent) - - # Create storage config for testing - 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, - enable_storage_metrics=False, - is_page_first_layout=False, - model_name="test_model", - extra_config={"plugin": {"posix": {"active": True}}}, - ) - - try: - self.hicache = HiCacheNixl( - storage_config=self.storage_config, - file_path=self.test_dir, - ) - self.hicache = HiCacheNixl(storage_config=self.storage_config) - 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): - import shutil - - shutil.rmtree(self.test_dir, ignore_errors=True) - - @staticmethod - def _open_fds() -> int: - return len(os.listdir("/proc/self/fd")) - - def delete_test_file(self, file_path: str) -> bool: - """Helper method to delete a test file. - - Args: - file_path: Path to the file to delete - - Returns: - bool: True if file was deleted or didn't exist, False on error - """ - try: - if os.path.exists(file_path): - os.remove(file_path) - return True - except Exception as e: - return False - - def verify_tensors_equal(self, expected: torch.Tensor, actual: torch.Tensor): - """Helper to verify tensor equality.""" - self.assertIsNotNone(actual, "Retrieved tensor is None") - self.assertTrue( - torch.allclose(expected, actual, atol=1e-6), - f"Tensors not equal:\nExpected: {expected}\nActual: {actual}", - ) - - def verify_tensor_lists_equal( - self, expected: List[torch.Tensor], actual: List[torch.Tensor] - ): - """Helper to verify lists of tensors are equal.""" - self.assertEqual(len(expected), len(actual), "Lists have different lengths") - for exp, act in zip(expected, actual): - self.verify_tensors_equal(exp, act) - - # ============================================================================ - # HiCache Integration Tests - # ============================================================================ - - def test_single_set_get(self): - """Test single tensor set/get operations.""" - key = "test_key" - value = torch.randn(10, 10, device="cpu") - dst_tensor = torch.zeros_like(value, device="cpu") - - # Test set - self.assertTrue(self.hicache.set(key, value)) - self.assertTrue(self.hicache.exists(key)) - - # Test get - retrieved = self.hicache.get(key, dst_tensor) - self.verify_tensors_equal(value, dst_tensor) - self.verify_tensors_equal(value, retrieved) - - # Same test in addr,len mode with another key and dst_tensor - key2 = "test_key2" - dst_tensor2 = torch.zeros_like(value, device="cpu") - src_addr, src_len = value.data_ptr(), value.numel() * value.element_size() - dst_addr, dst_len = ( - dst_tensor2.data_ptr(), - dst_tensor2.numel() * dst_tensor2.element_size(), - ) - - # Test set - self.assertTrue(self.hicache.set(key, None, src_addr, src_len)) - self.assertTrue(self.hicache.exists(key)) - - # Test get - retrieved2 = self.hicache.get(key, dst_addr, dst_len) - self.assertTrue(retrieved2 is None) - self.verify_tensors_equal(value, dst_tensor2) - - def test_batch_set_get(self): - """Test batch tensor set/get operations.""" - keys = ["key1", "key2", "key3"] - values = [ - torch.randn(5, 5, device="cpu"), - torch.randn(3, 3, device="cpu"), - torch.randn(7, 7, device="cpu"), - ] - dst_tensors = [torch.zeros_like(v, device="cpu") for v in values] - - # Test batch set - self.assertTrue(self.hicache.batch_set(keys, values)) - self.assertTrue(all(self.hicache.exists(key) for key in keys)) - - # Test batch get - retrieved = self.hicache.batch_get(keys, dst_tensors) - self.verify_tensor_lists_equal(values, retrieved) - - # Same test in addr,len mode with another key and dst_tensor - keys2 = ["key4", "key5", "key6"] - dst_tensors2 = [torch.zeros_like(v, device="cpu") for v in values] - src_addrs = [v.data_ptr() for v in values] - src_lens = [v.numel() * v.element_size() for v in values] - dst_addrs = [dt.data_ptr() for dt in dst_tensors2] - dst_lens = [dt.numel() * dt.element_size() for dt in dst_tensors2] - - # Test batch set - self.assertTrue(self.hicache.batch_set(keys2, None, src_addrs, src_lens)) - self.assertTrue(all(self.hicache.exists(key) for key in keys2)) - - # Test batch get - retrieved2 = self.hicache.batch_get(keys, dst_addrs, dst_lens) - self.assertTrue(all(ret is None for ret in retrieved2)) - self.verify_tensor_lists_equal(values, dst_tensors2) - - def test_mixed_operations(self): - """Test mixing single and batch operations.""" - # Test interleaved set/get operations - key1, key2 = "key1", "key2" - value1 = torch.randn(4, 4, device="cpu") - value2 = torch.randn(6, 6, device="cpu") - dst1 = torch.zeros_like(value1) - dst2 = torch.zeros_like(value2) - - # Single set/get; baseline after first set absorbs any one-time NIXL internals - self.assertTrue(self.hicache.set(key1, value1)) - fds = self._open_fds() - retrieved1 = self.hicache.get(key1, dst1) - self.verify_tensors_equal(value1, retrieved1) - self.assertEqual(self._open_fds(), fds, "fd leak after get") - - # Batch set/get - self.assertTrue(self.hicache.batch_set([key2], [value2])) - self.assertEqual(self._open_fds(), fds, "fd leak after batch_set") - retrieved2 = self.hicache.batch_get([key2], [dst2]) - self.verify_tensors_equal(value2, retrieved2[0]) - self.assertEqual(self._open_fds(), fds, "fd leak after batch_get") - - def test_data_integrity(self): - """Test data integrity across operations.""" - # Test with various tensor types and sizes - test_cases = [ - ("float32", torch.randn(10, 10, dtype=torch.float32)), - ("float64", torch.randn(5, 5, dtype=torch.float64)), - ("int32", torch.randint(-100, 100, (8, 8), dtype=torch.int32)), - ("int64", torch.randint(-100, 100, (6, 6), dtype=torch.int64)), - ("bool", torch.randint(0, 2, (4, 4)).bool()), - ] - - for name, tensor in test_cases: - with self.subTest(tensor_type=name): - key = f"test_{name}" - dst_tensor = torch.zeros_like(tensor) - - # Set and immediately get - self.assertTrue(self.hicache.set(key, tensor)) - retrieved1 = self.hicache.get(key, dst_tensor) - self.verify_tensors_equal(tensor, retrieved1) - - # Get again to verify persistence - dst_tensor.zero_() - retrieved2 = self.hicache.get(key, dst_tensor) - self.verify_tensors_equal(tensor, retrieved2) - - def test_basic_file_operations(self): - """Test basic file operations.""" - test_file = os.path.join(self.test_dir, "test_file.bin") - self.file_manager.create_file(test_file) - self.assertTrue(os.path.exists(test_file)) - self.assertEqual(os.path.getsize(test_file), 0) # Empty file - - # Test file deletion - self.assertTrue(self.delete_test_file(test_file)) - self.assertFalse(os.path.exists(test_file)) - - def test_create_nixl_tuples(self): - """Test creation of NIXL tuples.""" - test_file = os.path.join(self.test_dir, "test_file.bin") - self.file_manager.create_file(test_file) - - # Test tuple creation - tuples = self.file_manager.files_to_nixl_tuples([test_file]) - self.assertIsNotNone(tuples) - self.assertTrue(len(tuples) > 0) - - def test_error_handling(self): - """Test error handling in file operations.""" - # Test non-existent file - self.assertTrue( - self.delete_test_file("nonexistent_file.bin") - ) # Returns True if file doesn't exist - - # Test invalid file path - self.assertFalse(self.file_manager.create_file("")) # Empty path should fail - - def test_register_buffers(self): - """Test registration of memory buffers.""" - # Create test tensor - tensor = torch.randn(10, 10) - - # Test buffer registration - self.assertIsNotNone(self.hicache.register_buffers(tensor)) - - # Test batch registration - tensors = [torch.randn(5, 5) for _ in range(3)] - self.assertIsNotNone(self.hicache.register_buffers(tensors)) - - def test_register_files(self): - """Test registration of files with NIXL.""" - files = [os.path.join(self.test_dir, f"test_file_{i}.bin") for i in range(3)] - for file in files: - self.file_manager.create_file(file) - - result = self.hicache.register_files(files) - self.assertIsNotNone(result) - - def test_batch_set_v1_skips_on_nonzero_mla_rank(self): - """Test batch_set_v1 is a no-op on nonzero MLA backup ranks.""" - self.hicache.storage_config.is_mla_model = True - self.hicache.storage_config.tp_rank = 1 - self.hicache.backup_skip = True - self.hicache._batch_set_preprocess = MagicMock( - side_effect=AssertionError("batch_set_v1 should have been skipped") - ) - - results = self.hicache.batch_set_v1(["key1", "key2"], torch.tensor([0, 1])) - - self.assertEqual(results, [True, True]) - self.hicache._batch_set_preprocess.assert_not_called() - - def test_batch_exists_zero_copy_mla_uses_single_key_denominator(self): - """Test 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 = MagicMock(return_value=[object(), None]) - - result = self.hicache.batch_exists(["key1", "key2"]) - - self.assertEqual(result, 1) - - def test_batch_exists_zero_copy_mha_uses_two_key_denominator(self): - """Test zero-copy MHA 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 = MagicMock( - return_value=[object(), object(), None, None] - ) - - result = self.hicache.batch_exists(["key1", "key2"]) - - self.assertEqual(result, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/registered/unit/mem_cache/test_hicache_nixl_storage.py b/test/registered/unit/mem_cache/test_hicache_nixl_storage.py new file mode 100644 index 000000000..a6ea710a8 --- /dev/null +++ b/test/registered/unit/mem_cache/test_hicache_nixl_storage.py @@ -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()