Refactor NIXL hicache. Add O_DIRECT support (#25173)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas Humbel
2026-06-01 17:28:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 89feb18eb9
commit d8a5a25c36
12 changed files with 1565 additions and 839 deletions
+5
View File
@@ -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)
+7 -10
View File
@@ -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
]
@@ -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:
@@ -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):
@@ -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)
@@ -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`)
@@ -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
@@ -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,
)
@@ -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
@@ -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()