Add bucketed multi-dir layout for NIXL file storage (#27672)

Co-authored-by: ishandhanani <82981111+ishandhanani@users.noreply.github.com>
This commit is contained in:
qiaozp
2026-06-12 05:34:55 -07:00
committed by GitHub
co-authored by ishandhanani
parent 18989f3d48
commit fcae6767d5
5 changed files with 171 additions and 24 deletions
@@ -81,10 +81,12 @@ If a plugin is configured but its dependencies are missing, it will be skipped.
For POSIX / GDS / GDS_MT file-based backends, the default storage location is `/tmp/hicache_storage`. However, you can customize where cached data is stored:
```bash
export SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR=/path/to/storage/dir
# When specifying multiple storage directories. SGLang routes each cache object to one
# directory with a stable hash.
export SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR=/path/to/storage/dir1,/path/to/storage/dir2,/path/to/storage/dir3
```
This directory is used only for **FILE-backed** plugins. **OBJ-backed** plugins use object keys instead of local files.
These directories are used only for **FILE-backed** plugins. **OBJ-backed** plugins use object keys instead of local files.
### 3. How to Provide Configuration for Backends
@@ -300,7 +302,7 @@ python/sglang/srt/mem_cache/storage/nixl/
### HiCache / NIXL Data Model
- **FILE backends** use local file paths under `SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR`
- **FILE backends** use local file paths under `SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR`. When multiple comma-separated directories are configured, each logical cache key is routed to one base directory with a stable hash and stored as `base_dir/<bucket>/<key>`.
- **OBJ backends** use object keys directly
- **MHA naming** includes TP rank and TP size, so each rank stores its own KV data
- **MLA naming** omits TP rank, so all ranks refer to one shared logical KV object / file
@@ -1,4 +1,5 @@
import logging
import os
import time
import uuid
from typing import Any, List, Optional
@@ -30,6 +31,26 @@ except ImportError as e:
logger = logging.getLogger(__name__)
def _parse_storage_dirs(raw: Optional[str]) -> List[str]:
"""Split NIXL FILE storage directory config into ordered unique paths."""
if not raw:
return []
candidates = [path.strip() for path in raw.split(",")]
candidates = [path for path in candidates if path]
seen: dict[str, str] = {}
ordered: List[str] = []
for path in candidates:
real_path = os.path.realpath(path)
if real_path in seen:
raise ValueError(
"SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR contains duplicate "
f"path {path!r} (same mount as {seen[real_path]!r})."
)
seen[real_path] = path
ordered.append(path)
return ordered
class HiCacheNixl(HiCacheStorage):
"""HiCacheNixl provides high-performance storage using NIXL plugins."""
@@ -49,9 +70,11 @@ class HiCacheNixl(HiCacheStorage):
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
storage_dirs = _parse_storage_dirs(
envs.SGLANG_HICACHE_NIXL_BACKEND_STORAGE_DIR.get() or file_path
)
self.file_manager = (
NixlFileManager(file_path, use_direct_io=use_direct_io)
NixlFileManager(storage_dirs, use_direct_io=use_direct_io)
if plugin not in NixlBackendSelection.OBJ_PLUGINS
else None
)
@@ -0,0 +1,29 @@
"""Deterministic path routing for NIXL FILE-backed HiCache storage."""
import hashlib
_BUCKET_HEX_CHARS = 2
_BUCKET_MASK = (1 << (4 * _BUCKET_HEX_CHARS)) - 1
def stable_key_hash(key: str) -> int:
"""Return a process-stable 64-bit hash for a NIXL storage key."""
return int.from_bytes(
hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest(), "big"
)
def route_key(key: str, num_disks: int) -> tuple[int, str]:
"""Return the storage disk index and bucket directory for a storage key."""
if num_disks <= 0:
raise ValueError("num_disks must be positive")
key_hash = stable_key_hash(key)
return (
(key_hash >> 16) % num_disks,
f"{key_hash & _BUCKET_MASK:0{_BUCKET_HEX_CHARS}x}",
)
def route_disk(key: str, num_disks: int) -> int:
"""Return the storage disk index for a storage key."""
return route_key(key, num_disks)[0]
@@ -3,6 +3,7 @@ import os
from typing import Optional
from sglang.srt.environ import envs
from sglang.srt.mem_cache.storage.nixl.nixl_routing import route_key
logger = logging.getLogger(__name__)
@@ -171,45 +172,60 @@ class NixlBackendSelection:
class NixlFileManager:
"""Handles file system operations for NIXL."""
def __init__(self, base_dir: str, use_direct_io: bool = True):
def __init__(self, base_dir: "list[str] | str", use_direct_io: bool = True):
"""
Initialize file manager.
Args:
base_dir: Base directory for storing tensor files
base_dir: Base directory or ordered base directories for 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
if isinstance(base_dir, str):
self.base_dirs = [base_dir] if base_dir else []
else:
self.base_dirs = [d for d in base_dir if d]
self.use_direct_io = use_direct_io
if base_dir == "":
self._created_bucket_dirs: set[str] = set()
if not self.base_dirs:
logger.debug(
f"Initialized file manager without a base directory. Direct I/O: {use_direct_io}"
)
else:
os.makedirs(base_dir, exist_ok=True)
for base in self.base_dirs:
os.makedirs(base, exist_ok=True)
logger.debug(
f"Initialized file manager with base directory: {base_dir}. Direct I/O: {use_direct_io}"
f"Initialized file manager with base directories: {self.base_dirs}. Direct I/O: {use_direct_io}"
)
def clear(self) -> None:
"""Clear all files in the base directory."""
if self.base_dir == "":
logger.warning("Base directory is empty, skipping clear operation")
"""Clear all files below every configured base directory."""
if not self.base_dirs:
logger.warning("Base directories are empty, skipping clear operation")
return
try:
for root, dirs, files in os.walk(self.base_dir):
for file in files:
os.remove(os.path.join(root, file))
logger.debug(f"Cleared all files in base directory: {self.base_dir}")
except Exception as e:
logger.error(
f"Failed to clear files in base directory {self.base_dir}: {e}"
)
for base in self.base_dirs:
try:
for root, _dirs, files in os.walk(base):
for file in files:
file_path = os.path.join(root, file)
try:
os.remove(file_path)
except OSError as e:
logger.warning(f"Failed to remove file {file_path}: {e}")
except Exception as e:
logger.error(f"Failed to clear base directory {base}: {e}")
logger.debug(f"Cleared all files in base directories: {self.base_dirs}")
def iter_all_base_dirs(self) -> list[str]:
"""Return base directories that may contain NIXL FILE cache entries."""
return list(self.base_dirs)
def get_file_path(self, key: str) -> str:
"""Get full file path for a given key."""
return os.path.join(self.base_dir, key)
if not self.base_dirs:
return key
disk_idx, bucket = route_key(key, len(self.base_dirs))
return os.path.join(self.base_dirs[disk_idx], bucket, key)
def open_file(self, file_path: str, create: bool = False) -> Optional[int]:
"""Open a file and return its file descriptor.
@@ -230,6 +246,11 @@ class NixlFileManager:
"this system. Falling back to buffered I/O."
)
try:
if create:
parent = os.path.dirname(file_path)
if parent and parent not in self._created_bucket_dirs:
os.makedirs(parent, exist_ok=True)
self._created_bucket_dirs.add(parent)
return os.open(file_path, flags, 0o644)
except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}")
@@ -773,5 +773,77 @@ class TestNixlDirectIO(CustomTestCase):
self.assertFalse(hicache.file_manager.use_direct_io)
class TestNixlFileLayout(CustomTestCase):
"""Tests for deterministic NIXL FILE storage path layout."""
def setUp(self):
self.test_dir = tempfile.mkdtemp(prefix="test_nixl_layout_")
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_route_key_is_stable_and_bucketed(self):
from sglang.srt.mem_cache.storage.nixl.nixl_routing import (
_BUCKET_HEX_CHARS,
route_key,
)
self.assertEqual(route_key("page-123", 4), route_key("page-123", 4))
disk_idx, bucket = route_key("page-123", 4)
self.assertGreaterEqual(disk_idx, 0)
self.assertLess(disk_idx, 4)
self.assertEqual(len(bucket), _BUCKET_HEX_CHARS)
self.assertRegex(bucket, rf"^[0-9a-f]{{{_BUCKET_HEX_CHARS}}}$")
def test_route_key_rejects_empty_disk_set(self):
from sglang.srt.mem_cache.storage.nixl.nixl_routing import route_key
with self.assertRaises(ValueError):
route_key("page-123", 0)
def test_file_manager_routes_to_bucketed_base_dir(self):
from sglang.srt.mem_cache.storage.nixl.nixl_routing import (
route_disk,
route_key,
)
from sglang.srt.mem_cache.storage.nixl.nixl_utils import NixlFileManager
base_dirs = [os.path.join(self.test_dir, f"disk{i}") for i in range(3)]
fm = NixlFileManager(base_dirs, use_direct_io=False)
key = "page-123"
disk_idx, bucket = route_key(key, len(base_dirs))
self.assertEqual(route_disk(key, len(base_dirs)), disk_idx)
self.assertEqual(
fm.get_file_path(key), os.path.join(base_dirs[disk_idx], bucket, key)
)
self.assertEqual(fm.iter_all_base_dirs(), base_dirs)
def test_open_file_creates_bucket_directory(self):
from sglang.srt.mem_cache.storage.nixl.nixl_utils import NixlFileManager
fm = NixlFileManager(self.test_dir, use_direct_io=False)
file_path = fm.get_file_path("page-123")
fd = fm.open_file(file_path, create=True)
try:
self.assertIsNotNone(fd)
self.assertTrue(os.path.exists(file_path))
finally:
if fd is not None:
os.close(fd)
def test_clear_removes_nested_bucket_files(self):
from sglang.srt.mem_cache.storage.nixl.nixl_utils import NixlFileManager
fm = NixlFileManager(self.test_dir, use_direct_io=False)
file_path = fm.get_file_path("page-123")
fd = fm.open_file(file_path, create=True)
os.close(fd)
fm.clear()
self.assertFalse(os.path.exists(file_path))
if __name__ == "__main__":
unittest.main()