[HiCache] Add NIXL FILE cache cleaner (#28258)
Co-authored-by: ishandhanani <82981111+ishandhanani@users.noreply.github.com>
This commit is contained in:
@@ -18,10 +18,11 @@ NIXL also supports additional backends such as **AZURE_BLOB**, **GUSLI**, and **
|
||||
|
||||
## Overview
|
||||
|
||||
The NIXL integration consists of two main files:
|
||||
The NIXL integration consists of these main files:
|
||||
|
||||
- **`hicache_nixl.py`** - Main HiCache storage connector using NIXL
|
||||
- **`nixl_utils.py`** - Utility classes for backend selection, registration, and file management
|
||||
- **`nixl_cleaner.py`** - Background FILE-backend disk cleaner
|
||||
|
||||
At runtime, HiCache uses NIXL as a transfer layer between host memory and either:
|
||||
|
||||
@@ -52,6 +53,11 @@ Owns the `(agent, mem_type, file_manager)` triple and exposes `host(...)` and `s
|
||||
|
||||
The current implementation performs per-transfer registration for file / object targets and explicitly closes FILE descriptors after registration / transfer setup to avoid descriptor leaks.
|
||||
|
||||
### L3 Cleaner (`nixl_cleaner.py`)
|
||||
For FILE-backed plugins, TP rank 0 starts a best-effort background cleaner that scans the bucketed storage directories and deletes the oldest logical cache-key groups when disk usage exceeds the configured high watermark. Deleted files are handled by the cache layer as ordinary storage misses and can be recomputed.
|
||||
|
||||
Set the top-level `l3_cleaner_enabled` config key to `false` when an external cleaner is responsible for L3 cache eviction.
|
||||
|
||||
## Using NIXL as the HiCache Storage Backend
|
||||
|
||||
### 1. How Backend Plugin Selection Works
|
||||
@@ -276,6 +282,7 @@ For MLA models, the NIXL backend now mirrors HF3FS's backend-local protection:
|
||||
```text
|
||||
python/sglang/srt/mem_cache/storage/nixl/
|
||||
├── hicache_nixl.py # Main HiCache storage connector
|
||||
├── nixl_cleaner.py # Background FILE-backend disk cleaner
|
||||
├── nixl_utils.py # NIXL utility classes
|
||||
├── test_hicache_nixl_storage.py # Unit tests
|
||||
├── nixl.config.toml.sample # Example configuration
|
||||
@@ -309,6 +316,7 @@ python/sglang/srt/mem_cache/storage/nixl/
|
||||
- In zero-copy mode:
|
||||
- **MHA** expands each logical page into `_k` and `_v` entries
|
||||
- **MLA** expands each logical page into a single `_k` entry because MLA stores one interleaved KV representation
|
||||
- The L3 cleaner groups physical files by the logical base key after removing TP-rank and zero-copy `_k` / `_v` suffixes. This keeps MHA, MLA, and DSA file cleanup aligned with the names emitted by `HiCacheNixl`.
|
||||
|
||||
### Zero-Copy Behavior
|
||||
|
||||
@@ -370,6 +378,9 @@ The following keys are placed at the **top level** of the config file (not insid
|
||||
| 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. |
|
||||
| `l3_cleaner_enabled` | boolean | `true` | Enable the built-in background cleaner for FILE-backed L3 storage. Set to `false` when using an external cleaner. |
|
||||
| `l3_cleaner_high_watermark` | float | `80.0` | Start cleanup when the built-in cleaner is enabled and the filesystem containing a configured storage directory reaches this disk-usage percentage. |
|
||||
| `l3_cleaner_low_watermark` | float | `70.0` | Stop cleanup after hot filesystems drop below this disk-usage percentage. Must be lower than `l3_cleaner_high_watermark`. |
|
||||
|
||||
**Page-alignment and `O_DIRECT`**
|
||||
|
||||
@@ -390,6 +401,28 @@ active = true
|
||||
|
||||
or via environment variable: `SGLANG_HICACHE_NIXL_USE_DIRECT_IO=0`.
|
||||
|
||||
To tune FILE-backend cleanup watermarks:
|
||||
|
||||
```toml
|
||||
l3_cleaner_enabled = true
|
||||
l3_cleaner_high_watermark = 85.0
|
||||
l3_cleaner_low_watermark = 75.0
|
||||
|
||||
[plugin.posix]
|
||||
use_uring = "true"
|
||||
active = true
|
||||
```
|
||||
|
||||
To use an external cleaner instead of the built-in cleaner:
|
||||
|
||||
```toml
|
||||
l3_cleaner_enabled = false
|
||||
|
||||
[plugin.posix]
|
||||
use_uring = "true"
|
||||
active = true
|
||||
```
|
||||
|
||||
|
||||
### 2. POSIX File System Backend (`plugin.posix`)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from sglang.srt.mem_cache.hicache_storage import (
|
||||
)
|
||||
from sglang.srt.mem_cache.mmap_allocator import alloc_mmap
|
||||
from sglang.srt.mem_cache.pool_host import HostKVCache
|
||||
from sglang.srt.mem_cache.storage.nixl.nixl_cleaner import HiCacheL3Cleaner
|
||||
|
||||
from .nixl_registry import NixlRegistry
|
||||
from .nixl_utils import NixlBackendConfig, NixlBackendSelection, NixlFileManager
|
||||
@@ -141,6 +142,28 @@ class HiCacheNixl(HiCacheStorage):
|
||||
self._bounce_set: Optional[torch.Tensor] = None
|
||||
self._bounce_get: Optional[torch.Tensor] = None
|
||||
self._bounce_page_bytes: Optional[int] = None
|
||||
cleanup_dirs = (
|
||||
self.file_manager.iter_all_base_dirs()
|
||||
if self.file_manager is not None
|
||||
else []
|
||||
)
|
||||
cleaner_config = nixlconfig.get_l3_cleaner_config()
|
||||
self._l3_cleaner: Optional[HiCacheL3Cleaner] = (
|
||||
HiCacheL3Cleaner(
|
||||
cleanup_dirs,
|
||||
tp_rank,
|
||||
high_watermark=cleaner_config["high_watermark"],
|
||||
low_watermark=cleaner_config["low_watermark"],
|
||||
)
|
||||
if (
|
||||
cleanup_dirs
|
||||
and self.file_manager is not None
|
||||
and cleaner_config["enabled"]
|
||||
)
|
||||
else None
|
||||
)
|
||||
if self._l3_cleaner is not None:
|
||||
self._l3_cleaner.start()
|
||||
|
||||
def _get_suffixed_key(self, key: str) -> str:
|
||||
return key + self.config_suffix
|
||||
@@ -331,6 +354,9 @@ class HiCacheNixl(HiCacheStorage):
|
||||
self.file_manager.clear()
|
||||
|
||||
def close(self):
|
||||
if self._l3_cleaner is not None:
|
||||
self._l3_cleaner.stop()
|
||||
self._l3_cleaner = None
|
||||
while self._host_regs:
|
||||
reg = self._host_regs.pop()
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
################################################################################
|
||||
|
||||
|
||||
########################################
|
||||
# GLOBAL NIXL HICACHE SETTINGS
|
||||
########################################
|
||||
|
||||
# Open FILE-backend cache files with O_DIRECT when supported.
|
||||
use_direct_io = true
|
||||
|
||||
# Built-in background cleaner for FILE-backed L3 storage. Set to false when an
|
||||
# external cleaner is responsible for eviction. OBJ plugins ignore this.
|
||||
l3_cleaner_enabled = true
|
||||
|
||||
# Background cleaner watermarks for FILE-backed L3 storage. OBJ plugins ignore these.
|
||||
l3_cleaner_high_watermark = 80.0
|
||||
l3_cleaner_low_watermark = 70.0
|
||||
|
||||
|
||||
########################################
|
||||
# POSIX FILE SYSTEM BACKEND
|
||||
########################################
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Background disk cleaner for the NIXL FILE HiCache backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sglang.srt.mem_cache.storage.nixl.nixl_routing import BUCKET_HEX_CHARS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_INTERVAL_SEC = 30.0
|
||||
_DEFAULT_RECHECK_GROUPS = 50
|
||||
_DEFAULT_HIGH_WATERMARK = 80.0
|
||||
_DEFAULT_LOW_WATERMARK = 70.0
|
||||
_RANK_SUFFIX_RE = re.compile(r"_(\d+)_(\d+)$")
|
||||
_KV_SUFFIXES = ("_k", "_v")
|
||||
_BUCKET_NAME_RE = re.compile(rf"^[0-9a-f]{{{BUCKET_HEX_CHARS}}}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _GroupInfo:
|
||||
"""Metadata for one logical cache key group in a cleaner tick."""
|
||||
|
||||
base_key: str
|
||||
mtime: float = 0.0
|
||||
size: int = 0
|
||||
paths: set[str] = field(default_factory=set)
|
||||
# Physical files in one logical group can hash to different base dirs
|
||||
# because TP-rank and zero-copy K/V suffixes are part of the routed key.
|
||||
base_dirs: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
def _parse_group_key(name: str) -> str:
|
||||
"""Return the logical cache-key group for one NIXL FILE object name.
|
||||
|
||||
The physical names are produced by ``HiCacheNixl._get_suffixed_key`` and
|
||||
``HiCacheNixl._get_key_list_from_meta``.
|
||||
"""
|
||||
stem = name
|
||||
for suffix in _KV_SUFFIXES:
|
||||
if stem.endswith(suffix):
|
||||
stem = stem[: -len(suffix)]
|
||||
break
|
||||
|
||||
match = _RANK_SUFFIX_RE.search(stem)
|
||||
if match is not None:
|
||||
stem = stem[: match.start()]
|
||||
return stem
|
||||
|
||||
|
||||
def _safe_unlink(path: str) -> tuple[bool, int]:
|
||||
"""Best-effort unlink; returns whether a file was removed and its size."""
|
||||
try:
|
||||
size = os.stat(path).st_size
|
||||
os.unlink(path)
|
||||
return True, size
|
||||
except FileNotFoundError:
|
||||
logger.debug("NIXL L3 file already removed before cleanup: %s", path)
|
||||
return False, 0
|
||||
except OSError:
|
||||
logger.debug("Failed to unlink NIXL L3 file %s", path, exc_info=True)
|
||||
return False, 0
|
||||
|
||||
|
||||
class HiCacheL3Cleaner:
|
||||
"""Delete old NIXL FILE cache entries when disk usage exceeds watermarks.
|
||||
|
||||
Cleanup operates on physical file names only, so it is compatible with MHA,
|
||||
MLA, and DSA naming as long as the keys are generated by ``HiCacheNixl``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_dirs: list[str] | str,
|
||||
tp_rank: int,
|
||||
*,
|
||||
high_watermark: float = _DEFAULT_HIGH_WATERMARK,
|
||||
low_watermark: float = _DEFAULT_LOW_WATERMARK,
|
||||
interval_sec: float = _DEFAULT_INTERVAL_SEC,
|
||||
recheck_groups: int = _DEFAULT_RECHECK_GROUPS,
|
||||
unlink_workers: Optional[int] = None,
|
||||
) -> None:
|
||||
if isinstance(storage_dirs, str):
|
||||
storage_dirs = [storage_dirs] if storage_dirs else []
|
||||
self.storage_dirs = [path for path in storage_dirs if path]
|
||||
self.tp_rank = tp_rank
|
||||
self.high_watermark = high_watermark
|
||||
self.low_watermark = low_watermark
|
||||
self.interval_sec = interval_sec
|
||||
self.recheck_groups = max(1, recheck_groups)
|
||||
self.unlink_workers = unlink_workers or max(
|
||||
8, 8 * max(len(self.storage_dirs), 1)
|
||||
)
|
||||
|
||||
if self.low_watermark >= self.high_watermark:
|
||||
raise ValueError(
|
||||
"L3 cleaner low_watermark must be lower than high_watermark "
|
||||
f"(low_watermark={self.low_watermark}, "
|
||||
f"high_watermark={self.high_watermark})"
|
||||
)
|
||||
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the cleaner thread on TP rank 0."""
|
||||
if self.tp_rank != 0 or not self.storage_dirs:
|
||||
return
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, name="hicache-l3-cleaner", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"HiCacheL3Cleaner started: dirs=%s high=%.1f%% low=%.1f%% "
|
||||
"interval=%.1fs unlink_workers=%d",
|
||||
self.storage_dirs,
|
||||
self.high_watermark,
|
||||
self.low_watermark,
|
||||
self.interval_sec,
|
||||
self.unlink_workers,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the cleaner thread if it was started."""
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
|
||||
def _disk_usage_pct(self, path: str) -> float:
|
||||
try:
|
||||
stat = os.statvfs(path)
|
||||
except OSError:
|
||||
return 0.0
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
if total == 0:
|
||||
return 0.0
|
||||
available = stat.f_bavail * stat.f_frsize
|
||||
return 100.0 * (total - available) / total
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except Exception:
|
||||
logger.warning("NIXL L3 cleaner tick failed", exc_info=True)
|
||||
if self._stop.wait(self.interval_sec):
|
||||
break
|
||||
|
||||
def _tick(self) -> bool:
|
||||
initial_pcts = {path: self._disk_usage_pct(path) for path in self.storage_dirs}
|
||||
hot_dirs = {
|
||||
path for path, pct in initial_pcts.items() if pct >= self.high_watermark
|
||||
}
|
||||
if not hot_dirs:
|
||||
return False
|
||||
|
||||
scan_start = time.perf_counter()
|
||||
groups: dict[str, _GroupInfo] = {}
|
||||
for base_dir in self.storage_dirs:
|
||||
self._scan_base_dir(base_dir, groups)
|
||||
|
||||
ordered = sorted(
|
||||
(group for group in groups.values() if group.base_dirs & hot_dirs),
|
||||
key=lambda group: group.mtime,
|
||||
)
|
||||
if not ordered:
|
||||
return False
|
||||
|
||||
deleted_groups = 0
|
||||
deleted_files = 0
|
||||
bytes_deleted = 0
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=self.unlink_workers,
|
||||
thread_name_prefix="hicache-l3-unlink",
|
||||
) as pool:
|
||||
idx = 0
|
||||
while idx < len(ordered) and not self._stop.is_set():
|
||||
batch = ordered[idx : idx + self.recheck_groups]
|
||||
paths = list(self._iter_group_paths(batch))
|
||||
for removed, removed_bytes in pool.map(_safe_unlink, paths):
|
||||
if removed:
|
||||
deleted_files += 1
|
||||
bytes_deleted += removed_bytes
|
||||
deleted_groups += len(batch)
|
||||
idx += len(batch)
|
||||
|
||||
if all(
|
||||
self._disk_usage_pct(path) < self.low_watermark for path in hot_dirs
|
||||
):
|
||||
break
|
||||
|
||||
final_pcts = {path: self._disk_usage_pct(path) for path in self.storage_dirs}
|
||||
logger.info(
|
||||
"NIXL L3 cleanup: deleted %d groups / %d files (%.2f GiB) in %.1fs, "
|
||||
"initial_hot=%s final=%s",
|
||||
deleted_groups,
|
||||
deleted_files,
|
||||
bytes_deleted / (1024**3),
|
||||
time.perf_counter() - scan_start,
|
||||
{path: f"{initial_pcts[path]:.1f}%" for path in hot_dirs},
|
||||
{path: f"{pct:.1f}%" for path, pct in final_pcts.items()},
|
||||
)
|
||||
return True
|
||||
|
||||
def _scan_base_dir(self, base_dir: str, groups: dict[str, _GroupInfo]) -> None:
|
||||
if not os.path.isdir(base_dir):
|
||||
return
|
||||
try:
|
||||
bucket_entries = list(os.scandir(base_dir))
|
||||
except OSError:
|
||||
logger.warning("NIXL L3 cleaner failed to scan %s", base_dir, exc_info=True)
|
||||
return
|
||||
|
||||
for bucket_entry in bucket_entries:
|
||||
try:
|
||||
is_bucket_dir = bucket_entry.is_dir(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if (
|
||||
not is_bucket_dir
|
||||
or _BUCKET_NAME_RE.fullmatch(bucket_entry.name) is None
|
||||
):
|
||||
continue
|
||||
self._scan_bucket(base_dir, bucket_entry.path, groups)
|
||||
|
||||
def _scan_bucket(
|
||||
self, base_dir: str, bucket_path: str, groups: dict[str, _GroupInfo]
|
||||
) -> None:
|
||||
try:
|
||||
entries = list(os.scandir(bucket_path))
|
||||
except OSError:
|
||||
logger.debug(
|
||||
"NIXL L3 cleaner skipped bucket %s", bucket_path, exc_info=True
|
||||
)
|
||||
return
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
stat = entry.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
group_key = _parse_group_key(entry.name)
|
||||
group = groups.setdefault(group_key, _GroupInfo(group_key))
|
||||
group.paths.add(entry.path)
|
||||
group.base_dirs.add(base_dir)
|
||||
group.size += stat.st_size
|
||||
group.mtime = max(group.mtime, stat.st_mtime)
|
||||
|
||||
def _iter_group_paths(self, groups: Iterable[_GroupInfo]) -> Iterable[str]:
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for path in sorted(group.paths):
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
yield path
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import hashlib
|
||||
|
||||
_BUCKET_HEX_CHARS = 2
|
||||
_BUCKET_MASK = (1 << (4 * _BUCKET_HEX_CHARS)) - 1
|
||||
BUCKET_HEX_CHARS = 2
|
||||
_BUCKET_MASK = (1 << (4 * BUCKET_HEX_CHARS)) - 1
|
||||
|
||||
|
||||
def stable_key_hash(key: str) -> int:
|
||||
@@ -20,7 +20,7 @@ def route_key(key: str, num_disks: int) -> tuple[int, str]:
|
||||
key_hash = stable_key_hash(key)
|
||||
return (
|
||||
(key_hash >> 16) % num_disks,
|
||||
f"{key_hash & _BUCKET_MASK:0{_BUCKET_HEX_CHARS}x}",
|
||||
f"{key_hash & _BUCKET_MASK:0{BUCKET_HEX_CHARS}x}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ from sglang.srt.mem_cache.storage.nixl.nixl_routing import route_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SGLANG_NIXL_CONFIG_KEYS = {
|
||||
"use_direct_io",
|
||||
"l3_cleaner_enabled",
|
||||
"l3_cleaner_high_watermark",
|
||||
"l3_cleaner_low_watermark",
|
||||
}
|
||||
|
||||
|
||||
class NixlBackendConfig:
|
||||
"""Handles NIXL backend configurations"""
|
||||
@@ -35,6 +42,27 @@ class NixlBackendConfig:
|
||||
return bool(self.config["use_direct_io"])
|
||||
return envs.SGLANG_HICACHE_NIXL_USE_DIRECT_IO.get()
|
||||
|
||||
def get_l3_cleaner_config(self) -> dict:
|
||||
"""Return typed NIXL FILE L3 cleaner options from top-level config."""
|
||||
config = {
|
||||
"enabled": True,
|
||||
"high_watermark": 80.0,
|
||||
"low_watermark": 70.0,
|
||||
}
|
||||
if "l3_cleaner_enabled" in self.config:
|
||||
enabled = self.config["l3_cleaner_enabled"]
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError("l3_cleaner_enabled must be a boolean")
|
||||
config["enabled"] = enabled
|
||||
key_map = {
|
||||
"l3_cleaner_high_watermark": ("high_watermark", float),
|
||||
"l3_cleaner_low_watermark": ("low_watermark", float),
|
||||
}
|
||||
for raw_key, (cleaner_key, parser) in key_map.items():
|
||||
if raw_key in self.config:
|
||||
config[cleaner_key] = parser(self.config[raw_key])
|
||||
return config
|
||||
|
||||
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" """
|
||||
|
||||
@@ -75,6 +103,9 @@ class NixlBackendConfig:
|
||||
config_data = self.config
|
||||
|
||||
for key, value in config_data.items():
|
||||
# These keys are consumed by SGLang itself, not by NIXL plugins.
|
||||
if key in _SGLANG_NIXL_CONFIG_KEYS:
|
||||
continue
|
||||
initparams[key] = str(value)
|
||||
|
||||
return initparams
|
||||
|
||||
Reference in New Issue
Block a user